骨架屏自动化?看这篇就够了

3,945 阅读13分钟

前言

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

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

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

方案调研

用菊花图的页面有10多个,并且基本都是多路由页面,一个一个写?显然不是一个好的方案。
聪明(懒惰)的小伟本着不重复造轮子(白嫖🙄️)的思想,调研了以下几个骨架屏自动化方案.

百度 - vue-skeleton-webpack-plugin

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的生成方式。

对于符合条件的区域,”一视同仁”生成相应区域的颜色块。”一视同仁”即对于符合条件的区域不区分具体元素、不考虑结构层级、不考虑样式,统一根据该区域与视口的绝对距离值生成 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中修改代码,如何同步修改结果
做为前端小伙伴,对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>