(馬蜂窩技術原創內容,公衆號 ID:mfwtech)html
一份來自 Akamai 的研究報告顯示,在對 1048 名網購戶進行採訪後發現:前端
約 47% 的用戶指望他們的頁面在兩秒以內加載完成。node
若是頁面加載時間超過 3s,約 40% 的用戶會選擇離開或關閉頁面。webpack
一直以來,爲了提高用戶在頁面加載時的體驗,不管是 Web 仍是 iOS、Android 的應用中,前端開發工程師都作了許多工做。除了解決如何讓網頁展示速度更快的問題,還有很重要的一點就是提高用戶對加載等待時間的感知。「菊花圖」以及由其衍生出的各類加載動畫就是一類常見的解決方案,相信不管是開發者仍是用戶對下面這個圖標都不會陌生:web
本文要介紹的「骨架屏」則被視爲菊花圖升級版的方案。受現有骨架屏方案的啓發,馬蜂窩電商前端研發團隊實現了一種自動化生成骨架屏的方法,並在馬蜂窩商城的多個頁面中實現應用,取得了不錯的效果。算法
骨架屏能夠理解爲在頁面數據還沒有返回或頁面未完成徹底渲染前,先給用戶呈現一個由灰白塊組成的當前頁面大體結構,讓用戶產生頁面正在逐漸渲染的感覺,從而使加載過程從視覺上變得流暢。生成後的骨架屏頁面以下圖所示:數組
骨架屏的主要優點爲:瀏覽器
在選擇骨架屏以前,咱們也考慮了一些其餘的方法,好比可否經過服務端渲染(SSR)的方式來避開前端白屏時間的問題。但發現須要涉及項目過多,還會涉及服務的構建與部署;或是經過 prerender-spa-plugin 提供簡單的預呈現,它對 SPA 支持友好,但須要額外的 webpack 配置,且由於包源的問題,下載時間過長,有時還會莫名失敗,等等,都由於種種緣由最終放棄。bash
通過一系列調研後,咱們對業界常見的幾種骨架屏解決方案,以及它們的優點、不足進行了一個簡單的梳理。網絡
即經過 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。
本文做者:康岑波、孫昊男,馬蜂窩電商平臺前端研發工程師。