前端骨架屏方案与实践

对于依赖接口渲染的页面,在拿到数据之前页面往往是空白的,为了提示用户当前正在加载中,往往会使用进度条、loading图标或骨架屏的方式。

对于前两种方案而言,实现比较简单;本文主要研究骨架屏的实现方案。

骨架屏(Skeleton Screen)是指在页面数据加载完成前,先给用户展示出页面的大致结构(灰色占位图),不会造成网页长时间白屏或者闪烁,在拿到接口数据后渲染出实际页面内容然后替换掉。Skeleton Screen 是近两年开始流行的加载控件,本质上是界面加载过程中的过渡效果。Skeleton Screen 给用户一种资源是逐渐加载呈现出来的感觉,使得加载过程变得流畅。

1 利用图片切换实现

1.1 原理

使用一张占位骨架图(svg / lottie / gif)来代替 loading 效果,当数据加载完成后对替换掉骨架图。

1.2 具体实现

我们知道,浏览器加载资源的顺序是:

  1. html、css、font这三种类型的资源优先级最高;
  2. 然后是preload资源(通过 <link rel=“preload"> 标签预加载)、script、xhr请求;
  3. 接着是图片、语音、视频;
  4. 最低的是 prefetch 预读取的资源。

因此需要使用 preload 提高图片加载优先级,让骨架图更早的显示,其次需要尽量减少图片的体积以加快加载速度,还有就是由于浏览器对同一域名的请求有并发限制骨架屏的图片尽量放在单独的域名上,最后获取数据后隐藏图图片,显示真实 DOM 元素。

<link rel="preload" as="image" href="https://www.edmi.cdn.com/skeketon.gif">

link元素rel属性的属性值preload能够让浏览器预先加载在和缓存对应的资源,as属性可以指定预加载内容的类型。可以被预加载如下:

  • audio: 音频文件;
  • document: 一个将要被嵌入到<frame><iframe> 内部的 HTML 文档;
  • embed: 一个将要被嵌入到<embed>元素内部的资源;
  • fetch: 那些将要通过 fetchXHR 请求来获取的资源,比如一个 ArrayBufferJSON 文件;
  • font: 字体文件;
  • image: 图片文件;
  • object: 一个将会被嵌入到<embed>元素内的文件;
  • script: JavaScript 文件;
  • style: 样式表;
  • track: WebVTT 文件;
  • worker: 一个 JavaScriptweb workershared worker
  • video: 视频文件; 

1.3 优缺点

优点是实现简单,开发成本较低。缺点是维护成本较高,对于迭代比较频繁的页面,增大UI设计的工作量。

2 css + html 实现骨架屏

2.1 原理

用 css + html 实现一个骨架屏的元素,当数据加载完成后替换掉。与利用图片切换实现相比较,这种方案易于维护。

2.2 具体实现

首先实现一个会动的渐变效果

  • 通过 animation: loading 2s ease infinite;控制背景移动实现从左到右的进度效果;
  • 通过 animation: opacity 2s ease infinite;控制透明度实现渐隐渐现的动画效果;
.card {
  ...
  overflow: hidden;
}

.skeleton .user-avatar-cont,
.skeleton .img-cont {
  background: #eee;
}
.skeleton .user-name span,
.skeleton .user-profession span {
  background: #eee;
  color: #eee;
  border-radius: 5px;
}

.skeleton::before {
    content: '';
    position: absolute;
    background: linear-gradient(
       90deg,
       transparent,
       rgba(255, 255, 255, 0.9),
       transparent
    );
    width: 50%;
    height: 100%;
    top: 0;
    left: 0;
    /* 关键帧动画  */
    animation: loading 0.6s infinite; /** opacity 2s ease infinite; */
}

@keyframes loading {
  0% {
    background-size: 300% 100%;
    background-image: linear-gradient(100deg, #eee 40%, #fff 50%, #eee 60%);
    background-position: 100% 50%;
  }

  100% {
    background-size: 300% 100%;
    background-image: linear-gradient(100deg, #eee 40%, #fff 50%, #eee 60%);
    background-position: 0 50%;
  }

}

@keyframes loading-transform {
  0% {
     transform: skewX(-10deg) translateX(-100%);
  }
  100% {
     transform: skewX(-10deg) translateX(200%);
   }
}


/**
@keyframes opacity {
  0%{
    opacity: 1;
  }
  50%{
    opacity: 0.3;
  }
  100%{
    opacity: 1;
  }
}
*/

然后为需要显示骨架屏的元素添加选择器:

  <div className="card skeleton">
    <div className="img-cont">

    </div>
    <div className="user-info">
      <div className="user-avatar-cont">

      </div>
      <div className="user-details">
        <div className="user-name"><span>edemao & rrh</span></div>
        <div className="user-profession"><span>Front-end Blogger</span></div>
      </div>
    </div>
  </div>

由于将 overflow: hidden 值添加到 card 元素中,因此当 before 元素由于关键帧变换而超出 card 边界时,它在 card 边界之外是不可见的。最后,获取到数据后,去掉 skeleton 选择器即可。

2.3 优缺点

优点是与第一种方案来比较的话这种方案相对更灵活地定制骨架屏UI和动画效果,更容易维护些,但是开发和维护成本仍然较高——仍然需要在开发时为每个元素添加背景,形成页面的骨架屏框架,增加了一部分开发量,对于元素多类别杂的首页,工作量也不小。比如,采用这类方案的 react-content-loader 也可以通过配置快速生成对应的骨架屏,但对于某些高度定制的业务需求页面不好满足。

3 自动生成 css + html 骨架屏实现

3.1 原理

因为只需要保留元素最后一层的位置布局就可以了实现基本的页面的骨架,父级元素基本是提供一种嵌套关系。而 getBoundingClientRect()方法,可以获取到元素相对于可视窗口的位置以及宽高。

Element.getBoundingClientRect()  方法返回元素的大小及其相对于视口的位置。如果是标准盒子模型,元素的尺寸等于width/height + padding + border-width的总和。如果box-sizing: border-box,元素的的尺寸等于 width/height

具体思路是简化所有元素,不考虑结构层级、不考虑样式,对所有元素统一用 div 去代替,而且在骨架中只需要渲染最后一个层级,以定位的方式设置每个元素其相对于视窗的位置,形成骨架屏。

这样生成的节点是扁平的,体积比较小,同时避免额外的读取样式表和不需要通过抽离样式维持骨架屏的外观,使得骨架屏的节点更可控。该方法生成的骨架屏是由纯 DOM 颜色块拼成的

3.2 具体实现

首先,考虑到了机型适配问题,对渲染出的 div 宽高以百分比方式做适配;而且只有拥有一定的宽高(宽高大于5px)并且在可视范围内的元素才进行渲染。 

/** 骨架屏样式 */
let skeletonHtml = "<style>.skeleton {position: fixed;background: #eee;animation: opacity 2s ease infinite;} @keyframes opacity {0%{opacity: 1;}50%{opacity: 0.4;}100%{opacity: 1;}} </style>";

const removeClass = [];
const removeId = [];

function createDiv(node, customStyle) {
    let { width, height, top, left } = node.getBoundingClientRect();
    const nodeClassName = node.className ? `node-class=${node.className}`:"";
    const nodeId = node.id ? `node-id=${node.id}`:"";
    const { borderRadius } = getComputedStyle(node, null)
    const { innerWidth, innerHeight } = window
    // 必须符合要求的元素才渲染:有大小,并且在视图内;
    if (width > 5 && height > 5 && top < innerHeight && left < innerWidth) {
        width = ((width / innerWidth) * 100).toFixed(2) + '%'
        height = ((height / innerHeight) * 100).toFixed(2) + '%'
        left = ((left / innerWidth) * 100).toFixed(2) + '%'
        top = ((top / innerHeight) * 100).toFixed(2) + '%'
        skeletonHtml += `<div class="skeleton" ${nodeClassName} ${nodeId} style="width:${width};height:${height};left:${left};top:${top};border-radius:${borderRadius};"></div>`
    }
}

function getDom(options = { removeElements: [] }) {
    const { removeElements } = options
    for (let i = 0; i < removeElements.length; i++) {
        const el = removeElements[i]
        const reg = /^./
        if (el.match(reg) == ".") {
            removeClass.push(el.substr(1))
        }
        if (el.match(reg) == "#") {
            removeId.push(el.substr(1))
        }
    }
    const dom = document.body;
    const nodes = dom.childNodes;
    dom.style.overflow = "hidden";
    deepNode(nodes); // 遍历节点生成骨架屏框架 html
    return skeletonHtml;
}

function isRemove(node) {
    const { className, id } = node;
    if (className || id) {
        for (let i = 0; i < removeClass.length; i++) {
            if (className.indexOf(removeClass[i]) > -1) {
                return true;
            }
        }
        if (removeId.includes(id)) {
            return true;
        }
    }
    return false;
}

对于页面上多出来许多布局较乱的模块,需要移除或自定义绘制干扰节点,或者适当调整某个节点的样式,可以支持传入当前节点的 class或者id,来忽略这个节模块点,在 createDiv()创建元素时将当前节点的 class 和 id 写入元素节点的一个属性,方便定位要删除的节点,然后getDom()传入要删除的节点的集合,或者在 getDom 内,遍历节点之前就将其删除。此外,对于自定义绘制可以在遍历到指定节点时,调用传入的 customCreateDiv 方法进行自定义绘制:

getDom({ removeElements: [".removeClass","#removeId"], customElemnts: {customCreateId: function() {}} })

然后对于 deepNodes,对节点进行一个过滤,不可见的元素节点及其子结点直接跳过。循环当前节点的子节点,如果子节点存在任意一个元素节点,就代表当前节点还需要递归一次,如果不存在元素节点,就说明它是最后一层元素节点,可以被渲染:

  • 过滤掉不可见或者主动过滤的节点;
function isHide(node) {
    if (node.nodeType !== 1) return false;
    let style = getComputedStyle(node, null);
    return node.nodeName == "SCRIPT"|| style.display == 'none' || style.opacity == 0 || style.visibility == 'hidden' || node.hidden;
}
  • 最后一个层级的元素( nodeType === 1)节点(包括没有子元素节点的元素节点和没有子节点的元素节点);
function deepNode(nodes) {
    for (let i = 0; i < nodes.length; i++) {
        let node = nodes[i];
        if (isHide(node) || isRemove(node)) continue; // 过滤掉不可见或主动要求过滤的节点
        let isHasChildrenElementNode = false; // 判断是否有子元素节点
        for (let j = 0; j < node.childNodes.length; j++) {
            let childNode = node.childNodes[j]
            if (childNode.nodeType === 1) {
                isHasChildrenElementNode = true;
                break;
            }
        }
        // 没有子元素节点的元素节点 或 没有子节点的元素节点(单标签节点,比如 img)
        if ((node.nodeType == 1 && !isHasChildrenElementNode) || (node.nodeType == 1 && node.childNodes.length == 0)) {
            createDiv(node);
        }
        if (node.childNodes.length) {
            deepNode(node.childNodes);
        }
    }
}
  • 结合 Puppeteer 自动生成骨架屏:开发一个配套 CLI 工具——通过 Puppeteer 运行页面,并把生成骨架屏的脚本注入页面自动执行,执行的结果是生成的骨架屏代码被插入到页面中。首先,初始化 init 生成配置文件 config.js 并修改,然后运行 start 开始生成骨架屏。
const config = {
	url: '${conf.url}',      // 待生成骨架屏页面的地址
	output: {
		filepath: '${conf.filepath.toString()}',   // 生成骨架屏的存放页面,一般为项目的入口页面
		injectSelector: '#app'  // 生成的骨架屏插入页面的节点
	},
	// background: '#eee', // 骨架屏主题色
	// animation: 'opacity 1s linear infinite;', // css3动画属性
} 

 CLI 实现如下:

#!/usr/bin/env node

const program = require('commander')
const prompts = require('prompts')
const path = require('path')
const fs = require('fs')
const pkg = require('../package.json')
const defConf = require('./default.config')
const getSkeletonHtml = require('../src') // 生成骨架屏(利用 puppeteer 和脚本生成)
const utils = require('../src/utils')

const currDir = process.cwd()

program
  .version(pkg.version)
  .usage('<command> [options]')
  .option('-v, --version', 'latest version')
  .option('-tar, --target <tar>', 'same to the config of url@rootNode.');

program
  .command('init')
  .description('create a default edmi.config.js file')
  .action(function(env, options) {
    const edmiConfFile = path.resolve(currDir, defConf.filename)
    if(fs.existsSync(edmiConfFile)) {
      return console.log(`\n[${defConf.filename}] had been created! you can edit it and then run 'edmi start'\n`)
    }
    askForConfig().then(({url, filepath}) => {
      const outputPath = filepath ? path.resolve(currDir, filepath).replace(/\\/g, '\\\\') : '';
      prompts({
        type: 'toggle',
        name: 'value',
        message: `Are you sure to create skeleton screen base on ${url}. \n and will output to ${utils.calcText(outputPath)}`,
        initial: true,
        active: 'Yes',
        inactive: 'no'
      }).then(res => {
        if(res.value) {
          fs.writeFile(
            path.resolve(currDir, defConf.filename), 
            defConf.getTemplate({
              url: url,
              filepath: outputPath
            }),
            err => {
              if(err) throw err;
              console.log(`\n[${defConf.filename}] had been created! now, you can edit it and then run 'edmi start'\n`)
            }
          )
        }
      })
    });
  });

program
  .command('start')
  .description('start create a skeleton screen')
  .action(function() {
    // 生成骨架屏, 并默认注入到 #app 中;
    getSkeletonHtml(getEdmiconfig());
  });

program.parse(process.argv);
if (program.args.length < 1) program.help()

function getEdmiConfig() {
  const edmiConfFile = path.resolve(currDir, defConf.filename)
  if(!fs.existsSync(edmiConfFile)) {
    return utils.log.error(`please run 'edmi init' to initialize a config file`, 1)
  }
  return require(edmiConfFile);
}

function askForConfig() {
  const questions = [
    {
      type: 'text',
      name: 'url',
      message: "What's your page url ?",
      validate: function(value) {
        const urlReg = /^https?:\/\/.+/ig;
        if (urlReg.test(value)) {
          return true;
        }
  
        return 'Please enter a valid url';
      }
    },
    {
      type: 'text',
      name: 'filepath',
      message: "Enter a relative output filepath ? (optional)",
      validate: function(value) {
        const filepath = path.isAbsolute(value) ? value : path.join(__dirname, value);
        const exists = fs.existsSync(filepath);
        
        if(value && !exists) {
          return 'Please enter a exists target';
        }
        return true;
      }
    }
  ];
  return prompts(questions);
}

3.3 优缺点

优点是自动化,降低重复编写骨架屏代码的成本,缺点是对于复杂的页面,可能受元素定位的影响较大,自动生成的时候存在不确定性。另外,只能是首次加载,对于加载完成后用户触发的动态数据不支持生成骨架屏。

4 预渲染手动书写骨架屏实现(vue-skeleton-webpack-plugin)

4.1 原理

将骨架屏看成路由组件,在构建时(webpack)使用预渲染功能(VueSSR),将骨架屏组件的渲染结果 HTML 片段插入 HTML 页面模版的挂载点中,将样式(<style>)内联到 head 标签中。这样等前端加载完成时,使用客户端混合把挂载点中的骨架屏内容替换成真正的页面内容。比如 vue-skeleton-webpack-plugin。

4.2 实现

具体的实现细节如下:

  •  使用 Vue 预渲染骨架屏:创建一个仅使用骨架屏组件的入口文件 src/entry-skeleton.js,并创建一个用于服务端渲染的 webpack 配置对象 webpack.skeleton.conf.js,将刚创建的入口文件指定为 entry 依赖入口;最后,将这个 webpack 配置对象通过参数传入骨架屏插件中,骨架屏插件运行时使用 webpack 编译这个传入的配置对象,得到骨架屏的 bundle 文件。接下来使用这个 bundle 文件内容创建一个 renderer,调用 renderToString() 方法得到字符串形式的 HTML 渲染结果,默认情况下,webpack 模块引用的样式内容是内嵌在 JavaScript bundle 中的。使用插件ExtractTextPlugin 将骨架屏样式内容进行样式分离输出到单独的 CSS 文件中,得到渲染结果HTML和样式内容 CSS:
/** src/entry-skeleton.js */
import Skeleton from './Skeleton.vue';

export default new Vue({
    components: {
        Skeleton
    },
    template: '<skeleton />'
});

/** webpack.skeleton.conf.js */
{
    target: 'node', // 区别默认的 'web'
    entry: resolve('./src/entry-skeleton.js'), // 多页传入对象
    output: {
        libraryTarget: 'commonjs2'
    },
    externals: nodeExternals({
        whitelist: /\.css$/
    }),
    plugins: []
}

/** webpack.dev.conf.js */
plugins: [
    new SkeletonWebpackPlugin({ // 我们编写的插件
        webpackConfig: require('./webpack.skeleton.conf')
    })
]

/** vue-skeleton-webpack-plugin/src/ssr.js */
serverWebpackConfig.plugins.push(new ExtractTextPlugin({
    filename: outputCssBasename // 样式文件名
}));
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer;
// 从内存文件系统中读取 bundle 文件
let bundle = mfs.readFileSync(outputPath, 'utf-8');
// 创建 renderer
let renderer = createBundleRenderer(bundle);
// 渲染得到 HTML
renderer.renderToString({}, (err, skeletonHtml) => {});
// 加入 ExtractTextPlugin 插件到 webpack 配置对象插件列表中
  • 将骨架屏渲染结果插入 HTML 模版中:监听生成 HTML的 html-webpack-plugin 插件 的 html-webpack-plugin-before-html-processing 事件,在事件的回调函数中,插件会传入当前待处理的 HTML 内容 插入到设置的挂载点(默认是<div id="app">),样式部分则直接插入到 head 标签內。而在多页应用中,传给骨架屏插件的 webpack 配置对象是包含多个入口的,通常会引入多个 html-webpack-plugin,导致 html-webpack-plugin-before-html-processing 事件被多次触发,因此骨架屏插件都需要识别出 html-webpack-plugin-before-html-processing 事件触发对应的当前正在处理的入口文件,执行 webpack 编译当前页面对应的骨架屏入口文件,渲染对应的骨架屏组件。
/** webpack.skeleton.conf.js */
// 多页面的 entry-skeleton 入口
entry: {
    page1: resolve('./src/pages/page1/entry-skeleton.js'),
    page2: resolve('./src/pages/page2/entry-skeleton.js')
}
 
/** vue-skeleton-webpack-plugin/src/index.js */
// 当前页面使用的所有 chunks
let usedChunks = htmlPluginData.plugin.options.chunks;
let entryKey;
// chunks 和所有入口文件的交集就是当前待处理的入口文件
if (Array.isArray(usedChunks)) {
    entryKey = Object.keys(skeletonEntries);
    entryKey = entryKey.filter(v => usedChunks.indexOf(v) > -1)[0];
}
// 设置当前的 webpack 配置对象的入口文件和结果输出文件
webpackConfig.entry = skeletonEntries[entryKey];
webpackConfig.output.filename = `skeleton-${entryKey}.js`;
// 使用配置对象进行服务端渲染
ssr(webpackConfig).then(({skeletonHtml, skeletonCss}) => {
    // 注入骨架屏 HTML 和 CSS 到页面 HTML 中
})
  • 在开发模式下插入各个骨架屏路由,方便开发调试:向路由文件插入两类代码:1. 引入骨架屏组件的代码插入到文件顶部;2. 使用简单的字符串匹配来查找该数组的起始位置,将对应的路由规则对象插入到路由对象数组中。而在多页应用中,向每个页面对应的骨架屏插入代码,可以通过占位符设置来引入骨架屏组件语句和路由规则的模版,因为 loader 在运行时会使用这些模版,用真实的骨架屏名称替换掉占位符。
/** router.js */

import Skeleton from '@/pages/Skeleton.vue'
routes: [
    { // 插入骨架屏路由
        path: '/skeleton',
        name: 'skeleton',
        component: Skeleton
    }
    // ...其余路由规则
]

/** webpack.dev.conf.js */
module: {
    rules: [] // 其他规则
        .concat(SkeletonWebpackPlugin.loader({
            resource: resolve('src/router.js'), // 目标路由文件
            options: {
                entry: ['page1', 'page2'],
                importTemplate: 'import [nameCap] from \'@/pages/[name]/[nameCap].skeleton.vue\';',
                routePathTemplate: '/skeleton-[name]'
            }
        }))
}

4.3 优缺点

优点是配置化生成,根据给定页面,自动生成对应的骨架屏,不需要安装额外的工具(不用安装puppeteer),缺点是 vue-skeleton-webpack-plugin 与 vue 直接关联,不够灵活、可控,且存在编写骨架屏模板代码的工作,需要 webapck 来自动注入 HTML 文件;

5 puppeteer 自动化生成骨架屏实现(page-skeleton-webpack-plugin)

5.1 原理

通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架屏的页面,页面加载渲染完成之后,在保留页面布局样式的前提下,通过遍历页面节点对页面中元素进行删减或增添,对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片和文字,通过样式覆盖,使得其展示为灰色块。然后将修改后的 HTML 和 CSS 样式提取出来形成骨架屏。

5.2 具体实现

将页面划分为不同的块分别进行处理,避免破坏页面整体的样式和布局,保持生成的骨架屏和真实页面的布局样式完全一致,达到了复用样式及页面布局的目的。

包括以下块:

  • 文本块:仅包含文本节点(NodeType 为 Node.TEXT_NODE)的元素(NodeType 为 Node.ELEMENT_NODE),一个文本块可能是一个 p 元素也可能是 div 等。文本块将会被转化为灰色条纹。

    •  单行文本内容的高度,可以通过 fontSize 获取到。

    • 单行文本内容加空白间隙的高度,可以通过 lineHeight 获取到。

    • p 元素总共有多少行文本,也就是所谓行数,这个可以通过 p 元素的(height - paddingTop - paddingBottom)/ lineHeight 大概算出。

    • 文本的 textAlign 属性。

    • fontSize、lineHeight、paddingTop、paddingBottom 都可以通过 getComputedStyle 获取到,而元素的高度 height 可以通过 getBoundingClientRect 获取到。

const textHeightRatio = parseFloat(fontSize, 10) / parseFloat(lineHeight, 10)
const firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(decimal)
const secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(decimal)

const rule = `{
  // 将线性渐变的起始点设置小于前一个颜色点的起始值,或者设置为0 %,使成为两条颜色分明的条纹;
  background-image: linear-gradient(
    transparent ${firstColorPoint}%, ${color} 0%,
    ${color} ${secondColorPoint}%, transparent 0%);
  background-size: 100% ${lineHeight};
  position: ${position};
  background-origin: content-box;
  background-clip: content-box;
  background-color: transparent;
  color: transparent; // 使得文字透明不显示
  background-repeat: repeat-y;
}`

  • 图片块:图片块是很好区分的,任何 img 元素都将被视为图片块,图片块的颜色将被处理成配置的颜色,形状也被修改为配置的矩形或者圆型。

    • 通过一个 div 元素来替换 img 元素,然后设置 div 元素背景为灰色,div 的宽高等同于原来 img 元素的宽高的方案存在的问题:通过元素选择器设置到 img 元素上的样式无法运用到 div 元素上面,导致最终图片块的骨架效果和真实的图片在页面样式上有出入,特别是没法适配不同的移动端设备,因为 div 的宽高被硬编码。

    • 通过 Canvas 来绘制和原来图片大小相同的灰色块,然后将 Canvas 转化为 dataUrl 赋予给 img 元素的 src 特性上使得 img 元素是灰色块存在的问题:文件大小会增加,会使得页面加载变慢。

    • 将一张1 * 1 像素的 gif 透明图片,转化成 dataUrl ,然后将其赋予给 IMG 元素的 src 特性上,同时设置图片的 width 和 height 特性为之前图片的宽高,将背景色调至为骨架样式所配置的颜色值,优点是大小只有几十B

function imgHandler(ele, { color, shape, shapeOpposite }) {
  const { width, height } = ele.getBoundingClientRect();
  const attrs = {
    width,
    height,
    src
  }

  const finalShape = shapeOpposite.indexOf(ele) > -1 ? getOppositeShape(shape) : shape;

  setAttributes(ele, attrs);

  const className = CLASS_NAME_PREFEX + 'image';
  const shapeName = CLASS_NAME_PREFEX + finalShape;
  const rule = `{
    background: ${color} !important;
  }`
  addStyle(`.${className}`, rule);
  shapeStyle(finalShape);

  addClassName(ele, [className, shapeName]);

  if (ele.hasAttribute('alt')) {
    ele.removeAttribute('alt');
  }
}
  • 按钮块:任何 button 元素、 type 为 button 的 input 元素,role 为 button 的 a 元素,都将被视为按钮块。按钮块中的文本块不再进行处理。

  • svg 块:任何最外层是 svg 的元素都被视为 svg 块。

    • 判断 svg 元素 hidden 属性是否为 true,如果为 true,说明该元素不展示的,所以我们可以直接删除该元素。

if (width === 0 || height === 0 || ele.getAttribute('hidden') === 'true') {
  return removeElement(ele);
}

const shapeClassName = CLASS_NAME_PREFEX + shape
shapeStyle(shape)

Object.assign(ele.style, {
  width: px2relativeUtil(width, cssUnit, decimal),
  height: px2relativeUtil(height, cssUnit, decimal),
})

addClassName(ele, [shapeClassName])

if (color === TRANSPARENT) {
  setOpacity(ele)
} else {
  const className = CLASS_NAME_PREFEX + 'svg'
  const rule = `{
    background: ${color} !important;
  }`
  addStyle(`.${className}`, rule)
  ele.classList.add(className)
}
  • 伪类元素块:任何伪类元素都将视为伪类元素块,如 ::before 或者 ::after

在处理之前,利用 puppeteer 提供方法 page.addScriptTag(options),插入一段 js 脚本的 url 或者是相对/绝对路径,也可以直接是 js 脚本的内容,再 page.evaluate 方法来执行脚本中的全局对象 Skeleton 骨架屏生成方法(genSkeleton)来生成骨架页面:

async makeSkeleton(page) {
    const { defer } = this.options;
    await page.addScriptTag({ content: this.scriptContent });
    await sleep(defer);
    await page.evaluate((options) => {
      Skeleton.genSkeleton(options)
    }, this.options);
}

其中,在生成骨架页面的过程中,将所有的共用样式通过 addStyle 方法缓存起来,最后在生成骨架屏的时候,统一通过 style 标签插入到骨架屏中,保证样式尽可能多的复用。

其次,在处理列表的时候,对列表进行了同化处理,也就是说将 list 中所有的 listItem 都是同一个 listItem 的克隆。使得 list 的骨架屏样式统一且美观。

再者,对非首屏的元素进行了删除,只保留了首屏内部元素,大大缩减了生成骨架屏的体积。

最后,通过 document.querySelector 方法来判断该 CSS 是否被使用到,如果该 CSS 选择器能够选择上元素,说明该 CSS 样式是有用的,保留。如果没有选择上元素,说明该 CSS 样式没有用到,这样只提取了对骨架屏有用的 CSS,然后通过 style 标签引入。

const checker = (selector) => {
  if (DEAD_OBVIOUS.has(selector)) {
    return true
  }
  if (/:-(ms|moz)-/.test(selector)) {
     return true
  }
  if (/:{1,2}(before|after)/.test(selector)) {
    return true
  }
  try {
    const keep = !!document.querySelector(selector)
    return keep
  } catch (err) {
    const exception = err.toString()
    console.log(`Unable to querySelector('${selector}') [${exception}]`, 'error')
    return false
  }
}

骨架屏的HTML 和 CSS 代码在页面启动之前被添加到挂载元素内(<div id="app"><!-- skeleton.html --></div>)。

为了骨架屏的开发调试可编辑,不是马上将其写入到配置的输出文件夹中,而是 通过 memory-fs 将骨架屏写入到内存中,便于通过预览页面进行访问。同时将骨架屏源码通过websocket 发送到预览页面,使得可以通过修改源码,对骨架屏进行二次编辑。

最终在 webpack 的 after-emit 钩子函数中将骨架屏插入到 html 中。

SkeletonPlugin.prototype.apply = function (compiler) {
  // 其他代码
  compiler.plugin('after-emit', async (compilation, done) => {
    try {
      await outputSkeletonScreen(this.originalHtml, this.options, this.server.log.info)
    } catch (err) {
      this.server.log.warn(err.toString())
    }
    done()
  })
  // 其他代码
}

其中,outputSkeletonScreen 方法输出骨架屏的实现如下:

const outputSkeletonScreen = async (originHtml, options, log) => {
  const { pathname, staticDir, routes } = options
  return Promise.all(routes.map(async (route) => {
    const trimedRoute = route.replace(/\//g, '')
    const filePath = path.join(pathname, trimedRoute ? `${trimedRoute}.html` : 'index.html')
    const html = await promisify(fs.readFile)(filePath, 'utf-8')
    const finalHtml = originHtml.replace('<!-- shell -->', html)
    const outputDir = path.join(staticDir, route)
    const outputFile = path.join(outputDir, 'index.html')
    await fse.ensureDir(outputDir)
    await promisify(fs.writeFile)(outputFile, finalHtml, 'utf-8')
    log(`write ${outputFile} successfully in ${route}`)
    return Promise.resolve()
  }))
}

最后是对 SPA 多路由的支持:针对每一个路由页面生成一个单独的 index.html,也就是静态路由。然后将每个路由生成的骨架屏插入到不同的静态路由的 html 中。 

如何使用呢?

 /** webpack.conf.js */
 const HtmlWebpackPlugin = require('html-webpack-plugin')
 const { SkeletonPlugin } = require('page-skeleton-webpack-plugin')
 const path = require('path')

 plugins: [
  //...
  new HtmlWebpackPlugin({
    // Your HtmlWebpackPlugin config
  }),
  new SkeletonPlugin({
    pathname: path.resolve(__dirname, `${customPath}`), // 用来存储 shell 文件的地址
    staticDir: path.resolve(__dirname, './dist'), // 最好和 `output.path` 相同
    routes: ['/', '/search'], // 将需要生成骨架屏的路由添加到数组中
  })
 ]

其次,服务端渲染中有一种称为 Client-side Hydration(客户端混合) 的技术,指的是 Vue 在浏览器接管由服务端发送来的静态 HTML,并使其变为由 Vue 管理的动态 DOM 的过程。 

在构建骨架屏 DOM 结构和真实页面的 DOM 结构基本相同,只是添加了一些行内样式和 classname,即只在应用启动时才重新创建所有 DOM,而且只用激活这些骨架屏 DOM,让其能够相应数据的变化,使得 DOM 能够被复用,让骨架屏和真实页面更好的融合。

同时,页面完全加载后,会通过 AJAX 获取后端数据,通过骨架屏来作为一种非首屏交互的加载状态。

通过提供骨架屏的生命周期函数,或者提供相应的自定义事件,在生命周期不同阶段,调用相应的生命周期钩子函数或监听相应事件,将骨架屏的数据记录到性能监控工具中。

5.3 优缺点

优点是不需要编写骨架屏模板代码,而且不依赖框架,和项目无缝集成;缺点是对于复杂的页面,基于页面本身的结构和 CSS,嵌套可能比较深,体积不会太小,并且只支持 history 模式。

6. 使用Chrome扩展程序生成骨架屏实现

6.1 原理

通过扩展程序的 content.js 实现渲染骨架屏就可以不需要借助安装额外的工具(puppeteer)和本地开发环境。只保留节点的布局信息,需要覆盖节点原本的UI信息,包括背景色、图片、边框等,并且可以忽略不必要的节点。

6.2 具体实现

列表元素是骨架屏中比较常见的区块,可以由上面其他区块组成,默认采用移除多余的元素,仅使用第一个元素克隆占位,保证生成规整的骨架屏。

function preorder($dom) {
    replaceTextNode($dom)

    // 排除不可见的元素
    if (!checkNodeVisible($dom)) {
        return
    }

    let type = $dom.attr('skeleton-type') || getNodeSkeletonType($dom)  // 自动检测节点类型,并附上type
    let excludeType = $dom.attr('skeleton-exclude-type')

    if (!excludeType || type !== excludeType) {
        let handlers = {
            [TEXT]: renderText,
            [IMAGE]: renderImg,
            [BLOCK]: renderBlock,
            [BORDER]: renderBorder,
            [BUTTON]: renderButton,
            [LIST]: renderList,
            [BACKGROUND_IMAGE]: renderBackgroundImage,
            [INPUT]: renderInput,
            [IGNORE]: renderIgnore
        }

        let handler = handlers[type]
        handler && handler($dom)
        // 不再执行后面的模块
        if ([BLOCK].includes(type)) {
            return
        }
    }

    // 元素节点
    $dom.children().each(function () {
        const $this = $(this)
        // 递归
        preorder($this)
    });

}

对于特殊情况,引入 skeleton-type 的概念,对应节点类型。在遍历时会优先读取节点上的 skeleton-type,只有当属性值 skeleton-type 不存在时,才会根据 nodeType 自行推断相关的类型;

  • 对于需要隐藏的节点,直接指定 skeleton-type 为 ignore;
  • 对于需要从某个类型中忽略节点而言,则设置  skeleton-exclude-type 为 对应类型;
const SKELETON_TYPE = {
    IGNORE: "ignore",
    TEXT: "text",
    IMAGE: "img",
    BLOCK: "block",
    BORDER: "border",
    LIST: 'list',
    BUTTON: 'button',
    BACKGROUND_IMAGE: 'background-image',
    INPUT: 'input'
}

暴露 skeleton-type 与 skeleton-exclude-type 两个HTML属性,在开发页面的时候可以直接指定骨架屏区块类型,方便业务定制。

然后是获取骨架屏代码,使用扩展程序在骨架屏渲染结束之后自动保存对应的HTML代码:

chromeMsg.on("createSkeleton", (params) => {
    const {config} = params
    // 默认页面根节点,可以导出某个dom容器的骨架屏结构
    let content = renderSkeleton(".page", config)
    // 处理对应的骨架屏HTML代码,这里只是简单打印,也可以直接保存文件到本地,或者通过HTTP接口调用某些钩子服务完成自动注入等功能
    console.log(content);
})

对于整页骨架屏,我们需要先使用测试数据生成对应的骨架屏,然后将得到的HTML放入页面中即可。可以封装一个SkeletonFrame组件,其参数包括 frame和 loading两个 props,表示骨架屏HTML和是否正在加载,当 loading为 true 时展示骨架屏:

<SkeletonFrame frame={html} loading={isLoading}>
    <RealPage></RealPage>
</SkeletonFrame>

6.3 优缺点

优点是支持自定义节点类型、忽略或隐藏节点,不需要安装 puppeteer;不需要 webapck 自动将骨架屏注入模板文件,支持首屏或者部分区域使用骨架屏;生成的代码体积要足够小。缺点是需要使用浏览器插件。

总结

自动生成骨架屏的方案的诞生,源于随着业务需求不断变更,每新增一个新的页面,设计师都需要重新设计相应的骨架屏页面,且在后续迭代中存在着大量的维护和沟通成本,而研发人员也需要相应的跟着设计图来维护一套骨架屏的代码,会存在着大量的重复工作量,极大的浪费了设计师和研发人员的时间和精力。

展望一下更工程化的思路可以如下:

  • 骨架屏样式规范可以由设计师与开发人员来制定不同元素的展示样式(如Label、Button、Image、View展示的坐标、大小、颜色、圆角等样式)
  • 由开发人员根据视图层级和视图样式定义的可解析的视图数据结构(DSL)。
  • 由开发人员一次性开发的骨架屏生成工具——骨架屏组件,该组件可以获取当前页面的元素层级结构和样式属性,将其按照制定的数据结构和样式规范写入DSL文件,同时提供解析 DSL生成骨架屏(按照层级结构依次创建 view 即可,只是样式不同)的能力,调试阶段可以点击骨架屏元素,修改其颜色、大小以及显示隐藏等属性。
  • 使用时,先将修改好的数据文件导出放在项目里以便读取,然后解析文件生成骨架屏,控制其展示和隐藏即可。可以考虑将修改好的数据文件上传云端,后续通过云端下发来使用。

  

该方案的优点如下:

  • 骨架屏自动生成,设计师无需出骨架屏设计图,省去了APP版本迭代中的维护和沟通成本;
  • 开发人员无需为骨架屏单独维护一套UI代码,节省了研发成本 ;
  • 单独生成完整骨架屏页面,有效避免了骨架屏侵入业务代码,有效降低了代码耦合度;
  • 云端下发骨架屏配置文件,可动态修改线上骨架屏展示样式;
  • 开发阶段提供可视化调试功能,可编辑骨架屏子视图并实时预览编辑效果。

  • 11
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

薛定谔的猫96

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值