持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第 29 天,点击查看活动详情。
早在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地址如下:
参考资料: