今天分享的主題是:「一種自動化生成骨架屏的方案」, 先看下市場上常見的骨架屏優(yōu)化效果。





今天的分享主要分為三個部分:
- 首屏加載狀態(tài)演進
- 如何構(gòu)建骨架屏
- 將骨架屏打包的項目中
首屏加載的演進
我們先來看一些權(quán)威機構(gòu)所做的研究報告。
一份是 Akamai 的研究報告,當時總共采訪了大約 1048 名網(wǎng)上購物者,得出了這樣的結(jié)論:
大約有 47% 的用戶期望他們的頁面在兩秒之內(nèi)加載完成。
如果頁面加載時間超過 3s,大約有 40% 的用戶選擇離開或關(guān)閉頁面。

這是 TagMan 和眼鏡零售商 Glasses Direct 合作進行的測試,研究頁面加載速度和最終轉(zhuǎn)化率的關(guān)系:

在這份測試報告中,發(fā)現(xiàn)了網(wǎng)頁加載速度和轉(zhuǎn)化率呈現(xiàn)明顯的負相關(guān)性,在頁面加載時間為1~2 秒時的轉(zhuǎn)化率是最高的,而當加載時間繼續(xù)增長,轉(zhuǎn)化率開始呈現(xiàn)一個下降的趨勢,大約頁面加載時間每增加 1s 轉(zhuǎn)化率下降6.7個百分點。
另外一份研究報告是 MIT 神經(jīng)科學家在 2014 年做的研究,人類可以在 13ms 內(nèi)感知到離散圖片的存在,并將圖片的大概信息傳輸?shù)轿覀兊拇竽X中,在接下來的 100 到 140ms 之間,大腦會決定我們的眼睛具體關(guān)注圖片的什么位置,也就是獲取圖片的關(guān)注焦點。從另一個角度來看,如果用戶進行某項交互(比如點擊某按鈕),要讓用戶感知不到延遲或者數(shù)據(jù)加載,我們大概有 200 ms 的時間來準備新的界面信息呈現(xiàn)給用戶。
在 200ms 到 1s 之間,用戶似乎還感知不到自己處在交互等待狀態(tài),當一秒鐘后依然得不到任何反饋,用戶將會把其關(guān)注的焦點移到其他地方,如果等待超過 10s,用戶將對網(wǎng)站失去興趣,并瀏覽其他網(wǎng)站。
那么我們需要做些什么來留住用戶呢?
通常方案,我們會在首屏、或者獲取數(shù)據(jù)時,在頁面中展現(xiàn)一個進度條,或者轉(zhuǎn)動的 Spinner。
進度條:明確知道交互所需時間,或者知道一個大概值的時候我們選擇使用進度條。
Spinner:無法預測獲取數(shù)據(jù)、或者打開頁面的時長。
有了進度條或者 Spinner,至少告訴了用戶兩點內(nèi)容:
你所進行的操作需要等待一段時間。
其次,安撫用戶,讓其耐心等待。
除此之外,進度條和 Spinner 并不能帶來其他任何作用,既無法讓用戶感知到頁面加載得更快,也無法給用戶一個焦點,讓用戶將關(guān)注集中到這個焦點上,并且知道這個焦點即將呈現(xiàn)用戶感興趣的內(nèi)容。
那么有沒有比進度條和 Spinner 更好的方案呢?也許我們需要的是骨架屏。

其實,骨架屏(Skeleton Screen)已經(jīng)不是什么新奇的概念了,Luke Wroblewski 早在 2013 年就首次提出了骨架屏的概念,并將這一概念成功得運用到他當時的產(chǎn)品「Polar app」中,2014 年,「Polar」加入 Google,Luke Wroblewski 本人也成為了Google 的一位產(chǎn)品總監(jiān)。
A skeleton screen is essentially a blank version of a page into which information is gradually loaded.
他是這樣定義骨架屏的,他認為骨架屏是一個頁面的空白版本,通過這個空白版本傳遞信息,我們的頁面正在漸進式的加載過程中。
蘋果公司已經(jīng)將骨架屏寫入到了 iOS Human Interface Guidelines ,只是在該手冊中,其用了一個新的概念「launch images」。在該手冊中,其推薦在應用首屏中包含文本或者元素基本的輪廓。
2015 年,F(xiàn)acebook 也首次在其移動端 App 中使用了骨架屏的設計來預覽頁面的加載狀態(tài)。

隨后,Twitter,Medium,YouTube 也都在其產(chǎn)品設計中添加了骨架屏,骨架屏一時成為了首屏加載的新趨勢,國內(nèi)一些公司也緊隨其后,餓了么、知乎、掘金、騰訊新聞等也都在其 PC 端或者移動端加入了骨架屏設計。
<b>為什么需要骨架屏?</b>
在最開始關(guān)于 MIT 2014 年的研究中已有提到,用戶大概會在 200ms 內(nèi)獲取到界面的具體關(guān)注點,在數(shù)據(jù)獲取或頁面加載完成之前,給用戶首先展現(xiàn)骨架屏,骨架屏的樣式、布局和真實數(shù)據(jù)渲染的頁面保持一致,這樣用戶在骨架屏中獲取到關(guān)注點,并能夠預知頁面什么地方將要展示文字什么地方展示圖片,這樣也就能夠?qū)㈥P(guān)注焦點移到感興趣的位置。當真實數(shù)據(jù)獲取后,用真實數(shù)據(jù)渲染的頁面替換骨架屏,如果整個過程在 1s 以內(nèi),用戶幾乎感知不到數(shù)據(jù)的加載過程和最終渲染的頁面替換骨架屏,而在用戶的感知上,出現(xiàn)骨架屏那一刻數(shù)據(jù)已經(jīng)獲取到了,而后只是數(shù)據(jù)漸進式的渲染出來。這樣用戶感知頁面加載更快了。
再看看現(xiàn)在的前端框架, React 、Vue 、Angular 已經(jīng)占據(jù)了主導地位,市面上大多數(shù)前端應用也都是基于這三個框架或庫完成,這三個框架有一個共同的特點,都是 JS 驅(qū)動,在 JS 代碼解析完成之前,頁面不會展示任何內(nèi)容,也就是所謂的白屏。用戶是極其不喜歡看到白屏的,什么都沒有展示,用戶很有可能懷疑網(wǎng)絡或者應用出了什么問題。 拿 Vue 來說,在應用啟動時,Vue 會對組件中的 data 和 computed 中狀態(tài)值通過
Object.defineProperty方法轉(zhuǎn)化成 set、get 訪問屬性,以便對數(shù)據(jù)變化進行監(jiān)聽。而這一過程都是在啟動應用時完成的,這也勢必導致頁面啟動階段比非 JS 驅(qū)動(比如 jQuery 應用)的頁面要慢一些。
如何構(gòu)建骨架屏
餓了么移動 web 頁面在 2016 年開始引入骨架屏,是完全通過 HTML 和 CSS 手寫的,手寫骨架屏當然可以完全復刻頁面的真實樣式,但也有弊端:
舉個例子,突然有一天,產(chǎn)品經(jīng)理跑到了我面前,這個頁面布局需要調(diào)整一下,然后這一塊推廣內(nèi)容可以去掉了,我當時的心情可能是這樣的。

手寫骨架屏帶來的問題就是,每次需求的變更我們不僅需要修改業(yè)務代碼, 同時也要去修改骨架屏的樣式和布局,這往往是比較機械重復的工作,手寫骨架屏增加了維護成本。
因此餓了么前端團隊一直在尋找一種更好、更快的將數(shù)據(jù)呈現(xiàn)到用戶面前的方案。
在選擇骨架屏之前,我們也調(diào)研了其他兩種備選方案:服務端渲染(ssr)和預渲染(prerender)。

現(xiàn)在,前端領域,不同框架下,服務端渲染的技術(shù)已經(jīng)相當成熟,開箱即用的方案也有,比如 Vue 的 Nuxt.js。那么為什么不直接使用服務端渲染來加快內(nèi)容展現(xiàn)?
首先我們了解到,服務端渲染主要有兩個目的,一是 SEO,二是加快內(nèi)容展現(xiàn)。在帶來這兩個好處的同時,我們也需要評估服務端渲染的成本,首先我們需要服務端的支持,因此涉及到了到了服務構(gòu)建、部署等,同時我們的 web 項目是一個流量較大的網(wǎng)站,也需要考慮服務器的負載,以及相應的緩存策略,特別是一些外賣行業(yè),由于地理位置的不同,不同用戶看到的頁面也是不一樣的,也就是所謂的千人千面,這也為緩存造成了一定困難。

其次,預渲染(prerender),所謂預渲染,就是在項目的構(gòu)建過程中,通過一些渲染機制,比如 puppeteer 或則 jsdom 將頁面在構(gòu)建的過程中就渲染好,然后插入到 html 中,這樣在頁面啟動之前首先看到的就是預渲染的頁面了。但是該方案最終也拋棄了,預渲染渲染的頁面數(shù)據(jù)是在構(gòu)建過程中就已經(jīng)打包到了 html 中, 當真實訪問頁面的時候,真實數(shù)據(jù)可能已經(jīng)和預渲染的數(shù)據(jù)有了很大的出入,而且預渲染的頁面也是一個不可交互的頁面,在頁面沒有啟動之前,用戶無法和預渲染的頁面進行任何交互,預渲染頁面中的數(shù)據(jù)反而會影響到用戶獲取真實的信息,當涉及到一些價格、金額、地理位置的地方甚至會導致用戶做出一些錯誤的決定。因此我們最終沒有選擇預渲染方案。
生成骨架屏基本方案
通過 puppeteer 在服務端操控 headless Chrome 打開開發(fā)中的需要生成骨架屏的頁面,在等待頁面加載渲染完成之后,在保留頁面布局樣式的前提下,通過對頁面中元素進行刪減或增添,對已有元素通過層疊樣式進行覆蓋,這樣達到在不改變頁面布局下,隱藏圖片和文字,通過樣式覆蓋,使得其展示為灰色塊。然后將修改后的
HTML和CSS樣式提取出來,這樣就是骨架屏了。
下面我將通過 page-skeleton-webpack-plugin 工具中的代碼,來展示骨架屏的具體生成過程。
正如上面基本方案所描述的那樣,我們將頁面分成了不同的塊:
文本塊:僅包含文本節(jié)點(NodeType 為 Node.TEXT_NODE)的元素(NodeType 為 Node.ELEMENT_NODE),一個文本塊可能是一個 p 元素也可能是 div 等。文本塊將會被轉(zhuǎn)化為灰色條紋。
圖片塊:圖片塊是很好區(qū)分的,任何 img 元素都將被視為圖片塊,圖片塊的顏色將被處理成配置的顏色,形狀也被修改為配置的矩形或者圓型。
按鈕塊:任何 button 元素、 type 為 button 的 input 元素,role 為 button 的 a 元素,都將被視為按鈕塊。按鈕塊中的文本塊不在處理。
svg 塊:任何最外層是 svg 的元素都被視為 svg 塊。
偽類元素塊:任何偽類元素都將視為偽類元素塊,如 ::before 或者 ::after。
...
首先,我們?yōu)槭裁匆秧撁鎰澐譃椴煌膲K呢?
將頁面劃分為不同的塊,然后分別對每個塊進行處理,這樣不會破壞頁面整體的樣式和布局,當我們最終生成骨架屏后,骨架屏的布局樣式將和真實頁面的布局樣式完全一致,這樣就達到了復用樣式及頁面布局的目的。
在所有分開處理之前,我們需要完成一項工作,就是將我們生成骨架屏的腳本,插入到 puppeteer 打開的頁面中,這樣我們才能夠執(zhí)行腳本,并最終生成骨架屏。
值得慶幸的是,puppeteer 在其生成的 page 實例中提供了一個原生的方法。
page.addScriptTag(options)
options<Object>
url
path
content
type(Use 'module' in order to load a Javascript ES6 module.)
有了這種方法,我們可以插入一段 js 腳本的 url 或者是相對/絕對路徑,也可以直接是 js 腳本的內(nèi)容,在我們的實踐過程中,我們直接插入的腳本內(nèi)容。
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 方法來執(zhí)行腳本內(nèi)容并最終生成骨架頁面了。
由于時間有限,這兒不會對每個塊的生成骨架結(jié)構(gòu)進行詳盡分析,這兒可能會重點闡述下文本塊、圖片塊、svg 塊如何生成骨架結(jié)構(gòu)的,然后再談談如何對骨架結(jié)構(gòu)進行優(yōu)化。
文本塊的骨架結(jié)構(gòu)生成
文本塊可以算是骨架屏生成中最復雜的一個區(qū)塊了,正如上面也說的,任何只包含文本節(jié)點的元素都將視為文本塊,在確定某個元素是文本塊后,下一步就是通過一些 CSS 樣式,以及元素的增減將其修改為骨架樣式。

在這張圖中,圖左邊虛線框內(nèi)是一個 p 元素,可以看到其內(nèi)部有 4 行文本,右圖是一個已經(jīng)生成好的帶有 4 行文本的骨架屏。在生成文本塊骨架屏之前,我們首先需要了解一些基本的參數(shù)。
單行文本內(nèi)容的高度,可以通過 fontSize 獲取到。
單行文本內(nèi)容加空白間隙的高度,可以通過 lineHeight 獲取到。
p 元素總共有多少行文本,也就是所謂行數(shù),這個可以通過 p 元素的
(height - paddingTop - paddingBottom)/ lineHeight大概算出。文本的 textAlign 屬性。
在這些參數(shù)中,fontSize、lineHeight、paddingTop、paddingBottom 都可以通過 getComputedStyle 獲取到,而元素的高度 height 可以通過 getBoundingClientRect 獲取到,有了這些參數(shù)后我們就能夠繪制文本塊的骨架屏了。

相信很多人都讀過 @Lea Verou 的 CSS Secrets 這本書,書中有一篇專門闡述怎么通過線性漸變生成條紋背景的文章,而在繪制文本塊骨架屏方案,正是受到了這篇文章的啟發(fā),文本塊的骨架屏也是通過線性漸變來繪制的。核心簡化代碼:
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 等一些樣式參數(shù),通過這些參數(shù)我們計算出了文本占整個行高的比值,也就是 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。雖然我們把灰色條紋繪制出來了,但是,我們的文字依然顯示,在最終骨架樣式效果出現(xiàn)之前,我們還需要隱藏文字,設置 color:‘transparent’ 這樣我們的文字就和背景色一致,最終顯示得也就是灰色條紋了。
根據(jù) lineCount 我們可以判斷文本塊是單行文本還是多行,在處理單行文本的時候,由于文本的寬度并沒有整行寬度,因此,針對單行文本,我們還需要計算出文本的寬度,然后設置灰色條紋的寬度為文本寬度,這樣骨架樣式的效果才能夠更加接近文本樣式。
圖片塊的骨架生成
圖片塊的繪制比文本塊要相對簡單很多,但是在訂方案的過程中也踩了一些坑,這兒簡單分享下采坑經(jīng)歷。
最初訂的方案是通過一個 DIV 元素來替換 IMG 元素,然后設置 DIV 元素背景為灰色,DIV 的寬高等同于原來 IMG 元素的寬高,這種方案有一個嚴重的弊端就是,原來通過元素選擇器設置到 IMG 元素上的樣式無法運用到 DIV 元素上面,導致最終圖片塊的骨架效果和真實的圖片在頁面樣式上有出入,特別是沒法適配不同的移動端設備,因為 DIV 的寬高被硬編碼。
接下來我們又嘗試了一種看似「高級」的方法,通過 Canvas 來繪制和原來圖片大小相同的灰色塊,然后將 Canvas 轉(zhuǎn)化為 dataUrl 賦予給 IMG 元素的 src 特性上,這樣 IMG 元素就顯示成了一個灰色塊了,看似完美,當我們將生成的骨架頁面生成 HTML 文件時,一下就傻眼了,文件大小盡然有 200 多 kb,我們做骨架頁面渲染的一個重要原因就是希望用戶在感知上感覺頁面加載快了,如果骨架頁面都有 200 多 kb,必將導致頁面加載比之前要慢一些,違背了我們的初衷,因此該方案也只能夠放棄。
最終方案,我們選擇了將一張1 * 1 像素的 gif 透明圖片,轉(zhuǎn)化成 dataUrl ,然后將其賦予給 IMG 元素的 src 特性上,同時設置圖片的 width 和 height 特性為之前圖片的寬高,將背景色調(diào)至為骨架樣式所配置的顏色值,完美解決了所有問題。
// 最小 1 * 1 像素的透明 gif 圖片
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
這是1 * 1像素的 base64 格式的圖片,總共只有幾十個字節(jié),明顯比之前通過 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 塊骨架結(jié)構(gòu)
svg 塊處理起來也比較簡單,首先我們需要判斷 svg 元素 hidden 屬性是否為 true,如果為 true,說明該元素不展示的,所以我們可以直接刪除該元素。
if (width === 0 || height === 0 || ele.getAttribute('hidden') === 'true') {
return removeElement(ele)
}
如果不是隱藏的元素,那么我們將會把 svg 元素內(nèi)部所有元素刪除,減少最終生成的骨架頁面體積,其次,設置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)
}
一些優(yōu)化的細節(jié)
- 首先,由上面一些代碼可以看出,在我們生成骨架頁面的過程中,我們將所有的共用樣式通過 addStyle 方法緩存起來,最后在生成骨架屏的時候,統(tǒng)一通過 style 標簽插入到骨架屏中。這樣保證了樣式盡可能多的復用。
- 其次,在處理列表的時候,為了生成骨架屏盡可能美觀,我們對列表進行了同化處理,也就是說將 list 中所有的 listItem 都是同一個 listItem 的克隆。這樣生成的 list 的骨架屏樣式就更加統(tǒng)一了。
- 還有就是,正如前文所說,骨架屏僅是一種加載狀態(tài),并非真實頁面,因此其并不需要完整的頁面,其實只需要首屏就好了,我們對非首屏的元素進行了刪除,只保留了首屏內(nèi)部元素,這樣也大大縮減了生成骨架屏的體積。
- 刪除無用的 CSS 樣式,只是我們只提取了對骨架屏有用的 CSS,然后通過 style 標簽引入。
關(guān)鍵代碼大致是這樣的:
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 樣式?jīng)]有用到,所以移除。
在后面的一些 slides 中,我們來聊聊怎講將構(gòu)建骨架屏和 webpack 開發(fā)、打包結(jié)合起來,最終將我們的骨架屏打包到實際項目中。
通過 webpack 將骨架屏打包到項目中
在上一個部分,我們分析了怎么去生成骨架屏,在這一部分,我們將探討如何通過 webpack 將骨架屏打包的項目中。在這過程中,思考了以下一些問題:
為什么在開發(fā)過程中生成骨架屏?
其主要原因還是為了骨架屏的可編輯。
在上一個部分,我們通過一些樣式和元素的修改生成了骨架屏頁面,但是我們并沒有馬上將其寫入到配置的輸出文件夾中,在寫入骨架頁面到項目之前。我們通過 memory-fs 將骨架屏寫入到內(nèi)存中,以便我們能夠通過預覽頁面進行訪問。同時我們也將骨架屏源碼發(fā)送到了預覽頁面,這樣我們就可以通過修改源碼,對骨架屏進行二次編輯。
正如這張圖片,這張圖是插件打開的骨架屏的預覽頁面,從左到右依次是開發(fā)中的真實頁面、骨架屏、骨架屏可編輯源碼。

這樣我們就可以在開發(fā)過程中對骨架屏進行編輯,修改部分樣式,中部骨架屏可以進行實時預覽,這之間的通信都是通過websocket 來完成的。當我們對生成的骨架屏滿意后,并點擊右上角寫入骨架屏按鈕,將骨架屏寫入到項目中,在最后項目構(gòu)建時,將骨架屏打包到項目中。
如果我們同時在構(gòu)建的過程中生成骨架屏,并打包到項目中,這時的骨架屏我們是無法預覽的,因此我們對此時的骨架屏一無所知,也不能夠做任何修改,這就是我們在開發(fā)中生成骨架屏的原因所在。
演講最開始已經(jīng)提到,目前流行的前端框架基本都是 JS 驅(qū)動,也就是說,在最初的 index.html 中我們不用寫太多的 html 內(nèi)容,而是等框架啟動完成后,通過運行時將內(nèi)容填充到 html 中,通常我們會在 html 模板中添加一個根元素:
<div id="app"></div>
當應用啟動后,會將真實的內(nèi)容填充到上面的元素中。這也就給了我們一個展示骨架屏的機會,我們將骨架屏在頁面啟動之前添加到上面元素內(nèi):
<div id="app"><!-- shell.html --></div>
怎樣將骨架屏打包到項目中
Webpack 是一款優(yōu)秀的前端打包工具,其也提供了一些豐富的 API 讓我們可以自己編寫一些插件來讓 webpack 完成更多的工作,比如在構(gòu)建過程中,將骨架屏打包到項目中。
Webpack 在整個打包的過程中提供了眾多生命周期事件,比如compilation 、after-emit 等,比如我們最終將骨架屏插入到 html 中就是在after-emit 鉤子函數(shù)中進行的,簡單的代碼:
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 插件在我們內(nèi)部團隊已經(jīng)開始使用,在使用的過程中我們也得到了一些反饋信息。
首先是對 SPA 多路由的支持,其實現(xiàn)在插件已經(jīng)支持多路由了,只是還沒有用到真實項目中,我們針對每一個路由頁面生成一個單獨的 index.html,也就是靜態(tài)路由。然后將每個路由生成的骨架屏插入到不同的靜態(tài)路由的 html 中。
其次,玩過服務端渲染的同學都知道,在 React 和 Vue 服務端渲染中有一種稱為 Client-side Hydration 的技術(shù),指的是在 Vue 在瀏覽器接管由服務端發(fā)送來的靜態(tài) HTML,使其變?yōu)橛?Vue 管理的動態(tài) DOM 的過程。
在我們構(gòu)建骨架屏的過程中,其 DOM 結(jié)構(gòu)和真實頁面的 DOM 結(jié)構(gòu)基本相同,只是添加了一些行內(nèi)樣式和 classname,我們也在思考這些 DOM 能夠被復用,也就是在應用啟動時重新創(chuàng)建所有 DOM。我們只用激活這些骨架屏 DOM,讓其能夠相應數(shù)據(jù)的變化,這似乎就可以使骨架屏和真實頁面更好的融合。
還有,在頁面啟動后,我們可能還是會通過 AJAX 獲取后端數(shù)據(jù),這時候我們也可以通過 骨架屏 來作為一種加載狀態(tài)。也就是說,其實我們可以在「非首屏骨架屏」上做一些工作。
最后,在項目中可能會有一些性能監(jiān)控的需求,比如骨架屏什么時候創(chuàng)建,什么時候被銷毀,這些我們可能都希望通過一些性能監(jiān)控的工具記錄下來,以便將來做一些性能上面的分析。因此將來也會提供一些骨架屏的生命周期函數(shù),或者提供相應的自定義事件,在生命周期不同階段,調(diào)用相應的生命周期鉤子函數(shù)或監(jiān)聽相應事件,這樣就可以將骨架屏的一些數(shù)據(jù)記錄到性能監(jiān)控軟件中。
本文摘自:一種自動化生成骨架屏的方案
推薦閱讀