(马蜂窝技术原创内容,公众号 ID:mfwtech)html
一份来自 Akamai 的研究报告显示,在对 1048 名网购户进行采访后发现:前端
约 47% 的用户指望他们的页面在两秒以内加载完成。node
若是页面加载时间超过 3s,约 40% 的用户会选择离开或关闭页面。webpack
一直以来,为了提高用户在页面加载时的体验,不管是 Web 仍是 iOS、Android 的应用中,前端开发工程师都作了许多工做。除了解决如何让网页展示速度更快的问题,还有很重要的一点就是提高用户对加载等待时间的感知。「菊花图」以及由其衍生出的各类加载动画就是一类常见的解决方案,相信不管是开发者仍是用户对下面这个图标都不会陌生:web
本文要介绍的「骨架屏」则被视为菊花图升级版的方案。受现有骨架屏方案的启发,马蜂窝电商前端研发团队实现了一种自动化生成骨架屏的方法,并在马蜂窝商城的多个页面中实现应用,取得了不错的效果。算法
骨架屏能够理解为在页面数据还没有返回或页面未完成彻底渲染前,先给用户呈现一个由灰白块组成的当前页面大体结构,让用户产生页面正在逐渐渲染的感觉,从而使加载过程从视觉上变得流畅。生成后的骨架屏页面以下图所示:数组
骨架屏的主要优点为:浏览器
在选择骨架屏以前,咱们也考虑了一些其余的方法,好比可否经过服务端渲染(SSR)的方式来避开前端白屏时间的问题。但发现须要涉及项目过多,还会涉及服务的构建与部署;或是经过 prerender-spa-plugin 提供简单的预呈现,它对 SPA 支持友好,但须要额外的 webpack 配置,且由于包源的问题,下载时间过长,有时还会莫名失败,等等,都由于种种缘由最终放弃。bash
通过一系列调研后,咱们对业界常见的几种骨架屏解决方案,以及它们的优点、不足进行了一个简单的梳理。markdown
即经过 UI 提供符合页面首页样式的图来充当骨架屏,将骨架屏 base64 图片插入 root 根节点,在 webpack 打包时嵌入项目中。
这是一种简单粗暴的方法,实现起来比较容易。但缺点也很明显,就是须要 UI 设计师支持和开发介入,不能自动生成。
即经过手写 HTML、CSS 的方式为目标页定制骨架屏。这种方式能够作到对页面真实样式的复刻。不过一旦因为各类缘由致使页面样式发生改变,就须要再改一遍骨架屏的样式和布局,极大增长了维护的成本。
目前比较受关注的是饿了么开源的插件 page-skeleton-webpack-plugin,其具体实现原理为:
经过 Puppeteer 操控 handless Chrome 打开须要生成的骨架屏页面,在等待页面加载完成以后,保留页面布局样式的前提下,经过对页面中元素进行增删,对已有元素经过层叠样式进行覆盖,使其展现为灰白块。而后将修改后的 HTML 和 CSS 提取出来,将页面分为不一样的块区域,例如文本块、图片块、按钮块、SVG、伪类元素块等,分别对每一个块进行处理,使其尽可能与原页面保持一致。这里用到了 Puppeteer page 实例的 addScriptTag 方法来将处理块的脚本插入到 headless Chrome 打开的页面当中。
实际生成的骨架屏页面与原页面可能还会存在差距,插件经过 memory-fs 将骨架屏写入内存中,能够经过预览页面对生成的骨架屏进行二次编辑和效果预览,修改完成后点击生成按钮就能生成一份新的骨架屏写入到项目中。
借一张图来讲明:
骨架屏的 DOM 结构和 CSS 经过离线生成后,在构建时注入模板 (EJS) 中的节点下面,插入到 HTML 是在 after-emit 钩子函数中进行。
page-skeleton-webpack-plguin 生成骨架屏的方案能够根据项目中不一样的路由页面生成相应的骨架屏页面,并将骨架屏页面经过 webpack 打包到对应的静态路由页面中。
它的不足之处在于:
实际使用过程当中没法监听接口返回致使生成骨架屏的时机是否准确
生成的页面与业务人员写的结构质量有直接关系,常常出现须要手工二次调整的状况
在这样的背景下,马蜂窝电商研发前端团队但愿找一种在提高用户体验的同时,对开发更友好的骨架屏生成方式,能针对不一样的业务场景自动生成出类似的骨架屏,而且实现自动注入。对于开发而言,只须要执行一条命令,或者简单配置,就能够生成骨架屏,不须要再考虑后续的维护工做。
在方案调研过程当中,draw-page-structure 为咱们的设计提供了灵感。
// dps.config.js { url: 'https://baidu.com', output: { filepath: '/Users/famanoder/DrawPageStructure/example/index.html', injectSelector: '#app' }, background: '#eee', animation: 'opacity 1s linear infinite;', // ... } 复制代码
根据 URL 指定的线上地址,配合 Puppeteer 获取当前页面的 DOM 结构,并对其中元素节点生成骨架屏文件到 filepath 指定的文件里面,就能够生成骨架屏页面,结果以下图所示:
将上述生成的骨架屏文件插入到页面根节点下面通常为 id="app" 的节点,而后在通用工具里提供主动销毁骨架屏的方法,就能够帮助开发主动控制或销毁骨架屏,显示页面真实内容。
draw-page-structure 的设计思想很大程度上能够知足咱们的需求,不足的是只能对线上已经存在的 URL 生成骨架屏,不支持开发环境。另外因为是自动生成,当页面存在重定向(若是未登陆重定向到登陆页面)的状况时,生成的骨架屏可能与预期不一致。并且它的内部实现并不完善,可能致使某些结构复杂的页面下生成的骨架屏须要二次优化调整。
因而,咱们开始了进一步的探索。
基于对现有方案的借鉴,咱们想到了在配置文件中指定要生成骨架屏的页面 URL 和文件输出的目录,运行时读取配置文件中的配置项,经过 Puppeteer 打开指定的页面并注入 evalDom.js 的方法。由于此 JS 是在 Puppeteer 里面执行的,因此能够获取到当前页面完整的 DOM 结构,这给咱们留下了很是大的发挥空间。
最初咱们是从获取到的 DOM 结构中的 body 标签出发,递归去处理页面上的全部节点,处理完成后用生成的 DIV 替换原有元素的位置。初版方案中经过 getBoundingClientRect 和 getComputedStyle 的方法来获取元素全部计算属性和相对于视口的宽高和位置,而后结合元素自己的样式属性递归渲染,保留页面原始 DOM 嵌套层次。
但因为可以决定元素位置的属性实在太多,如 position,z-index、width、height、top、display、box-sizing、flex 等都须要考虑,致使没法聚焦对页面 DOM 结构处理的逻辑,并且这些属性在处理完成后还须要加到最终生成骨架屏节点的 style 上,这样骨架屏文件可能比原来完整的页面结构还大,这确定不是咱们但愿的。
优化后的方案是用 getBoundingClientRect 和 getComputedStyle 获取元素相关属性,而后直接经过绝对定位的方式来生成最终的骨架屏节点。这样在页面上最终须要的属性主要是 position、z-index、top、left、width、height、background、border-radius。除了没法保证页面原始的 DOM 结构,其它需求基本均可以知足,也更加聚焦于节点的处理。
主要实现流程以下图:
该方案目前主要应用于马蜂窝电商业务的多页面项目中,包括下单页、签证页等,如下单页为例,展现效果以下图:
(1) config.js 配置
const dpsConfig = { // 默认生成位置为当前项目目录skeleton文件夹,已有骨架屏页面不会再次生成,新页面配置只须要添加新条目便可 visa_guide: { url: 'https://w.mafengwo.cn/sfe-app/visa_guide.html?mdd_id=10083', // 必填项 }, call_charge: { url: 'http://localhost:8081/sfe-app/call_charge.html?rights_id=25', // 必填项 待生成骨架屏页面的地址,用百度(https://baidu.com)试试也能够 //url:'https://www.baidu.com', device: 'pc', // 非必填,默认mobile background: '#eee', // 非必填 animation: 'opacity 1s linear infinite;', // 非必填 headless:false, // 非必填 customizeElement: function(node) { // 非必填 //返回值枚举若是是true表示不会向下递归到这层为止,若是返回值是一个对象那么节点的档子就按照对象里面的样式来绘制 //若是返回值为0表示正常递归渲染 //若是返回值为1表示渲染当前节点不在向下递归 //若是返回值为2表示对当前节点不做任何处理 if(node.className === 'navs-bottom-bar'){ return 2; } return 0; }, showInitiativeBtn: true,// 非必填 若是此值设置为true表示开发须要主动触发生成骨架屏了,此时headless需设置为false writePageStructure: function(html) { // 非必填 // 本身处理生成的骨架屏 // fs.writeFileSync(filepath, html); // console.log(html) }, init: function() { // 非必填 // 生成骨架屏以前的操做,好比删除干扰节点 } } } module.exports = dpsConfig; 复制代码
(2)Puppeteer 新打开页面并返回浏览器实例、openPage
const ppteer = require('puppeteer'); const { log, getAgrType } = require('./utils'); const insertBtn = require('../insertBtn'); const devices = { mobile: [375, 667, 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1'], ipad: [1024, 1366, 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1'], pc: [1200, 1000, 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1'] }; async function pp({device = 'mobile', headless = true, showInitiativeBtn = false}) { const browser = await ppteer.launch({headless});//返回browser实例 async function openPage(url, extraHTTPHeaders) { const page = await browser.newPage(); let timeHandle = null; if(showInitiativeBtn){ browser.on('targetchanged', async ()=>{//监听页面路由变化,并获取当前标签页的最新的页面,在showInitiativeBtn为true时插入按钮由开发控制主动生成骨架屏 const targets = await browser.targets(); const currentTarget = targets[targets.length - 1] const currentPage = await currentTarget.page(); clearTimeout(timeHandle) setTimeout(()=>{ if(currentPage){ currentPage.evaluate(insertBtn); } },300) }) } try{ let deviceSet = devices[device]; page.setUserAgent(deviceSet[2]); page.setViewport({width: deviceSet[0], height: deviceSet[1]}); if(extraHTTPHeaders && getAgrType(extraHTTPHeaders) === 'object') { await page.setExtraHTTPHeaders(new Map(Object.entries(extraHTTPHeaders))); } await page.goto(url, { waitUntil: 'networkidle0'//再也不有网络链接时触发(至少500ms后) }); }catch(e){ console.log('\n'); log.error(e.message); } return page; } return { browser, openPage } }; module.exports = pp; 复制代码
(3)在浏览器环境里执行 evalDom.js 和 evalDom.js 中处理 node 节点的主要逻辑
agrs.unshift(evalScripts);//evalScripts = require('../evalDOM');在puppeteer里执行evalDom.js并将config.js里配置的参数传递给evalDom html = await page.evaluate.apply(page, agrs); 复制代码
//evalDom.js主要逻辑 startDraw: function () { const $this = this; const nodes = this.rootNode.childNodes; this.beforeRenderDomStyle(); function childNodesStyleConcat(childNodes) { for (let i = 0; i < childNodes.length; i++) { const currentChildNode = childNodes[i];//当前子节点 //有哪些节点要跳过绘制骨架屏的过程 if ($this.shouldIgnoreCurrentElement(currentChildNode)) { //是否应该忽略当前节点,不采起任何措施。后续这个地方能够由用户指定哪些节点应该被略去,todo continue; } const backgroundHasurl = analyseIfHadBackground(currentChildNode); const hasDirectTextChild = childrenNodesHasText(currentChildNode);//判断当前元素是否是有直接的子元素而且此元素是Text if ($this.customizeElement && $this.customizeElement(currentChildNode) !== 0 && $this.customizeElement(currentChildNode) !== undefined) { //开发者自定义节点须要渲染的样子,默认返回false表示使用正常递归的算法来处理。若是返回值是true表示不会在向下递归,若是返回值是一个对象那么表示开发须要自定义样式此时直接绘制就好。todo if (getArgtype($this.customizeElement(currentChildNode)) === 'object') { console.log('object'); //此处若是返回一个对象表示对象要自定义最后绘制的对象 } else if ($this.customizeElement(currentChildNode) === 1) { //若是此时返回true,表示此节点要过滤 getRenderStyle(currentChildNode); } else if ($this.customizeElement(currentChildNode) === 2){ continue ; } continue; } if (backgroundHasurl || analyseIsEmptyElement(currentChildNode) || hasDirectTextChild || shouldDrawCurrentNode(currentChildNode)) { //若是当前元素是内联元素或者当前元素非内联元素,可是不包含子节点或者子节点都是内联元素的话那么咱们就在当前的骨架屏上绘制此节点。 getRenderStyle(currentChildNode, hasDirectTextChild); } else if (currentChildNode.childNodes && currentChildNode.childNodes.length) { //若是当前节点包含子节点 //递归 childNodesStyleConcat(currentChildNode.childNodes); } } } childNodesStyleConcat(nodes); return this.showBlocks(); }, 复制代码
上述 rootNode 为根节点,默认为 document.body 或者能够由开发指定
主要逻辑为判断当前节点是否须要忽略、是否设置了背景图片、是否含有文本信息、开发是否指定了当前节点的处理方式等,对知足条件的渲染其对应的骨架屏节点,不然处理当前节点的子节点
全部节点处理完成后,调用 showBlocks 将生成的骨架屏节点拼接位 HTML 字符串,以便后续处理
(4) getRenderStyle 生成骨架屏样式
const styles = [ 'position: fixed', `z-index: ${zIndex}`, `top: ${top}%`, `left: ${left}%`, `width: ${width}%`, `height: ${height}%`, 'background: '+(background || '#eee'), ]; const radius = getStyle(node, 'border-radius'); radius && radius != '0px' && styles.push(`border-radius: ${radius}`); blocks.push(`<div style="${styles.join(';')}"></div>`); 复制代码
(5) 最终生成骨架屏的 HTML 文件以下:
<html><head></head> <body><div style="position: fixed;z-index: 999;top: 89.805%;left: 4.267%;width: 91.467%;height: 11.994%;background: #eee"></div></body></html> 复制代码
在项目入口 index.html 文件内添加
<body> <div id="app"> </div> <% if(htmlWebpackPlugin.options.hasSkeleton) { %> <div id="skeleton"><!-- 骨架屏经过htmlWebpackPlugin在启动打包的时候自动注入 --> <%= htmlWebpackPlugin.options.loading.html %> </div> <% } %> <!-- built files will be auto injected --> </body> 复制代码
目前,该方案已经支持由开发主动控制骨架屏生成时间,这样就避免了页面重定向的过程当中没法生成正确的骨架屏,同时能够支持在本地开发时生成骨架屏。将来咱们将实现支持开发自定义生成骨架屏节点的样式和组件骨架屏的生成,并优化 evalDom.js 内部节点过滤、处理的算法。敬请期待!
最后,咱们正在招聘资深前端开发工程师,欢迎感兴趣的同窗发送简历至:kangcenbo@mafengwo.com。
本文做者:康岑波、孙昊男,马蜂窝电商平台前端研发工程师。