【第1906期】考拉前端骨架屏生成技术揭秘

作者:子楼

前言

赞做成事的方式,看到由一个体验的问题发展成一个可视化的平台。今日早读文章由阿里@子楼分享,

@子楼,考拉体验技术部,负责考拉交易链路相关业务。专注于前端架构以及前端自动化测试领域,开源产品包括数据 Mock 方案 macaca-datahub、基于业务链路的测试报告器 macaca-repoter、骨架屏生成工具 awesome-skeleton等。

正文从这开始~~

为什么要使用骨架屏

骨架屏就是在页面数据尚未加载前,先给用户展示出页面的大致结构(灰色占位图),直到请求数据返回后再渲染页面,补充进需要显示的数据内容,考拉H5购物车就使用了骨架屏技术:

了解了骨架屏是什么,我们来看看为什么要使用骨架屏。假如能在加载前把网页的大概轮廓预先显示,接着再逐渐加载真正内容,这样既降低了用户的焦灼情绪,又能使界面加载过程变得自然通畅,不会造成网页长时间白屏或者闪烁。骨架屏能给人一种页面内容“已经渲染出一部分”的感觉,相较于传统的 loading 效果,在一定程度上可提升用户体验。尤其在下面场景中,骨架屏技术能极大提高用户体验:

骨架屏技术总览

目前主流的骨架屏生成技术主要包括以下三种:

前两种情况由于变更成本和续维护成本高,且对业务代码有一定侵入性,不进行讨论。业界对于自动生成骨架屏有多种实践,但是存在一些问题,有些配置较少,生成效果较差;有些操作繁琐,项目集成成本高,且难以定制。

本文主要针对自动生成骨架屏技术进行了深入的探讨,并开发了 awesome-skeleton,支持多种配置,以及骨架屏定制功能,并提供骨架图生成和骨架图模板注入能力。

自动生成骨架屏技术揭秘

谷歌浏览器在2017年自行开发了 Chrome Headless 特性,并与之同时推出了 Puppeteer。Puppeteer 是一个 Node库,提供了一组用来操纵 Chrome 的 API,默认 Headless 也就是无界面的chrome,俗称“无头浏览器”。我们在浏览器中完成的大多数操作都可以在 Puppeteer 中完成,比如截图、爬虫、自动化测试、性能分析等。

借助 Puppeteer,我们对自动生成骨架屏方案进行了如下设计:

我们可以通过传入页面地址,使用无头浏览器打开页面,对页面首屏图片和文本等节点进行灰色背景处理,然后对页面首屏进行截图,生成压缩后的 base64 png 图片,并注入 HTML + CSS,从而自动生成页面骨架屏。流程见下图:

使用 Puppeteer 渲染页面

使用 Puppeteer 可以指定不同设备模拟器来渲染页面,从而获取页面的 DOM 结构。关键代码:

  1. // 初始化无头浏览器

  2. const browser = await puppeteer.launch({

  3. headless: !options.debug, // 是否打开无头浏览器

  4. args: [ '--no-sandbox', '--disable-setuid-sandbox' ],

  5. });


  6. // 打开新页面

  7. const page = await browser.newPage();


  8. // 指定设备模拟器

  9. const device = devices[options.device] || desktopDevice;

  10. await page.emulate(device);


  11. // 打开指定页面

  12. await page.goto(options.pageUrl);

获取到页面 DOM 结构之后,需要对其进行处理,例如将图片转换为灰色色块,隐藏大小小于一定阈值的节点,这里我们通过向页面中动态插入一端 JavaScript 脚本,对页面节点进行处理,从而生成骨架屏。关键代码:

  1. // 获取处理 DOM 节点的脚本代码

  2. const scriptContent = await genScriptContent();


  3. // 将代码插入到页面中

  4. await page.addScriptTag({ content: scriptContent });

页面 DOM 处理

下面我们来重点看看如何对页面节点进行处理,主要包括以下内容:

预处理

首先,对页面所有节点进行下列处理:

文本处理

本文处理是所有节点中比较复杂的一种,需要考虑文本是一行还是多行、文本是位置是居中还是左对齐等,通过获取文本位置和样式,通过简单的计算来设置灰色色块位置。下面只列出了关键代码,全部代码见:text.js。

  1. // 获取行高

  2. if (!/\d/.test(lineHeight)) {

  3. const fontSizeNum = parseInt(fontSize, 10) || 14;

  4. lineHeight = `${fontSizeNum * 1.4}px`;

  5. }


  6. // 获取行数

  7. let lineCount = (height - parseFloat(paddingTop, 10) - parseFloat(paddingBottom, 10)) / parseFloat(lineHeight, 10) || 0;

  8. lineCount = lineCount < 1.5 ? 1 : lineCount;


  9. // 设置文本色块样式

  10. ele.classList.add(SKELETON_TEXT_CLASS);

  11. Object.assign(ele.style, {

  12. backgroundImage: `linear-gradient(

  13. transparent ${(1 - textHeightRatio) / 2 * 100}%,

  14. ${MAIN_COLOR} 0%,

  15. ${MAIN_COLOR} ${((1 - textHeightRatio) / 2 + textHeightRatio) * 100}%,

  16. transparent 0%

  17. )`,

  18. backgroundSize: `100% ${px2rem(parseInt(lineHeight) * 1.1)}`,

  19. position,

  20. });


  21. // 添加文本Mask

  22. if (lineCount > 1) { // 多行情况特殊处理

  23. addTextMask(ele, Object.assign(JSON.parse(JSON.stringify(comStyle)), {

  24. lineHeight,

  25. }));

  26. } else { // 单行文本处理

  27. const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10));

  28. ele.style.backgroundSize = `${textWidthPercent * 100}% 100%`;

  29. switch (textAlign) {

  30. case 'left':

  31. break;

  32. case 'right':

  33. ele.style.backgroundPositionX = '100%';

  34. break;

  35. default: // center

  36. ele.style.backgroundPositionX = '50%';

  37. break;

  38. }

  39. }

列表处理

可以根据配置,对列表进行重复处理,也就是根据列表的第一项重复渲染其他项,关键代码:

  1. const listHandler = (node, options) => {

  2. if (!options.openRepeatList || !node.children.length) return;


  3. const children = node.children;

  4. const len = Array.from(children).filter(child => LIST_ITEM_TAG.indexOf(child.tagName) > -1).length;


  5. if (len === 0) return false;


  6. const firstChild = children[0];

  7. // 若元素不是列表节点,则递归处理

  8. if (LIST_ITEM_TAG.indexOf(firstChild.tagName) === -1) {

  9. return listHandler(firstChild, options);

  10. }


  11. // 只保留第一个项目

  12. Array.from(children).forEach((li, index) => {

  13. if (index > 0) {

  14. removeElement(li);

  15. }

  16. });


  17. // 重复渲染剩余项

  18. for (let i = 1; i < len; i++) {

  19. node.appendChild(firstChild.cloneNode(true));

  20. }

  21. };

其他节点

其他节点的处理较为简单,有兴趣可以在 Github 上查看代码,这里有几个需要关注的点:

文本类型特殊处理

需要注意的是,对于文本节点,由于存在下面的情况,所以需要进行特殊处理:

  1. <span>111<a>222</a></span> 文本中包含超链接

  2. <span>111<img src="xx" /></span> 文本中包含图片

  3. 111 文本没有使用标签包裹

文本类型进行处理的关键代码:

  1. handleText(node) {

  2. const tagName = node.tagName && node.tagName.toUpperCase();


  3. // 处理 <div>xxx</div> or <a>xxx</a>

  4. if (node.childNodes && node.childNodes.length === 1 && node.childNodes[0].nodeType === 3) {

  5. handler.text(node, this.options);

  6. return true;

  7. }


  8. // 处理 xxx,转换为 <i>xxx</i>

  9. if (node && node.nodeType === 3 && node.textContent) {

  10. const parent = node.parentNode;

  11. // Determine if it has been processed

  12. if (!parent.classList.contains(SKELETON_TEXT_CLASS)) {

  13. // It is plain text itself and needs to be replaced with a node

  14. const textContent = node.textContent.replace(/[\r\n]/g, '').trim();

  15. if (textContent) {

  16. const tmpNode = document.createElement('i');

  17. tmpNode.classList.add(SKELETON_TEXT_CLASS);

  18. tmpNode.innerText = textContent;

  19. node.parentNode.replaceChild(tmpNode, node);

  20. handler.text(tmpNode, this.options);

  21. return true;

  22. }

  23. }

  24. }


  25. // 处理 <span>111<a>222</a></span> <span>111<img src="xx" /></span>

  26. if (tagName === 'SPAN' && node.innerHTML) {

  27. // Process image and background image first

  28. this.handleImages(node.childNodes);


  29. handler.text(node, this.options);

  30. return true;

  31. }


  32. return false;

  33. },

处理页面节点入口函数

有些上述单个节点的处理函数,我们可以遍历页面所有节点进行处理。关键代码:

  1. handleNode(node) {

  2. if (!node) return;


  3. // Delete elements that are not in first screen, or marked for deletion

  4. if (!inViewPort(node) || hasAttr(node, 'data-skeleton-remove')) {

  5. return removeElement(node);

  6. }


  7. // Handling elements that are ignored by user tags -> End

  8. const ignore = hasAttr(node, 'data-skeleton-ignore') || node.tagName === 'STYLE';

  9. if (ignore) return;


  10. // Preprocessing some styles

  11. handler.before(node, this.options);


  12. // Preprocessing pseudo-class style

  13. handler.pseudo(node, this.options);


  14. const tagName = node.tagName && node.tagName.toUpperCase();

  15. const isBtn = tagName && (tagName === 'BUTTON' || /(btn)|(button)/g.test(node.getAttribute('class')));


  16. let isCompleted = false;

  17. switch (tagName) {

  18. case 'SCRIPT':

  19. handler.script(node);

  20. break;

  21. case 'IMG':

  22. handler.img(node);

  23. break;

  24. case 'SVG':

  25. handler.svg(node);

  26. break;

  27. case 'INPUT':

  28. handler.input(node);

  29. break;

  30. case 'BUTTON': // Button processing ends once

  31. handler.button(node);

  32. break;

  33. case 'UL':

  34. case 'OL':

  35. case 'DL':

  36. handler.list(node, this.options);

  37. break;

  38. case 'A': // A label processing is placed behind to prevent IMG from displaying an exception

  39. handler.a(node);

  40. break;

  41. default:

  42. break;

  43. }


  44. if (isBtn) {

  45. handler.button(node);

  46. } else {

  47. isCompleted = this.handleText(node);

  48. }


  49. // If it is a button and has not been processed by handleText, then the child node is processed

  50. if (!isBtn && !isCompleted) {

  51. this.handleNodes(node.childNodes); // 递归处理

  52. }

  53. },

使用 rollup 打包脚本

由于对页面节点处理的脚本使用 es6 语法编写, 我们需要在插入页面之前,进行编译:

  1. // rollup.config.js

  2. export default {

  3. input: 'src/script/main.js',

  4. output: {

  5. file: 'src/script/dist/index.js',

  6. format: 'iife',

  7. name: 'AwesomeSkeleton',

  8. },

  9. };

生成骨架屏代码

生成处理页面节点脚本之后,插入到页面中,这个时候需要一些钩子来执行页面DOM的处理。我们定义一个全局对象 AwesomeSkeleton,其中包含 genSkeleton 方法,调用后会处理页面节点。

  1. window.AwesomeSkeleton = {

  2. // Entry function

  3. async genSkeleton(options) {

  4. this.options = options;

  5. if (options.debug) {

  6. await this.debugGenSkeleton(options);

  7. } else {

  8. await this.startGenSkeleton(); // 生成骨架屏

  9. }

  10. },


  11. // Start generating the skeleton

  12. async startGenSkeleton() {

  13. this.init();

  14. try {

  15. this.handleNode(document.body);

  16. } catch (e) {

  17. console.log('==genSkeleton Error==\n', e.message, e.stack);

  18. }

  19. },

  20. ...

  21. }

在使用 puppeteer 打开页面之后,我们注入了 rollup 打包后的脚本,这时候我们需要执行脚本才能生成骨架屏:

  1. await page.evaluate(async options => {

  2. await window.AwesomeSkeleton.genSkeleton(options);

  3. }, options);

生成骨架屏后,我们通过调用 puppeteer 的截图接口,生成骨架屏图片,并使用 images 包进行图片压缩,生成 base64,从而生成骨架屏代码。

  1. // First screen skeleton screenshot

  2. await page.screenshot({

  3. path: screenshotPath,

  4. });


  5. const imgWidth = options.device ? 375 : 1920;

  6. // Use images for image compression

  7. await images(screenshotPath).size(imgWidth).save(screenshotPath);

  8. const skeletonImageBase64 = base64Img.base64Sync(screenshotPath);


  9. // Inject the skeleton into the desired page

  10. const result = insertSkeleton(skeletonImageBase64, options);

使用 awesome-skeleton

通过上述讨论的技术方案,我们实现了 awesome-skeleton 骨架屏生成工具。支持命令行生成骨架屏代码,同时也可以非常方便的在第三方平台接入。

参数配置

参数名称必填默认值说明pageUrl是-页面地址(此地址必须可访问)pageName否output页面名称(仅限英文)cookies否页面 Cookies,用来解决登录态问题outputPath否skeleton-output骨架图文件输出文件夹路径,默认到项目 skeleton-output 中openRepeatList否true默认会将每个列表的第一项进行复制device否PC参考 puppeteer/DeviceDescriptors.js,可以设置为 'iPhone 6 Plus'debug否false是否开启调试开关debugTime否0调试模式下,页面停留在骨架图的时间minGrayBlockWidth否0最小处理灰色块的宽度minGrayPseudoWidth否0最小处理伪类宽

例如添加 skeleton.config.json,生成考拉首页在 iphone X 下的骨架屏。

  1. {

  2. "pageName": "baidu",

  3. "pageUrl": "https://www.kaola.com",

  4. "openRepeatList": false,

  5. "device": "iPhone X",

  6. "minGrayBlockWidth": 80,

  7. "minGrayPseudoWidth": 10,

  8. "debug": true,

  9. "debugTime": 3000

  10. }

一键生成骨架屏
  1. $ skeleton -c ./skeleton.config.json

页面 DomReady 之后,会在页面顶部出现红色按钮:开始生成骨架屏。

生成完成后,会在运行目录生成 skeleton-output 文件件,里面包括骨架屏 png 图片、base64 文本、html 文件:

其中 html 文件可以直接拿来用,复制下面位置:

  1. <html>

  2. <head>

  3. <!--- 骨架屏代码 -->

  4. </head>

  5. </html>

注意:

解决登录问题

如果页面需要登录,则需要下载 Chrome 插件 EditThisCookie,将 Cookie 复制到配置参数中。Puppeteer 通过在打开页面的时候注入 Cookie 从而模拟登录态:

  1. // Write cookies to solve the login status problem

  2. if (options.cookies && options.cookies.length) {

  3. await page.setCookie(...options.cookies);

  4. await page.cookies(options.pageUrl);

  5. await sleep(1000);

  6. }

DOM 节点配置

这是获取优质骨架图的要点,通过设置以下几个 dom 节点属性,在骨架图中对某些节点进行移除、忽略和指定背景色的操作,去除冗余节点的干扰,从而使得骨架图效果达到最佳。

参数名称说明data-skeleton-remove指定进行移除的 dom 节点属性data-skeleton-bgcolor指定在某 dom 节点中添加的背景色data-skeleton-ignore指定忽略不进行任何处理的 dom 节点属性data-skeleton-empty将某dom的innerHTML置为空字符串

示例:

  1. <div data-skeleton-remove><span>abc</span></div>

  2. <div data-skeleton-bgcolor="#EE00EE"><span>abc</span></div>

  3. <div data-skeleton-ignore><span>abc</span></div>

  4. <div data-skeleton-empty><span>abc</span></div>

开发骨架屏生成平台

有了骨架屏生成工具,我们可以非常方便的接入第三方平台,例如我们使用 egg.js 开发骨架屏生成平台,用户输入页面链接,自动生成对应骨架屏。关键代码:

  1. const getSkeleton = require('awesome-skeleton');


  2. class SkeletonService extends Service {

  3. async generator(params) {


  4. try {

  5. const result = await getSkeleton(params);


  6. return {

  7. success: true,

  8. ...result,

  9. };

  10. } catch (e) {

  11. ...

  12. }

  13. }

  14. }

页面效果:

骨架屏配置:

参与贡献

Github:https://github.com/kaola-fed/awesome-skeleton

感谢阅读

关于本文 作者:@子楼 原文:https://zhuanlan.zhihu.com/p/114362353

为你推荐


【第1432期】megalo -- 网易考拉小程序解决方案


【第1125期】GraphQL 技术栈揭秘


【第1438期】如何让你的网页“看起来”展现地更快 —— 骨架屏二三事