對(duì)開發(fā)友好的前端骨架屏自動(dòng)生成方案
一份來(lái)自 Akamai 的研究報(bào)告顯示,在對(duì) 1048 名網(wǎng)購(gòu)戶進(jìn)行采訪后發(fā)現(xiàn):
約 47% 的用戶期望他們的頁(yè)面在兩秒之內(nèi)加載完成。
如果頁(yè)面加載時(shí)間超過(guò) 3s,約 40% 的用戶會(huì)選擇離開或關(guān)閉頁(yè)面。
一直以來(lái),為了提升用戶在頁(yè)面加載時(shí)的體驗(yàn),無(wú)論是 Web 還是 iOS、Android 的應(yīng)用中,前端開發(fā)工程師都做了許多工作。除了解決如何讓網(wǎng)頁(yè)展現(xiàn)速度更快的問(wèn)題,還有很重要的一點(diǎn)就是提升用戶對(duì)加載等待時(shí)間的感知?!妇栈▓D」以及由其衍生出的各種加載動(dòng)畫就是一類常見的解決方案,相信無(wú)論是開發(fā)者還是用戶對(duì)下面這個(gè)圖標(biāo)都不會(huì)陌生:
本文將要介紹的「骨架屏」則被視為菊花圖升級(jí)版的方案。受現(xiàn)有骨架屏方案的啟發(fā),馬蜂窩電商前端研發(fā)團(tuán)隊(duì)實(shí)現(xiàn)了一種自動(dòng)化生成骨架屏的方法,并在馬蜂窩商城的多個(gè)頁(yè)面中實(shí)現(xiàn)應(yīng)用,取得了不錯(cuò)的效果。
一、什么是骨架屏
骨架屏可以理解為在頁(yè)面數(shù)據(jù)尚未返回或頁(yè)面未完成完全渲染前,先給用戶呈現(xiàn)一個(gè)由灰白塊組成的當(dāng)前頁(yè)面大致結(jié)構(gòu),讓用戶產(chǎn)生頁(yè)面正在逐漸渲染的感受,從而使加載過(guò)程從視覺(jué)上變得流暢。生成后的骨架屏頁(yè)面如下圖所示:
骨架屏的主要優(yōu)勢(shì)為:
1.用戶避免看到長(zhǎng)時(shí)間的白頁(yè)
2.可以獲知頁(yè)面的大體結(jié)構(gòu),減小用戶認(rèn)為頁(yè)面出錯(cuò)而離開的機(jī)率
3.與菊花圖相比視覺(jué)更加流暢
二、常見的前端骨架屏方案
在選擇骨架屏之前,我們也考慮了一些其他的方法,比如能否通過(guò)服務(wù)端渲染(SSR)的方式來(lái)避開前端白屏?xí)r間的問(wèn)題。但發(fā)現(xiàn)需要涉及項(xiàng)目過(guò)多,還會(huì)涉及服務(wù)的構(gòu)建與部署;或是通過(guò) prerender-spa-plugin 提供簡(jiǎn)單的預(yù)呈現(xiàn),它對(duì) SPA 支持友好,但需要額外的 webpack 配置,且因?yàn)榘吹膯?wèn)題,下載時(shí)間過(guò)長(zhǎng),有時(shí)還會(huì)莫名失敗,等等,都因?yàn)榉N種原因最終放棄。
經(jīng)過(guò)一系列調(diào)研后,我們對(duì)業(yè)界常見的幾種骨架屏解決方案,以及它們的優(yōu)勢(shì)、不足進(jìn)行了一個(gè)簡(jiǎn)單的梳理。
1. UI 骨架屏圖
即通過(guò) UI 提供符合頁(yè)面首頁(yè)樣式的圖來(lái)充當(dāng)骨架屏,將骨架屏 base64 圖片插入 root 根節(jié)點(diǎn),在 webpack 打包時(shí)嵌入項(xiàng)目中。
這是一種簡(jiǎn)單粗暴的方法,實(shí)現(xiàn)起來(lái)比較容易。但缺點(diǎn)也很明顯,就是需要 UI 設(shè)計(jì)師支持和開發(fā)介入,不能自動(dòng)生成。
2. 手寫骨架屏
即通過(guò)手寫 HTML、CSS 的方式為目標(biāo)頁(yè)定制骨架屏。這種方式可以做到對(duì)頁(yè)面真實(shí)樣式的復(fù)刻。不過(guò)一旦由于各種原因?qū)е马?yè)面樣式發(fā)生改變,就需要再改一遍骨架屏的樣式和布局,極大增加了維護(hù)的成本。
3. 自動(dòng)生成靜態(tài)骨架屏
目前比較受關(guān)注的是餓了么開源的插件 page-skeleton-webpack-plugin,其具體實(shí)現(xiàn)原理為:
- 生成骨架屏
通過(guò) Puppter 操控 handless Chrome 打開需要生成的骨架屏頁(yè)面,在等待頁(yè)面加載完成之后,保留頁(yè)面布局樣式的前提下,通過(guò)對(duì)頁(yè)面中元素進(jìn)行增刪,對(duì)已有元素通過(guò)層疊樣式進(jìn)行覆蓋,使其展示為灰白塊。然后將修改后的 HTML 和 CSS 提取出來(lái),將頁(yè)面分為不同的塊區(qū)域,例如文本塊、圖片塊、按鈕塊、SVG、偽類元素塊等,分別對(duì)每個(gè)塊進(jìn)行處理,使其盡量與原頁(yè)面保持一致。這里用到了 Puppetter page 實(shí)例的 addScriptTag 方法來(lái)將處理塊的腳本插入到 headless Chrome 打開的頁(yè)面當(dāng)中。
實(shí)際生成的骨架屏頁(yè)面與原頁(yè)面可能還會(huì)存在差距,插件通過(guò) memory-fs 將骨架屏寫入內(nèi)存中,可以通過(guò)預(yù)覽頁(yè)面對(duì)生成的骨架屏進(jìn)行二次編輯和效果預(yù)覽,修改完成后點(diǎn)擊生成按鈕就能生成一份新的骨架屏寫入到項(xiàng)目中。
借一張圖來(lái)說(shuō)明:
- 插入骨架屏
骨架屏的 DOM 結(jié)構(gòu)和 CSS 通過(guò)離線生成后,在構(gòu)建時(shí)注入模板 (EJS) 中的節(jié)點(diǎn)下面,插入到 HTML 是在 after-emit 鉤子函數(shù)中進(jìn)行。
page-skeleton-webpack-plguin 生成骨架屏的方案可以根據(jù)項(xiàng)目中不同的路由頁(yè)面生成相應(yīng)的骨架屏頁(yè)面,并將骨架屏頁(yè)面通過(guò) webpack 打包到對(duì)應(yīng)的靜態(tài)路由頁(yè)面中。
它的不足之處在于:
- 實(shí)際使用過(guò)程中無(wú)法監(jiān)聽接口返回導(dǎo)致生成骨架屏的時(shí)機(jī)是否準(zhǔn)確
- 生成的頁(yè)面與業(yè)務(wù)人員寫的結(jié)構(gòu)質(zhì)量有直接關(guān)系,經(jīng)常出現(xiàn)需要手工二次調(diào)整的情況
在這樣的背景下,馬蜂窩電商研發(fā)前端團(tuán)隊(duì)希望找一種在提升用戶體驗(yàn)的同時(shí),對(duì)開發(fā)更友好的骨架屏生成方式,能針對(duì)不同的業(yè)務(wù)場(chǎng)景自動(dòng)生成出相似的骨架屏,并且實(shí)現(xiàn)自動(dòng)注入。對(duì)于開發(fā)而言,只需要執(zhí)行一條命令,或者簡(jiǎn)單配置,就可以生成骨架屏,不需要再考慮后續(xù)的維護(hù)工作。
在方案調(diào)研過(guò)程中,draw-page-structure 為我們的設(shè)計(jì)提供了靈感。
4. 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;',
- // ...
- }
根據(jù) URL 指定的線上地址,配合 Puppeteer 獲取當(dāng)前頁(yè)面的 DOM 結(jié)構(gòu),并對(duì)其中元素節(jié)點(diǎn)生成骨架屏文件到 filepath 指定的文件里面,就可以生成骨架屏頁(yè)面,結(jié)果如下圖所示:
- 插入骨架屏
將上述生成的骨架屏文件插入到頁(yè)面根節(jié)點(diǎn)下面一般為 id="app" 的節(jié)點(diǎn),然后在通用工具里提供主動(dòng)銷毀骨架屏的方法,就可以幫助開發(fā)主動(dòng)控制或銷毀骨架屏,顯示頁(yè)面真實(shí)內(nèi)容。
draw-page-structure 的設(shè)計(jì)思想很大程度上可以滿足我們的需求,不足的是只能對(duì)線上已經(jīng)存在的 URL 生成骨架屏,不支持開發(fā)環(huán)境。另外由于是自動(dòng)生成,當(dāng)頁(yè)面存在重定向(如果未登錄重定向到登錄頁(yè)面)的情況時(shí),生成的骨架屏可能與預(yù)期不一致。而且它的內(nèi)部實(shí)現(xiàn)并不完善,可能導(dǎo)致某些結(jié)構(gòu)復(fù)雜的頁(yè)面下生成的骨架屏需要二次優(yōu)化調(diào)整。
于是,我們開始了進(jìn)一步的探索。
三、對(duì)開發(fā)更友好的實(shí)現(xiàn)方案
1. 設(shè)計(jì)思路
基于對(duì)現(xiàn)有方案的借鑒,我們想到了在配置文件中指定要生成骨架屏的頁(yè)面 URL 和文件輸出的目錄,運(yùn)行時(shí)讀取配置文件中的配置項(xiàng),通過(guò) Pupeteer 打開指定的頁(yè)面并注入 evalDom.js 的方法。因?yàn)榇?JS 是在 Pupeteer 里面執(zhí)行的,所以可以獲取到當(dāng)前頁(yè)面完整的 DOM 結(jié)構(gòu),這給我們留下了非常大的發(fā)揮空間。
最初我們是從獲取到的 DOM 結(jié)構(gòu)中的 body 標(biāo)簽出發(fā),遞歸去處理頁(yè)面上的所有節(jié)點(diǎn),處理完成后用生成的 DIV 替換原有元素的位置。第一版方案中通過(guò) getBoundingClientRect 和 getComputedStyle 的方法來(lái)獲取元素所有計(jì)算屬性和相對(duì)于視口的寬高和位置,然后結(jié)合元素本身的樣式屬性遞歸渲染,保留頁(yè)面原始 DOM 嵌套層次。
但由于能夠決定元素位置的屬性實(shí)在太多,如 position,z-index、width、height、top、display、box-sizing、flex 等都需要考慮,導(dǎo)致無(wú)法聚焦對(duì)頁(yè)面 DOM 結(jié)構(gòu)處理的邏輯,而且這些屬性在處理完成后還需要加到最終生成骨架屏節(jié)點(diǎn)的 style 上,這樣骨架屏文件可能比原來(lái)完整的頁(yè)面結(jié)構(gòu)還大,這肯定不是我們希望的。
優(yōu)化后的方案是用 getBoundingClientRect 和 getComputedStyle 獲取元素相關(guān)屬性,然后直接通過(guò)絕對(duì)定位的方式來(lái)生成最終的骨架屏節(jié)點(diǎn)。這樣在頁(yè)面上最終需要的屬性主要是 position、z-index、top、left、width、height、background、border-radius。除了無(wú)法保證頁(yè)面原始的 DOM 結(jié)構(gòu),其它需求基本都可以滿足,也更加聚焦于節(jié)點(diǎn)的處理。
主要實(shí)現(xiàn)流程如下圖:
該方案目前主要應(yīng)用于馬蜂窩電商業(yè)務(wù)的多頁(yè)面項(xiàng)目中,包括下單頁(yè)、簽證頁(yè)等,以下單頁(yè)為例,展示效果如下圖:
2. 實(shí)現(xiàn)方式
- 生成骨架屏
(1) config.js 配置
- const dpsConfig = {
- // 默認(rèn)生成位置為當(dāng)前項(xiàng)目目錄skeleton文件夾,已有骨架屏頁(yè)面不會(huì)再次生成,新頁(yè)面配置只需要添加新條目即可
- visa_guide: {
- url: 'https://w.mafengwo.cn/sfe-app/visa_guide.html?mdd_id=10083', // 必填項(xiàng)
- },
- call_charge: {
- url: 'http://localhost:8081/sfe-app/call_charge.html?rights_id=25', // 必填項(xiàng) 待生成骨架屏頁(yè)面的地址,用百度(https://baidu.com)試試也可以
- //url:'https://www.baidu.com',
- device: 'pc', // 非必填,默認(rèn)mobile
- background: '#eee', // 非必填
- animation: 'opacity 1s linear infinite;', // 非必填
- headless:false, // 非必填
- customizeElement: function(node) { // 非必填
- //返回值枚舉如果是true表示不會(huì)向下遞歸到這層為止,如果返回值是一個(gè)對(duì)象那么節(jié)點(diǎn)的檔子就按照對(duì)象里面的樣式來(lái)繪制
- //如果返回值為0表示正常遞歸渲染
- //如果返回值為1表示渲染當(dāng)前節(jié)點(diǎn)不在向下遞歸
- //如果返回值為2表示對(duì)當(dāng)前節(jié)點(diǎn)不作任何處理
- if(node.className === 'navs-bottom-bar'){
- return 2;
- }
- return 0;
- },
- showInitiativeBtn: true,// 非必填 如果此值設(shè)置為true表示開發(fā)需要主動(dòng)觸發(fā)生成骨架屏了,此時(shí)headless需設(shè)置為false
- writePageStructure: function(html) { // 非必填
- // 自己處理生成的骨架屏
- // fs.writeFileSync(filepath, html);
- // console.log(html)
- },
- init: function() { // 非必填
- // 生成骨架屏之前的操作,比如刪除干擾節(jié)點(diǎn)
- }
- }
- }
- module.exports = dpsConfig;
(2)Pupeteer 新打開頁(yè)面并返回瀏覽器實(shí)例、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實(shí)例
- async function openPage(url, extraHTTPHeaders) {
- const page = await browser.newPage();
- let timeHandle = null;
- if(showInitiativeBtn){
- browser.on('targetchanged', async ()=>{//監(jiān)聽頁(yè)面路由變化,并獲取當(dāng)前標(biāo)簽頁(yè)的最新的頁(yè)面,在showInitiativeBtn為true時(shí)插入按鈕由開發(fā)控制主動(dòng)生成骨架屏
- 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'//不再有網(wǎng)絡(luò)連接時(shí)觸發(fā)(至少500ms后)
- });
- }catch(e){
- console.log('\n');
- log.error(e.message);
- }
- return page;
- }
- return {
- browser,
- openPage
- }
- };
- module.exports = pp;
(3)在瀏覽器環(huán)境里執(zhí)行 evalDom.js 和 evalDom.js 中處理 node 節(jié)點(diǎn)的主要邏輯
- agrs.unshift(evalScripts);//evalScripts = require('../evalDOM');在pupeteer里執(zhí)行evalDom.js并將config.js里配置的參數(shù)傳遞給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];//當(dāng)前子節(jié)點(diǎn)
- //有哪些節(jié)點(diǎn)要跳過(guò)繪制骨架屏的過(guò)程
- if ($this.shouldIgnoreCurrentElement(currentChildNode)) { //是否應(yīng)該忽略當(dāng)前節(jié)點(diǎn),不采取任何措施。后續(xù)這個(gè)地方可以由用戶指定哪些節(jié)點(diǎn)應(yīng)該被略去,todo
- continue;
- }
- const backgroundHasurl = analyseIfHadBackground(currentChildNode);
- const hasDirectTextChild = childrenNodesHasText(currentChildNode);//判斷當(dāng)前元素是不是有直接的子元素并且此元素是Text
- if ($this.customizeElement && $this.customizeElement(currentChildNode) !== 0 && $this.customizeElement(currentChildNode) !== undefined) {
- //開發(fā)者自定義節(jié)點(diǎn)需要渲染的樣子,默認(rèn)返回false表示使用正常遞歸的算法來(lái)處理。如果返回值是true表示不會(huì)在向下遞歸,如果返回值是一個(gè)對(duì)象那么表示開發(fā)需要自定義樣式此時(shí)直接繪制就好。todo
- if (getArgtype($this.customizeElement(currentChildNode)) === 'object') {
- console.log('object');
- //此處如果返回一個(gè)對(duì)象表示對(duì)象要自定義最后繪制的對(duì)象
- } else if ($this.customizeElement(currentChildNode) === 1) {
- //如果此時(shí)返回true,表示此節(jié)點(diǎn)要過(guò)濾
- getRenderStyle(currentChildNode);
- } else if ($this.customizeElement(currentChildNode) === 2){
- continue ;
- }
- continue;
- }
- if (backgroundHasurl || analyseIsEmptyElement(currentChildNode) || hasDirectTextChild || shouldDrawCurrentNode(currentChildNode)) { //如果當(dāng)前元素是內(nèi)聯(lián)元素或者當(dāng)前元素非內(nèi)聯(lián)元素,但是不包含子節(jié)點(diǎn)或者子節(jié)點(diǎn)都是內(nèi)聯(lián)元素的話那么我們就在當(dāng)前的骨架屏上繪制此節(jié)點(diǎn)。
- getRenderStyle(currentChildNode, hasDirectTextChild);
- } else if (currentChildNode.childNodes && currentChildNode.childNodes.length) { //如果當(dāng)前節(jié)點(diǎn)包含子節(jié)點(diǎn)
- //遞歸
- childNodesStyleConcat(currentChildNode.childNodes);
- }
- }
- }
- childNodesStyleConcat(nodes);
- return this.showBlocks();
- },
- 上述 rootNode 為根節(jié)點(diǎn),默認(rèn)為 document.body 或者可以由開發(fā)指定
- 主要邏輯為判斷當(dāng)前節(jié)點(diǎn)是否需要忽略、是否設(shè)置了背景圖片、是否含有文本信息、開發(fā)是否指定了當(dāng)前節(jié)點(diǎn)的處理方式等,對(duì)滿足條件的渲染其對(duì)應(yīng)的骨架屏節(jié)點(diǎn),否則處理當(dāng)前節(jié)點(diǎn)的子節(jié)點(diǎn)
- 所有節(jié)點(diǎn)處理完成后,調(diào)用 showBlocks 將生成的骨架屏節(jié)點(diǎn)拼接位 HTML 字符串,以便后續(xù)處理
(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>`);
zIndex、top、left、width、height 為處理后的屬性,然后把所有骨架屏節(jié)點(diǎn)的字符串都 push 進(jìn) blocks 這個(gè)數(shù)組中。
(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>
- 插入骨架屏
在項(xiàng)目入口 index.html 文件內(nèi)添加
- <body>
- <div id="app">
- </div>
- <% if(htmlWebpackPlugin.options.hasSkeleton) { %>
- <div id="skeleton"><!-- 骨架屏通過(guò)htmlWebpackPlugin在啟動(dòng)打包的時(shí)候自動(dòng)注入 -->
- <%= htmlWebpackPlugin.options.loading.html %>
- </div>
- <% } %>
- <!-- built files will be auto injected -->
- </body>
四、總結(jié)
目前,該方案已經(jīng)支持由開發(fā)主動(dòng)控制骨架屏生成時(shí)間,這樣就避免了頁(yè)面重定向的過(guò)程中無(wú)法生成正確的骨架屏,同時(shí)可以支持在本地開發(fā)時(shí)生成骨架屏。未來(lái)我們將實(shí)現(xiàn)支持開發(fā)自定義生成骨架屏節(jié)點(diǎn)的樣式和組件骨架屏的生成,并優(yōu)化 evalDom.js 內(nèi)部節(jié)點(diǎn)過(guò)濾、處理的算法。敬請(qǐng)期待!
本文作者:康岑波、孫昊男,馬蜂窩電商平臺(tái)前端研發(fā)工程師。
【本文是51CTO專欄作者馬蜂窩技術(shù)的原創(chuàng)文章,作者微信公眾號(hào)馬蜂窩技術(shù)(ID:mfwtech)】