7

什么?都1202年了,你还在手写骨架屏

 3 years ago
source link: https://segmentfault.com/a/1190000040463903
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

什么?都1202年了,你还在手写骨架屏

发布于 8 月 5 日

小伟是个特别负责的切图崽,最近他恋爱了。

风和日丽的一天,离下班还有半个小时,小伟憧憬着下班和小美的约会,这时产品经理大明找到小伟: 竞品app的H5页面都有占位符(骨架屏),而我们的只有一个菊花图,在网络不好的情况下,用户看到菊花图都不愿意等待就关闭页面了,很影响我们的页面的uv,小伟你赶快安排一下,别人有的我们也不能少(🙄️)。

于是,在大明义正严辞(威逼利诱)的要求下,负(卑) 责(微)的小伟开始了他的骨架屏开发之旅。

用菊花图的页面有10多个,并且基本都是多路由页面,一个一个写?显然不是一个好的方案。

聪明(懒惰)的小伟本着不重复造轮子(白嫖🙄️)的思想,调研了以下几个骨架屏自动化方案.

百度 - vue-skeleton-webpack-plugin

实现原理

通过 vueSSR (vue 服务端渲染)结合 webpack 在构建时渲染写好的 vue 骨架屏组件,将预渲染生成的 DOM 节点和相关样式插入到最终输出的 html 中

不足

  1. 预渲染的骨架屏组件需要开发者编写(对于想偷懒的小伟来说明显不是最优解🙄️)
  2. 方案只适用于vue项目(小伟的H5项目既有react也有vue)

京东 - dps

实现原理

通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架页面的页面,在等待页面加载渲染完成之后, 执行遍历dom树的脚本代码,通过单纯的 DOM 操作,挑选目标节点,生成骨架屏html和css代码

不足

  1. 无法选择生成骨架屏的时机。当页面存在着重定向(H5需要鉴权)的时候,生成的骨架屏和预期相差比较大
  2. 内部实现并不完善,某些元素比如伪元素等无法生成骨架屏
  3. 某些依赖浏览器jsbridge接口的页面,工具无法使用

饿了么 - page-skeleton-webpack-plugin

实现原理

通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架页面的页面,在等待页面加载渲染完成之后,在保留页面布局样式的前提下,通过对页面中元素进行删减或增添,对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片、文字和图片的展现,通过样式覆盖,使得其展示为灰色块。并且将修改后的 HTML 和 CSS 样式提取出来,通过 webpack 插件的形式注入最后生成的html中,并且还可以启动 UI 界面专门调整骨架屏代码。

不足

  1. 由于生成的骨架屏节点是基于页面本身的结构和样式,在某些嵌套比较深的页面,骨架屏代码体积不会很小,并且对于多路由的页面,生成的代码就更加庞大了
  2. 无法选择生成骨架屏的时机。当页面存在着重定向(H5需要鉴权)的时候,生成的骨架屏和预期相差比较大
  3. 某些依赖浏览器jsbridge接口的页面,工具无法使用
  4. 只支持history路由

插曲

调研之后小伟很苦恼,业界方案或多或少都有些问题。 抬头一看已经是晚上11点了,小伟已经错过和小美的晚餐,而且看起来也没有现成的方案可以完全放心使用。对于认真负责的小伟来说,这件事就像一根刺一样扎在小伟的心上。于是小伟心一横,开始了工具的自研之路。

1. 用户可以掌控骨架屏的生成时机(保证当前页面为目标页面)

2. 不和某种框架强耦合

3. 生成的骨架屏代码要尽可能小

4. 自动集成(生成的骨架屏代码不需要手动复制到html文件中)

5. 支持页面多路由(包括hash 路由和 history 路由)

6. 可在真机上触发生成开关(服务于某些严重依赖服务端接口或者客户端jsbridge环境的页面)

项目如何接入工具

要自动生成骨架屏,就必须拿到真实的dom结构,并且要让开发者可以自由选择生成时机,就需要有一个"开关", 最好这个开关是可见的。这样就涉及到开关和骨架屏生成脚本如何集成到用户项目中。

非侵入式的接入最好的方式肯定还是和构建工具结合,这样可以保证开发代码和工具代码不耦合在一起。

未命名文件.jpg

具体实现代码(以webpack plugin为例子)

// webpack v4/v5 compatibility:
// https://github.com/webpack/webpack/issues/11425#issuecomment-690387207
if (webpack.version.startsWith('5.')) { // 如果是webpack 5
  compiler.hooks.compilation.tap(TAP_NAME, (compilation) => {
    compilation.hooks.processAssets.tapAsync(
      {
        name: TAP_NAME,
        stage: webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
      },
      (assets, callback) => {
        this.replaceCode(compilation, assets);
        callback();
      },
    );
  });
} else { // webpack 4
  compiler.hooks.emit.tapAsync(TAP_NAME, (compilation, callback) => {
    this.replaceCode(compilation, compilation.assets);
    callback();
  });
}

replaceCode(compilation, assets) {
  const { options = {} } = this;
  const { htmlName = DEFAULT_HTML_NAME } = options;
  Object.keys(assets).forEach((name) => {
    if (name === htmlName) { // 发现是目标html,执行脚本插入
      const content = this.getReplaceCode(compilation, name);
      updateAsset(compilation, name, content);
    }
  });
}

骨架屏代码如何生成

在我们拿到完整dom结构之后,接下来需要考虑的点是骨架屏幕代码如何生成。

生成的步骤不外乎一下两步:

1. 挑选目标dom节点

2. 将目标dom节点转化成骨架屏代码

如何挑选目标节点

挑选目标节点要遵循两个原则

  1. 精准挑选节点(要保证骨架屏代码要尽可能小,我们只挑选用户首屏可见的节点)
  2. 节点用户可自定义(要保障最后生成都骨架屏代码是用户想要的)

骨架屏代码要尽可能小

  1. 只遍历首屏可见的dom节点
/**
 * 元素是否隐藏
 */
function isHidden(node) {
  const computedStyle = getComputedStyle(node);
  const { display, visibility, opacity } = computedStyle;
  return display === 'none' || visibility === 'hidden' || opacity === '0' || node.hidden;
}

/**
 * 元素是否出现在可视窗口中
 * @param { Object } node HTML element节点
 * @return { Boolean } 元素是否出现在可视窗口中
 */
function isInViewPort(node) {
  const { top, right, bottom, left } = node.getBoundingClientRect();

  return !isHidden(node) && bottom >= 0 && right >= 0 && left <= WINDOW_WIDTH && top <= WINDOW_HEIGHT;
}
  1. 只挑选目标节点
    只挑选有效内容节点:(背景)图片、文字、表单项、音频视频、Canvas、伪元素
const TARGET_TAG_NAME = [
  'audio',
  'button',
  'canvas',
  'code',
  'img',
  'input',
  'pre',
  'svg',
  'textarea',
  'video',
  'xmp',
];

/**
 * dom节点是否包含某个标签
 * @param { Object } node HTML Node节点
 */
const hasTargetLabel = (node) => TARGET_TAG_NAME.includes(node.tagName.toLowerCase());

/**
 * 判断dom节点css属性backgroundImage中是否有url参数,并且作为全局占满全屏
 * @param { Object } node HTML Node节点
 */
const backgroundHasurl = (node) => {
  const hasBackgroundImage = /url\(.+?\)/.test(getComputedStyle(node).backgroundImage);
  const { width, height } = node.getBoundingClientRect();

  return hasBackgroundImage && !(width === WINDOW_WIDTH && height === WINDOW_HEIGHT);
};

/**
 * 判断dom节点子节点中是否有有效内容节点
 * @param { Object } node HTML Node节点
 */
const hasTextNode = (node) => Array.prototype.some.call(node.childNodes, (v) => isTextNode(v));

目标节点用户可自定义

任何工具不管多完美,在复杂的页面场景中都可能有某些缺陷。所以需要留一个入口让用户可自己新增或者删除目标节点。

设定黑名单和白名单

/**
* @param { String } attName
* @param { Object } node node节点
* @return { Boolean } node 节点中是否包含attName
*/
const curryCheckNode = (attName) => (node) => node.hasAttribute(attName);

/**
 * 是否在黑名单中
*/
const isInBlackList = curryCheckNode('unneed-node');

/**
 * 是否在白名单中
*/
const isInWhiteList = curryCheckNode('need-node');

dom节点转化成骨架屏代码

这里我们借鉴了京东 - dps的生成方式。<br/>

对于符合条件的区域,”一视同仁”生成相应区域的颜色块。”一视同仁”即对于符合条件的区域不区分具体元素、不考虑结构层级、不考虑样式,统一根据该区域与视口的绝对距离值生成 div 的颜色块。

只需要获取到dom节点到视口的距离,元素的宽高和圆角,即可生成骨架屏代码。并且将距离和宽高都转化成百分比,这样也可解决骨架屏代码在不同机型下的兼容问题。

  /**
   * 根据node节点生成html
   * @param { Object } node
   */
  generateHtml(node) {
    const computedStyle = getComputedStyle(node);
    let { top, left, width, height } = node.getBoundingClientRect();
    const { boxSizing, paddingTop, paddingLeft, paddingBottom, paddingRight, borderRadius } = computedStyle;
    const isStandardBoxModel = boxSizing === 'border-box';
    width = isStandardBoxModel ? width : width - parseInt(paddingLeft, 10) - parseInt(paddingRight, 10);

    height = isStandardBoxModel ? height : height - parseInt(paddingTop, 10) - parseInt(paddingBottom, 10);

    top = isStandardBoxModel ? top : top + parseInt(paddingTop, 10);
    left = isStandardBoxModel ? left : left + parseInt(paddingLeft, 10);

    this.htmlQueue.push(drawBlock(width, height, top, left, borderRadius));
  }

树的遍历方式选择选择有两种:

  1. bfs(广度优先算法)
  2. dfs(深度优先算法)

这个时候可能有小伙伴会有疑问,这两种方式对最终生成对代码有什么区别嘛?反正在用户视角上看起来其实都是一样的,因为只要目标节点确定了,对于单个dom节点来说生成的骨架屏代码是一样的。

对于单个目标节点来说,生成的骨架屏代码确实是一样的,但是这两种方式对于节点和节点之间组合的顺序是有很大区别的。

由于我们是以pick的形式去挑选节点,所以生成的骨架屏代码和之前的页面代码的结构是会有很大差异。

使用dfs深度遍历可以最大可能的保证生成的节点的在dom树的上下顺序和之前页面的结构一致

traversal() {
  while (this.queue.length) {
    const node = this.queue.shift();

    if (isTextNode(node) || node.id === INSERT_IMG_ID) {
      continue;
    }

     // 非目标节点或者非可视窗口可见元素不做处理
    if ((node.nodeType === 3 && node.textContent.trim().length === 0) || !isTargetNode(node) || !isInViewPort(node))
      continue;
      
    // 目标节点
     if (isAppointed(node) || hasTargetLabel(node) || backgroundHasurl(node) || hasTextNode(node)) {
      this.generateHtml(node);
      continue;
    }

    this.queue.unshift(...Array.from(node.childNodes));
  }
}

如何支持多路由

当用户自己可以掌控骨架屏的生成时机,多路由就不算问题了。只要用户在不同路由中点击生成开关,工具通过url链接获取当前路由,执行骨架屏生成脚本之后,再把路由标识一起传给工具的server,工具再根据标识作为文件名保存下来即可。

未命名文件.png

如何将生成的骨架屏代码插件集成到项目中

当我们把骨架屏代码保存到用户项目之后,我们一样可以借用构建工具插件的功能,遍历骨架屏文件夹,获取所有骨架屏代码,再插入到项目的html中,其实就是一个简版的HtmlWebpackPlugin。

工具插件主流程代码如下(webpack-plugin):

  apply(compiler) {
    assert(compiler.hooks, 'Please upgrade the webpack version to 4 or above!');

    // 生产环境 -> 插入骨架屏幕,开发环境 -> 启动服务,插入生成骨架屏所需代码
    if (isProd(compiler)) {
      this.insertSkeleton(compiler);
      return;
    }

    // 启动服务
    this.startServer(compiler);

    // 编译出错或者watch完成之后关闭服务
    this.watchCompile(compiler);

    // 替换资源
    this.replaceSource(compiler);
  }

圆满结束?

小伟在完成骨架屏自动化工具之后,兴高采烈的开始了工具的实践,随着测试用例(H5页面)的增加,小伟发现了新的问题:

  1. 生成的骨架屏虽然很还原真实的页面结构,但是不够好看
  2. 骨架屏动画的切换也很麻烦,每次切换都需要配置插件中的动画参数重新生成
  3. 修改生成的骨架屏代码之后都需要刷新浏览器才能看到效果
  4. 比较骨架屏页面和真实的H5页面需要来回切换着看,并且对于半屏H5页面来说,也很难还原场景
  5. 开发者不满意的dom节点,需要通过添加节点黑名单重新生成,操作很繁琐

这些问题的出现让小伟这个完美主义者陷入了沉思,他抬了抬头看了看时间,已经是夜里的12点。小伟因为工具的研发已经一周没有和小美一起吃晚饭了,想到此处,一股淡淡的哀伤袭上他的心头。

等等,如果工具有一个编辑器,那么是不是就可以很快的完成这些调优操作了?

想到此处,小伟开始了他的编辑器开发之旅。

骨架屏编辑器

  1. 可以对生成的骨架屏节点进行拖拽,删除,和复制。
  2. 一键切换动画
  3. 一键保存
  4. 实时预览: 支持hot reload,保存骨架屏幕代码之后编辑器页面立马刷新
  5. 提供对比窗口: 可直观的看出原页面和骨架屏页面的差异
  6. 效果预览
  7. 可直接通过浏览器提供的devtool修改样式代码,点击保存之后样式代码会同步更新到项目中

如何对骨架屏dom节点进行拖拽

拖拽很容易实现,不外乎对dom元素绑定mouse事件,计算移动距离,完成元素的移动。
这里我们要注意两个点:

  1. 由于骨架屏节点可能会有很多,所以我们采取事件委托的方式处理mouse事件。
  2. 前面我们说过: 骨架屏dom节点距离视口的长度是是百分比为单位。所以我们在拖拽过程中转化的转化的单位也应该是百分比。我们在窗口内移动节点,计算离视口的距离也应该是距离预览窗口的距离,所以我们需要使用iframe作为骨架屏预览窗口。
  <div
    class="edit-wrap"
    :style="{ width: width + 'px', height: height + 'px' }"
  >
    <iframe
      ref="code"
      id="code"
      :width="width"
      :height="height"
      frameborder="0"
      scrolling="no"
      marginheight="0"
      marginwidth="0"
      :src="iframeUrl"
    ></iframe>
  </div>

如何实现对比窗口

先来看一张效果图

image.png
在上图中我们可以清晰的看到左边的真实页面。我们在用户点击开关生成骨架屏的时候,也同时对当前页面进行了截图,并且将图片转化成base64传给工具server,工具server再把图片保存到用户工程项目中,具体流程如下:

未命名文件的副本.png

截图我们采用的是html2canvas(站在巨人的肩膀上😄),但是在使用的过程中我们发现一个问题: 图片跨域

最后我们在工具server中提供了一层proxy,才解决了这个问题。

如何实时预览

在编辑器启动的时候,工具server会和编辑器建立长连接,并且会实时监听用户项目中的骨架屏文件中文件的变化,当文件改变时,push消息到编辑器,编辑器刷新页面。

  // 工具server代码
  initSocket(server) {
    const { log, pathname } = this;
    const io = socketIo(server);

    io.on('connection', (socket) => {
      socket.emit('open');
    });

    chokidar.watch(pathname).on('change', (path) => {
      log.info(`${path} is change!`);
      io.sockets.emit('reload');
    });
  }
  
  // 编辑器代码
  socket.on('reload', () => {
    window.location.reload();
  });

在浏览器的devtool中修改代码,如何同步修改结果<br/>
做为前端小伙伴,对devtool肯定是再熟悉不过了, 那么编辑器是怎么做到可以同步devtool中的修改?

这个和我们骨架屏代码特点有关系,前面我们也说了我们骨架屏的dom节点的样式中有一个白名单: width, height, top, left, borderRadius,background, animation.

当用户在devtool修改完代码样式之后,我们只需要遍历iframe中的骨架屏节点,通过getComputedStyle获取白名单样式,生成修改后的代码,通过工具server保存到项目中即可。

整体架构图

未命名文件.png

编辑器总览图

image.png

无痛接入smart-skeleton-screen

安装插件包 (根据项目构建工具选择相关的插件包,以webpack为例)

tnpm install @tencent/smart-skeleton-screen

构建工具引入

const SmartSkeletonScreen = require('@tencent/smart-skeleton-screen').plugin;

new SmartSkeletonScreen({
   background: '#33333324',
   serverUrl: 'https://server.qq.com',
   port: 4001,
   pathname: path.join(__dirname, 'src/pages/fans/skeleton'),
}),

html文件中插入替换标识符

<div id="app"><% smart-skeleton %></div>

工具近期会开源,大家感兴趣的话可以收藏我这篇文章,我代表小伟谢谢大家😁


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK