早在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)图像
图像也就是元素,其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是一个布尔值,表示是否是一个元素。当传入非元素时,就需要将其背景替换成初始化时的纯色。getRect()是一个辅助函数,用于获取元素的尺寸和坐标。
function getRect(element) { return element.getBoundingClientRect(); }
2)文本
处理文本是比较复杂的,因为文本长度是不定的,如下图所示,左边的文本是两行,骨架屏中也要变成两行,并且第二行不是满行的。
网上的资料对于最后一行都会做遮罩处理,也就是用一个白底的块定位到相应位置,把多余的灰底遮掉。当文本只有一行时,还需要做特殊处理。
而我在设计骨架屏插件的时候,采用了一个简单粗暴的方法,能够避免遮罩和单行的处理,那就是为所有文本节点添加元素。对于我这边不太复杂的HTML结构而言,能够大大简化代码的复杂度。具体方法如下所示,采用递归的方式逐个访问子节点,当节点是文本类型并且有内容时,就为其包裹标签。
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); } }); }
下面的第一个
元素在调用了appendTextNode()方法后,就变成了第二个
元素。
<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是一个特殊的参数,在插件初始化时可传递进来,因为表单比较复杂,所以要自定义着色规则。例如一些页面的表单结构是下面这样的,那么就需要将
<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); //为文本添加 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); } ); //元素处理 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多行,本插件主要用于学习。