其次,预渲染(prerender),所谓预渲染,就是在项目的构建过程中,通过一些渲染机制,比如 puppeteer 或则 jsdom 将页面在构建的过程中就渲染好,然后插入到 html 中,这样在页面启动之前首先看到的就是预渲染的页面了。但是该方案最终也抛弃了,预渲染渲染的页面数据是在构建过程中就已经打包到了 html 中, 当真实访问页面的时候,真实数据可能已经和预渲染的数据有了很大的出入,而且预渲染的页面也是一个不可交互的页面,在页面没有启动之前,用户无法和预渲染的页面进行任何交互,预渲染页面中的数据反而会影响到用户获取真实的信息,当涉及到一些价格、金额、地理位置的地方甚至会导致用户做出一些错误的决定。因此我们最终没有选择预渲染方案。
生成骨架屏基本方案通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架屏的页面,在等待页面加载渲染完成之后,在保留页面布局样式的前提下,通过对页面中元素进行删减或增添,对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片和文字,通过样式覆盖,使得其展示为灰色块。然后将修改后的 HTML 和 CSS 样式提取出来,这样就是骨架屏了。
下面我将通过
page-skeleton-webpack-plugin 工具中的代码,来展示骨架屏的具体生成过程。
正如上面基本方案所描述的那样,我们将页面分成了不同的块:
- 文本块:仅包含文本节点(NodeType 为 Node.TEXTNODE)的元素(NodeType 为 Node.ELEMENTNODE),一个文本块可能是一个 p 元素也可能是 div 等。文本块将会被转化为灰色条纹。
- 图片块:图片块是很好区分的,任何 img 元素都将被视为图片块,图片块的颜色将被处理成配置的颜色,形状也被修改为配置的矩形或者圆型。
- 按钮块:任何 button 元素、 type 为 button 的 input 元素,role 为 button 的 a 元素,都将被视为按钮块。按钮块中的文本块不在处理。
- svg 块:任何最外层是 svg 的元素都被视为 svg 块。
- 伪类元素块:任何伪类元素都将视为伪类元素块,如 ::before 或者 ::after。
- …
首先,我们为什么要把页面划分为不同的块呢?
将页面划分为不同的块,然后分别对每个块进行处理,这样不会破坏页面整体的样式和布局,当我们最终生成骨架屏后,骨架屏的布局样式将和真实页面的布局样式完全一致,这样就达到了复用样式及页面布局的目的。
在所有分开处理之前,我们需要完成一项工作,就是将我们生成骨架屏的脚本,插入到 puppeteer 打开的页面中,这样我们才能够执行脚本,并最终生成骨架屏。
值得庆幸的是,puppeteer 在其生成的 page 实例中提供了一个原生的方法。
page.addScriptTag(options)
options<Object>
url
path
content
type(Use 'module' in order to load a Javascript ES6 module.)
有了这种方法,我们可以插入一段 js 脚本的 url 或者是相对/绝对路径,也可以直接是 js 脚本的内容,在我们的实践过程中,我们直接插入的脚本内容。
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)
}
有了上面插入的脚本,并且我们在脚本中提供了一个全局对象 Skeleton,这样我们就可以直接通过 page.evaluate 方法来执行脚本内容并最终生成骨架页面了。
由于时间有限,这儿不会对每个块的生成骨架结构进行详尽分析,这儿可能会重点阐述下文本块、图片块、svg 块如何生成骨架结构的,然后再谈谈如何对骨架结构进行优化。
文本块的骨架结构生成文本块可以算是骨架屏生成中最复杂的一个区块了,正如上面也说的,任何只包含文本节点的元素都将视为文本块,在确定某个元素是文本块后,下一步就是通过一些 CSS 样式,以及元素的增减将其修改为骨架样式。
在这张图中,图左边虚线框内是一个 p 元素,可以看到其内部有 4 行文本,右图是一个已经生成好的带有 4 行文本的骨架屏。在生成文本块骨架屏之前,我们首先需要了解一些基本的参数。
- 单行文本内容的高度,可以通过 fontSize 获取到。
- 单行文本内容加空白间隙的高度,可以通过 lineHeight 获取到。
- p 元素总共有多少行文本,也就是所谓行数,这个可以通过 p 元素的 (height - paddingTop - paddingBottom)/ lineHeight 大概算出。
- 文本的 textAlign 属性。
在这些参数中,fontSize、lineHeight、paddingTop、paddingBottom 都可以通过 getComputedStyle 获取到,而元素的高度 height 可以通过 getBoundingClientRect 获取到,有了这些参数后我们就能够绘制文本块的骨架屏了。
相信很多人都读过 @Lea Verou 的 CSS Secrets 这本书,书中有一篇专门阐述怎么通过线性渐变生成条纹背景的文章,而在绘制文本块骨架屏方案,正是受到了这篇文章的启发,文本块的骨架屏也是通过线性渐变来绘制的。核心简化代码:
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 = `{
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;
}`
我们首先计算了 lineHeight 和 fontSize 等一些样式参数,通过这些参数我们计算出了文本占整个行高的比值,也就是 textHeightRadio,有了这一比值,就可以知道灰色条纹的分界点,正如 @Lea Verou 所说:
摘自:CSS Secrets “If a color stop has a position that is less than the specied position of any color stop before it in the list, set its position to be equal to the largest speci ed position of any color stop before it.” — CSS Images Level 3 (
http://w3.org/TR/css3-images)
也就是说,在线性渐变中,如果我们将线性渐变的起始点设置小于前一个颜色点的起始值,或者设置为0 %,那么线性渐变将会消失,取而代之的将是两条颜色分明的条纹,也就是说不再有线性渐变。
在我们绘制文本块的时候,backgroundSize 宽度为 100%, 高度为 lineHeight,也就是灰色条纹加透明条纹的高度是 lineHeight。虽然我们把灰色条纹绘制出来了,但是,我们的文字依然显示,在最终骨架样式效果出现之前,我们还需要隐藏文字,设置 color:‘transparent’ 这样我们的文字就和背景色一致,最终显示得也就是灰色条纹了。
根据 lineCount 我们可以判断文本块是单行文本还是多行,在处理单行文本的时候,由于文本的宽度并没有整行宽度,因此,针对单行文本,我们还需要计算出文本的宽度,然后设置灰色条纹的宽度为文本宽度,这样骨架样式的效果才能够更加接近文本样式。
图片块的骨架生成图片块的绘制比文本块要相对简单很多,但是在订方案的过程中也踩了一些坑,这儿简单分享下采坑经历。
最初订的方案是通过一个 DIV 元素来替换 IMG 元素,然后设置 DIV 元素背景为灰色,DIV 的宽高等同于原来 IMG 元素的宽高,这种方案有一个严重的弊端就是,原来通过元素选择器设置到 IMG 元素上的样式无法运用到 DIV 元素上面,导致最终图片块的骨架效果和真实的图片在页面样式上有出入,特别是没法适配不同的移动端设备,因为 DIV 的宽高被硬编码。
接下来我们又尝试了一种看似「高级」的方法,通过 Canvas 来绘制和原来图片大小相同的灰色块,然后将 Canvas 转化为 dataUrl 赋予给 IMG 元素的 src 特性上,这样 IMG 元素就显示成了一个灰色块了,看似完美,当我们将生成的骨架页面生成 HTML 文件时,一下就傻眼了,文件大小尽然有 200 多 kb,我们做骨架页面渲染的一个重要原因就是希望用户在感知上感觉页面加载快了,如果骨架页面都有 200 多 kb,必将导致页面加载比之前要慢一些,违背了我们的初衷,因此该方案也只能够放弃。
最终方案,我们选择了将一张1 * 1 像素的 gif 透明图片,转化成 dataUrl ,然后将其赋予给 IMG 元素的 src 特性上,同时设置图片的 width 和 height 特性为之前图片的宽高,将背景色调至为骨架样式所配置的颜色值,完美解决了所有问题。
// 最小 1 * 1 像素的透明 gif 图片 'data:image/gif;base64,
R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
这是1 * 1像素的 base64 格式的图片,总共只有几十个字节,明显比之前通过 Canvas 绘制的图片小很多。
代码:
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')
}
}
svg 块骨架结构
svg 块处理起来也比较简单,首先我们需要判断 svg 元素 hidden 属性是否为 true,如果为 true,说明该元素不展示的,所以我们可以直接删除该元素。
if (width === 0 || height === 0 || ele.getAttribute('hidden') === 'true') {
return removeElement(ele)
}
如果不是隐藏的元素,那么我们将会把 svg 元素内部所有元素删除,减少最终生成的骨架页面体积,其次,设置svg 元素的宽、高和形状等。
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)
}
一些优化的细节
- 首先,由上面一些代码可以看出,在我们生成骨架页面的过程中,我们将所有的共用样式通过 addStyle 方法缓存起来,最后在生成骨架屏的时候,统一通过 style 标签插入到骨架屏中。这样保证了样式尽可能多的复用。
- 其次,在处理列表的时候,为了生成骨架屏尽可能美观,我们对列表进行了同化处理,也就是说将 list 中所有的 listItem 都是同一个 listItem 的克隆。这样生成的 list 的骨架屏样式就更加统一了。
- 还有就是,正如前文所说,骨架屏仅是一种加载状态,并非真实页面,因此其并不需要完整的页面,其实只需要首屏就好了,我们对非首屏的元素进行了删除,只保留了首屏内部元素,这样也大大缩减了生成骨架屏的体积。
- 删除无用的 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
}
}
可以看出,我们主要通过 document.querySelector 方法来判断该 CSS 是否被使用到,如果该 CSS 选择器能够选择上元素,说明该 CSS 样式是有用的,保留。如果没有选择上元素,说明该 CSS 样式没有用到,所以移除。
在后面的一些 slides 中,我们来聊聊怎讲将构建骨架屏和 webpack 开发、打包结合起来,最终将我们的骨架屏打包到实际项目中。
通过 webpack 将骨架屏打包到项目中在上一个部分,我们分析了怎么去生成骨架屏,在这一部分,我们将探讨如何通过 webpack 将骨架屏打包的项目中。在这过程中,思考了以下一些问题:
为什么在开发过程中生成骨架屏?其主要原因还是为了骨架屏的可编辑。
在上一个部分,我们通过一些样式和元素的修改生成了骨架屏页面,但是我们并没有马上将其写入到配置的输出文件夹中,在写入骨架页面到项目之前。我们通过 memory-fs 将骨架屏写入到内存中,以便我们能够通过预览页面进行访问。同时我们也将骨架屏源码发送到了预览页面,这样我们就可以通过修改源码,对骨架屏进行二次编辑。
正如这张图片,这张图是插件打开的骨架屏的预览页面,从左到右依次是开发中的真实页面、骨架屏、骨架屏可编辑源码。
这样我们就可以在开发过程中对骨架屏进行编辑,修改部分样式,中部骨架屏可以进行实时预览,这之间的通信都是通过websocket 来完成的。当我们对生成的骨架屏满意后,并点击右上角写入骨架屏按钮,将骨架屏写入到项目中,在最后项目构建时,将骨架屏打包到项目中。
如果我们同时在构建的过程中生成骨架屏,并打包到项目中,这时的骨架屏我们是无法预览的,因此我们对此时的骨架屏一无所知,也不能够做任何修改,这就是我们在开发中生成骨架屏的原因所在。
演讲最开始已经提到,目前流行的前端框架基本都是 JS 驱动,也就是说,在最初的 index.html 中我们不用写太多的 html 内容,而是等框架启动完成后,通过运行时将内容填充到 html 中,通常我们会在 html 模板中添加一个根元素:
<div id="app"></div>
当应用启动后,会将真实的内容填充到上面的元素中。这也就给了我们一个展示骨架屏的机会,我们将骨架屏在页面启动之前添加到上面元素内:
<div id="app"><!-- shell.html --></div>
怎样将骨架屏打包到项目中
Webpack 是一款优秀的前端打包工具,其也提供了一些丰富的 API 让我们可以自己编写一些插件来让 webpack 完成更多的工作,比如在构建过程中,将骨架屏打包到项目中。
Webpack 在整个打包的过程中提供了众多生命周期事件,比如compilation 、after-emit 等,比如我们最终将骨架屏插入到 html 中就是在after-emit 钩子函数中进行的,简单的代码:
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 是如何将骨架屏插入到原始的 HTML 中,并且写入到配置的输入文件夹的。
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()
}))
}
更多思考
Page Skeleton webpack 插件在我们内部团队已经开始使用,在使用的过程中我们也得到了一些反馈信息。
首先是对 SPA 多路由的支持,其实现在插件已经支持多路由了,只是还没有用到真实项目中,我们针对每一个路由页面生成一个单独的 index.html,也就是静态路由。然后将每个路由生成的骨架屏插入到不同的静态路由的 html 中。
其次,玩过服务端渲染的同学都知道,在 React 和 Vue 服务端渲染中有一种称为 Client-side Hydration 的技术,指的是在 Vue 在浏览器接管由服务端发送来的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。
在我们构建骨架屏的过程中,其 DOM 结构和真实页面的 DOM 结构基本相同,只是添加了一些行内样式和 classname,我们也在思考这些 DOM 能够被复用,也就是在应用启动时重新创建所有 DOM。我们只用激活这些骨架屏 DOM,让其能够相应数据的变化,这似乎就可以使骨架屏和真实页面更好的融合。
还有,在页面启动后,我们可能还是会通过 AJAX 获取后端数据,这时候我们也可以通过 骨架屏 来作为一种加载状态。也就是说,其实我们可以在「非首屏骨架屏」上做一些工作。
最后,在项目中可能会有一些性能监控的需求,比如骨架屏什么时候创建,什么时候被销毁,这些我们可能都希望通过一些性能监控的工具记录下来,以便将来做一些性能上面的分析。因此将来也会提供一些骨架屏的生命周期函数,或者提供相应的自定义事件,在生命周期不同阶段,调用相应的生命周期钩子函数或监听相应事件,这样就可以将骨架屏的一些数据记录到性能监控软件中。
本文摘自:一种自动化生成骨架屏的方案(
http://github.com/Jocs/jocs.github.io/issues/22)
推荐阅读
- page-skeleton-webpack-plugin插件地址 (https://github.com/ElemeFE/page-skeleton-webpack-plugin)
- 如何让网页“看起来”展现地更快?骨架屏二三事 (https://zhuanlan.zhihu.com/p/48601348)
- 美团网页首屏优化 (https://mp.weixin.qq.com/s/wi52VIfteEXGift95wzkWQ)
- Vue页面骨架屏注入实践 (https://segmentfault.com/a/1190000014832185)
- Vue项目骨架屏注入实践 (https://juejin.cn/post/6844903661726859272)
- vue-cli项目添加骨架屏多种方式,自动生成骨架屏 (https://blog.csdn.net/zhouzuoluo/article/details/100216255)
- 小程序构建骨架屏的探索 (https://segmentfault.com/a/1190000015876164)