早在2013年Luke Wroblewski就提出了骨架屏(Skeleton Screen)的概念,他认为骨架屏是一个页面的空白版本,通过这个空白版本来传递一种信息,即页面正在渐进式的加载中。骨架屏的布局能与页面的视觉呈现保持一致,这样就能引导用户的关注点聚焦到感兴趣的位置。如下图所示,左边是数据渲染后的页面,右边是骨架屏,可以看到相应的位置都能对起来。

  在网上阅读了一些骨架屏原理的资料后,就自己想尝试一下,练练手,制作一个极简版本的骨架屏插件。因为简单,所以未来如要扩展,成本也会很低。上图是通过自己写的骨架屏插件得到的效果,对于公司简单结构的项目,还是游刃有余的。在编写插件时,参考了网上多篇资料分享的代码,站在巨人的肩膀上整合代码,省力了很多。插件的完整代码已上传至GitHub中,下面是其中的构造函数,以及三个常量,用到了ES6的一些概念,如对此不熟悉,可参考我之前整理的《ES6躬行记》。

const NODE_ELEMENT = 1,     //元素类型的节点常量
  NODE_TEXT = 3,            //文本类型的节点常量
  NODE_COMMENT = 8;         //注释类型的节点常量

/**
 * @param color         字体的背景色
 * @param bgColor       带背景图模块的背景色
 * @param rectHeight    指定区域的高度,默认为视口高度
 * @param formFn        自定义表单着色规则
 * @constructor
 */
function Skeleton({
  color = "#DCDCDC",
  bgColor = "#F6F8FA",
  rectHeight = global.innerHeight,
  formFn = function() {}
} = {}) {
  this.container = document.body;   //骨架容器
  this.color = color;
  this.bgColor = bgColor;
  this.rectHeight = rectHeight;
  this.formFn = formFn;
}

一、绘制骨架屏

  由于对Node.js不熟,所以采用纯原生的JavaScript来绘制骨架屏。首先将页面中的元素分成三类:图像、文本和表单。

1)图像

  图像也就是<img>元素,其src属性会被替换成一张灰色(色素是#EEE)的1*1的gif图。为了避免引入额外的请求,将该gif图转换成base64格式,写死在替换函数image()中,如下所示,呈现的效果如下图所示。

image(element, isImage = true) {
  const { width, height } = getRect(element);
  //图像颜色 #EEE
  const src = "data:image/gif;base64,R0lGODlhAQABAPAAAPT09AA....";
  if (isImage) 
    element.src = src;
  else
    element.style.background = this.bgColor;
  element.width = width;
  element.height = height;
}

  由于image()函数声明在原型(prototype)之上,因此省略了function关键字。isImage是一个布尔值,表示是否是一个<img>元素。当传入非<img>元素时,就需要将其背景替换成初始化时的纯色。getRect()是一个辅助函数,用于获取元素的尺寸和坐标。

function getRect(element) {
  return element.getBoundingClientRect();
}

2)文本

  处理文本是比较复杂的,因为文本长度是不定的,如下图所示,左边的文本是两行,骨架屏中也要变成两行,并且第二行不是满行的。

  网上的资料对于最后一行都会做遮罩处理,也就是用一个白底的块定位到相应位置,把多余的灰底遮掉。当文本只有一行时,还需要做特殊处理。

  而我在设计骨架屏插件的时候,采用了一个简单粗暴的方法,能够避免遮罩和单行的处理,那就是为所有文本节点添加<span>元素。对于我这边不太复杂的HTML结构而言,能够大大简化代码的复杂度。具体方法如下所示,采用递归的方式逐个访问子节点,当节点是文本类型并且有内容时,就为其包裹<span>标签。

appendTextNode(parent) {
  //避免<span>中嵌套<span>
  if ( parent.childNodes.length <= 1 &&
    parent.nodeName.toLowerCase() == "span" ) {
    return;
  }
  parent.childNodes.forEach(node => {
    if (node.nodeType === NODE_TEXT && node.nodeValue.trim().length > 0) {
      let span = document.createElement("span");
      span.textContent = node.nodeValue;
      parent.replaceChild(span, node);
    } else {
      this.appendTextNode(node);
    }
  });
}

  下面的第一个<p>元素在调用了appendTextNode()方法后,就变成了第二个<p>元素。

<p>本活动最终解释权归上海易点时空网络有限公司所有</p>
<!-- 骨架屏结构 -->
<p><span>本活动最终解释权归上海易点时空网络有限公司所有</span></p>

  为了让多行文本能呈现灰白相间的效果,就得借助CSS3的linear-gradient渐变属性来实现。如果对其不熟悉,可以参考之前的《CSS3中惊艳的gradient》一文。

  下面的计算方式照搬了饿了么的page-skeleton-webpack-plugin插件,其中getStyle()函数用于获取元素的CSS属性或属性对象(CSSStyleDeclaration)。

calculate(element) {
  let { fontSize, lineHeight } = getStyle(element);
  lineHeight = parseFloat(lineHeight);                     //解析浮点数
  fontSize = parseFloat(fontSize);
  const textHeightRatio = fontSize / lineHeight,           //字体占行高的比值
    firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(2),                         //渐变的第一个位置,小数点后两位四舍五入
    secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(2);    //渐变的第二个位置
  return `
        background-image: linear-gradient(
            transparent ${firstColorPoint}%, ${this.color} 0,
            ${this.color} ${secondColorPoint}%, transparent 0);
        background-size: 100% ${lineHeight};
        position: relative;
        color: transparent;
    `;
}
function getStyle(element, name) {
  const style = global.getComputedStyle(element);
  return name ? style[name] : style;
}

  首先读取字体大小和行高,然后计算字体占行高的比值(textHeightRatio),接着计算出渐变的两个位置(firstColorPoint和secondColorPoint),最后通过模板字面量输出文本的样式,字体颜色被设为了透明。

  绘制文本的逻辑都封装到了text()方法中,具体如下所示。

text(element) {
  //判断是否是只包含文本的节点
  const isText =
    element.childNodes &&
    element.childNodes.length === 1 &&
    element.childNodes[0].nodeType === NODE_TEXT &&
    /\S/.test(element.childNodes[0].textContent);
  if (!isText) {
    return;
  }
  const rule = this.calculate(element);     //计算样式
  element.setAttribute("style", rule);
}

3)表单

  表单控件目前只处理了input、select和button,它们中的文本会变透明,添加背景色,placeholder属性变空,如下所示。

form(element) {
  element.style.color = "transparent";             //内容透明
  element.style.background = this.color;           //重置背景
  element.setAttribute("placeholder", "");         //清除提示
  this.formFn && this.formFn.call(this, element);         //执行自定义着色规则
}

  formFn是一个特殊的参数,在插件初始化时可传递进来,因为表单比较复杂,所以要自定义着色规则。例如一些页面的表单结构是下面这样的,那么就需要将<li>也添加背景色。

<ul>
  <li class="ui-flex">
    <input type="text" />
  </li>
  <li class="ui-flex">
    <input type="text" />
  </li>
</ul>

  自定义的着色规则如下所示,其中matches()是一个选择器匹配方法。

new Skeleton({
  formFn: function(element) {
    while(element && !this.matches(element, "li.ui-flex"))
      element = element.parentNode;
    element && (element.style.background = this.color);
  }
});

matches(element, selector) {
  if (!selector || !element || element.nodeType !== NODE_ELEMENT)
    return false;
  const matchesSelector = element.webkitMatchesSelector || element.matchesSelector;
  return matchesSelector.call(element, selector);
}

4)移除

  因为骨架屏的特点是快速,所以在生成时需要移除多余的元素,例如指定区域外的元素、隐藏的元素和脚本元素,如下所示,其中isHideStyle()函数可判断是否是隐藏元素。

removeElement(parent) {
  if (parent.children.length == 0) return;
  //有移除操作,所以未用Array.from()遍历
  for (let i = 0; i < parent.children.length; i++) {
    const element = parent.children[i],
      { top } = getRect(element);
    if (
      isHideStyle(element) ||                           //隐藏元素
      top >= this.rectHeight ||                         //超出指定高度
      element.nodeName.toLowerCase() == "script"        //脚本元素
    ) {
      element.remove();
      i--;
      continue;
    }
    this.removeElement(element);
  }
}
function isHideStyle(element) {
  return (
    getStyle(element, "display") == "none" ||
    getStyle(element, "visibility") == "hidden" ||
    getStyle(element, "opacity") == 0 ||
    element.hidden
  );
}

  本来是想用Array.from()遍历元素,但删除后会影响迭代逻辑,因此改成了for循环语句。

  除了这三类元素之外,还得将注释节点也一并删除,如下所示。注意,childNodes与上面的children属性不同,它能够通过forEach()遍历。

removeNode(parent) {
  if (parent.childNodes.length == 0) return;
  for (let i = 0; i < parent.childNodes.length; i++) {
    const node = parent.childNodes[i];
    if (node.nodeType === NODE_COMMENT) {
      node.remove();
      i--;
      continue;
    }
    this.removeNode(node);
  }
}

5)绘制

  绘制就是调用上面所提到的方法,包括移除元素、着色、替换图像等,具体如下所示。

function draw() {
  this.container.style.background = "#FFF";         //容器背景重置
  //移除元素和节点
  this.removeElement(this.container);
  this.removeNode(this.container);
  //为文本添加<span>
  this.appendTextNode(this.container);
  //处理普通元素
  Array.from(
    this.container.querySelectorAll(
      "div,section,footer,header,a,p,span,form,label,li"
    )
  ).map(element => {
    //背景图或背景颜色的处理
    const hasBg =
      getStyle(element, "background-image") != "none" ||
      getStyle(element, "background-color") != "rgba(0, 0, 0, 0)";
    if (hasBg) {
      this.image(element, false);
    }
    //文本处理
    this.text(element);
  });
  //处理表单中的控件
  Array.from(this.container.querySelectorAll("input,select,button")).map(
    element => {
      this.form(element);
    }
  );
  //<img>元素处理
  Array.from(this.container.querySelectorAll("img")).map(img => {
    this.image(img);
  });
}

二、Puppeteer

  插件完成后,没有做到自动化,即需要在浏览器的控制台中手工执行骨架屏插件。翻阅资料后,大家都推荐使用Puppeteer。Puppeteer是一个Node库,它提供了一个高级API来通过DevTools协议控制Chromium或Chrome。也就是说,它是一个无头(headless)浏览器。

  一边翻资料,一边查看demo,尝试着写Node.js,后面跌跌撞撞的写出了可以执行的脚本。

  原理就是先打开无头浏览器;然后输入视口参数和页面地址,并添加插件地址;然后在打开的页面中执行插件,返回document.body中的HTML代码;最后将HTML写入到一个txt文件中。

const puppeteer = require('puppeteer'),
    fs = require('fs');
(async () => {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
    //视口参数
    await page.setViewport({width: 375, height: 667});
    // 事件监听,可用于调试
    page.on('console', msg => console.log('PAGE LOG:', msg.text()));
    // waitUntil 参数有四个关键字:load、domcontentload、networkidle0和networkidle2
    await page.goto('http://www.pwstrick.com/index.html', {waitUntil: 'networkidle2'});
    await page.addScriptTag({url: 'http://www.pwstrick.com/js/skeleton.js'});
    // 对打开的页面进行操作
    const html = await page.evaluate(() => {
        let sk = new Skeleton();
        sk.draw();
        return document.body.innerHTML;
    });
    //将骨架屏代码添加到content.txt文件中
    fs.writeFileSync('content.txt', html);
    await browser.close();
})();

  本来是想在page.evaluate()中将插件以参数的形式传入,但一直不成功,后面就改成了page.addScriptTag(),引用插件的脚本。

  到目前为止,只能算是半自动化。要做到自动化,就得编写webpack插件,在打包的时候,将生成的HTML代码嵌入到页面中的指定位置,并且还要做到参数可配置化,以适合更多的场景。

  整个骨架屏插件只有200多行代码,去掉注释和空行只有160多行,本插件主要用于学习。

 

GitHub地址如下:

https://github.com/pwstrick/skeleton

 

 

参考资料:

一种自动化生成骨架屏的方案

前端骨架屏方案小结

网页骨架屏自动生成方案(dps)

一个前端非侵入式骨架屏自动生成方案

puppeteer

puppeteer中文

编写一个webpack插件

 posted on 2020-02-16 14:39  咖啡机(K.F.J)  阅读(3508)  评论(0编辑  收藏  举报