今天的分享主要分為三個(gè)部分:
首屏加載狀態(tài)演進(jìn)
如何構(gòu)建骨架屏
將骨架屏打包的項(xiàng)目中
首屏加載的演進(jìn)
我們先來(lái)看一些權(quán)威機(jī)構(gòu)所做的研究報(bào)告袱讹。
一份是 Akamai 的研究報(bào)告掸绞,當(dāng)時(shí)總共采訪了大約 1048 名網(wǎng)上購(gòu)物者吻贿,得出了這樣的結(jié)論:
大約有 47% 的用戶期望他們的頁(yè)面在兩秒之內(nèi)加載完成凭峡。
如果頁(yè)面加載時(shí)間超過(guò) 3s狈谊,大約有 40% 的用戶選擇離開(kāi)或關(guān)閉頁(yè)面榜聂。
這是 TagMan 和眼鏡零售商 Glasses Direct 合作進(jìn)行的測(cè)試宁脊,研究頁(yè)面加載速度和最終轉(zhuǎn)化率的關(guān)系:
在這份測(cè)試報(bào)告中箍镜,發(fā)現(xiàn)了網(wǎng)頁(yè)加載速度和轉(zhuǎn)化率呈現(xiàn)明顯的負(fù)相關(guān)性,在頁(yè)面加載時(shí)間為1~2 秒時(shí)的轉(zhuǎn)化率是最高的渔欢,而當(dāng)加載時(shí)間繼續(xù)增長(zhǎng)墓塌,轉(zhuǎn)化率開(kāi)始呈現(xiàn)一個(gè)下降的趨勢(shì),大約頁(yè)面加載時(shí)間每增加 1s 轉(zhuǎn)化率下降6.7個(gè)百分點(diǎn)奥额。
另外一份研究報(bào)告是 MIT 神經(jīng)科學(xué)家在 2014 年做的研究苫幢,人類可以在 13ms 內(nèi)感知到離散圖片的存在,并將圖片的大概信息傳輸?shù)轿覀兊拇竽X中垫挨,在接下來(lái)的 100 到 140ms 之間韩肝,大腦會(huì)決定我們的眼睛具體關(guān)注圖片的什么位置,也就是獲取圖片的關(guān)注焦點(diǎn)九榔。從另一個(gè)角度來(lái)看哀峻,如果用戶進(jìn)行某項(xiàng)交互(比如點(diǎn)擊某按鈕),要讓用戶感知不到延遲或者數(shù)據(jù)加載帚屉,我們大概有 200 ms 的時(shí)間來(lái)準(zhǔn)備新的界面信息呈現(xiàn)給用戶谜诫。
在 200ms 到 1s 之間,用戶似乎還感知不到自己處在交互等待狀態(tài)攻旦,當(dāng)一秒鐘后依然得不到任何反饋喻旷,用戶將會(huì)把其關(guān)注的焦點(diǎn)移到其他地方,如果等待超過(guò) 10s牢屋,用戶將對(duì)網(wǎng)站失去興趣且预,并瀏覽其他網(wǎng)站槽袄。
那么我們需要做些什么來(lái)留住用戶呢?
通常方案锋谐,我們會(huì)在首屏遍尺、或者獲取數(shù)據(jù)時(shí),在頁(yè)面中展現(xiàn)一個(gè)進(jìn)度條涮拗,或者轉(zhuǎn)動(dòng)的 Spinner乾戏。
進(jìn)度條:明確知道交互所需時(shí)間,或者知道一個(gè)大概值的時(shí)候我們選擇使用進(jìn)度條三热。
Spinner:無(wú)法預(yù)測(cè)獲取數(shù)據(jù)鼓择、或者打開(kāi)頁(yè)面的時(shí)長(zhǎng)。
有了進(jìn)度條或者 Spinner就漾,至少告訴了用戶兩點(diǎn)內(nèi)容:
你所進(jìn)行的操作需要等待一段時(shí)間呐能。
其次,安撫用戶抑堡,讓其耐心等待摆出。
除此之外,進(jìn)度條和 Spinner 并不能帶來(lái)其他任何作用首妖,既無(wú)法讓用戶感知到頁(yè)面加載得更快偎漫,也無(wú)法給用戶一個(gè)焦點(diǎn),讓用戶將關(guān)注集中到這個(gè)焦點(diǎn)上悯搔,并且知道這個(gè)焦點(diǎn)即將呈現(xiàn)用戶感興趣的內(nèi)容骑丸。
那么有沒(méi)有比進(jìn)度條和 Spinner 更好的方案呢舌仍?也許我們需要的是骨架屏妒貌。
其實(shí),骨架屏(Skeleton Screen)已經(jīng)不是什么新奇的概念了铸豁,Luke Wroblewski 早在 2013 年就首次提出了骨架屏的概念灌曙,并將這一概念成功得運(yùn)用到他當(dāng)時(shí)的產(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.
他是這樣定義骨架屏的头镊,他認(rèn)為骨架屏是一個(gè)頁(yè)面的空白版本蚣驼,通過(guò)這個(gè)空白版本傳遞信息,我們的頁(yè)面正在漸進(jìn)式的加載過(guò)程中相艇。
蘋果公司已經(jīng)將骨架屏寫入到了 iOS Human Interface Guidelines ,只是在該手冊(cè)中颖杏,其用了一個(gè)新的概念「launch images」。在該手冊(cè)中坛芽,其推薦在應(yīng)用首屏中包含文本或者元素基本的輪廓留储。
2015 年翼抠,F(xiàn)acebook 也首次在其移動(dòng)端 App 中使用了骨架屏的設(shè)計(jì)來(lái)預(yù)覽頁(yè)面的加載狀態(tài)。
隨后获讳,Twitter阴颖,Medium,YouTube 也都在其產(chǎn)品設(shè)計(jì)中添加了骨架屏丐膝,骨架屏一時(shí)成為了首屏加載的新趨勢(shì)量愧,國(guó)內(nèi)一些公司也緊隨其后,餓了么帅矗、知乎侠畔、掘金、騰訊新聞等也都在其 PC 端或者移動(dòng)端加入了骨架屏設(shè)計(jì)损晤。
為什么需要骨架屏软棺?
在最開(kāi)始關(guān)于 MIT 2014 年的研究中已有提到,用戶大概會(huì)在 200ms 內(nèi)獲取到界面的具體關(guān)注點(diǎn)尤勋,在數(shù)據(jù)獲取或頁(yè)面加載完成之前喘落,給用戶首先展現(xiàn)骨架屏,骨架屏的樣式最冰、布局和真實(shí)數(shù)據(jù)渲染的頁(yè)面保持一致瘦棋,這樣用戶在骨架屏中獲取到關(guān)注點(diǎn),并能夠預(yù)知頁(yè)面什么地方將要展示文字什么地方展示圖片暖哨,這樣也就能夠?qū)㈥P(guān)注焦點(diǎn)移到感興趣的位置赌朋。當(dāng)真實(shí)數(shù)據(jù)獲取后,用真實(shí)數(shù)據(jù)渲染的頁(yè)面替換骨架屏篇裁,如果整個(gè)過(guò)程在 1s 以內(nèi)沛慢,用戶幾乎感知不到數(shù)據(jù)的加載過(guò)程和最終渲染的頁(yè)面替換骨架屏,而在用戶的感知上达布,出現(xiàn)骨架屏那一刻數(shù)據(jù)已經(jīng)獲取到了团甲,而后只是數(shù)據(jù)漸進(jìn)式的渲染出來(lái)。這樣用戶感知頁(yè)面加載更快了黍聂。
再看看現(xiàn)在的前端框架躺苦, React、Vue产还、Angular 已經(jīng)占據(jù)了主導(dǎo)地位匹厘,市面上大多數(shù)前端應(yīng)用也都是基于這三個(gè)框架或庫(kù)完成,這三個(gè)框架有一個(gè)共同的特點(diǎn)脐区,都是 JS 驅(qū)動(dòng)愈诚,在 JS 代碼解析完成之前,頁(yè)面不會(huì)展示任何內(nèi)容,也就是所謂的白屏扰路。用戶是極其不喜歡看到白屏的尤溜,什么都沒(méi)有展示,用戶很有可能懷疑網(wǎng)絡(luò)或者應(yīng)用出了什么問(wèn)題汗唱。 拿 Vue 來(lái)說(shuō)宫莱,在應(yīng)用啟動(dòng)時(shí),Vue 會(huì)對(duì)組件中的 data 和 computed 中狀態(tài)值通過(guò)
Object.defineProperty
方法轉(zhuǎn)化成 set哩罪、get 訪問(wèn)屬性授霸,以便對(duì)數(shù)據(jù)變化進(jìn)行監(jiān)聽(tīng)。而這一過(guò)程都是在啟動(dòng)應(yīng)用時(shí)完成的际插,這也勢(shì)必導(dǎo)致頁(yè)面啟動(dòng)階段比非 JS 驅(qū)動(dòng)(比如 jQuery 應(yīng)用)的頁(yè)面要慢一些碘耳。
如何構(gòu)建骨架屏
餓了么移動(dòng) web 頁(yè)面在 2016 年開(kāi)始引入骨架屏,是完全通過(guò) HTML 和 CSS 手寫的框弛,手寫骨架屏當(dāng)然可以完全復(fù)刻頁(yè)面的真實(shí)樣式辛辨,但也有弊端:
舉個(gè)例子,突然有一天瑟枫,產(chǎn)品經(jīng)理跑到了我面前斗搞,這個(gè)頁(yè)面布局需要調(diào)整一下,然后這一塊推廣內(nèi)容可以去掉了慷妙,我當(dāng)時(shí)的心情可能是這樣的僻焚。
手寫骨架屏帶來(lái)的問(wèn)題就是,每次需求的變更我們不僅需要修改業(yè)務(wù)代碼膝擂, 同時(shí)也要去修改骨架屏的樣式和布局虑啤,這往往是比較機(jī)械重復(fù)的工作,手寫骨架屏增加了維護(hù)成本架馋。
因此餓了么前端團(tuán)隊(duì)一直在尋找一種更好狞山、更快的將數(shù)據(jù)呈現(xiàn)到用戶面前的方案。
在選擇骨架屏之前绩蜻,我們也調(diào)研了其他兩種備選方案:服務(wù)端渲染(ssr)和預(yù)渲染(prerender)铣墨。
現(xiàn)在室埋,前端領(lǐng)域办绝,不同框架下,服務(wù)端渲染的技術(shù)已經(jīng)相當(dāng)成熟姚淆,開(kāi)箱即用的方案也有孕蝉,比如 Vue 的 Nuxt.js。那么為什么不直接使用服務(wù)端渲染來(lái)加快內(nèi)容展現(xiàn)腌逢?
首先我們了解到降淮,服務(wù)端渲染主要有兩個(gè)目的,一是 SEO,二是加快內(nèi)容展現(xiàn)佳鳖。在帶來(lái)這兩個(gè)好處的同時(shí)霍殴,我們也需要評(píng)估服務(wù)端渲染的成本,首先我們需要服務(wù)端的支持系吩,因此涉及到了到了服務(wù)構(gòu)建来庭、部署等,同時(shí)我們的 web 項(xiàng)目是一個(gè)流量較大的網(wǎng)站穿挨,也需要考慮服務(wù)器的負(fù)載月弛,以及相應(yīng)的緩存策略,特別是一些外賣行業(yè)科盛,由于地理位置的不同帽衙,不同用戶看到的頁(yè)面也是不一樣的,也就是所謂的千人千面贞绵,這也為緩存造成了一定困難厉萝。
其次,預(yù)渲染(prerender)榨崩,所謂預(yù)渲染冀泻,就是在項(xiàng)目的構(gòu)建過(guò)程中,通過(guò)一些渲染機(jī)制蜡饵,比如 puppeteer 或則 jsdom 將頁(yè)面在構(gòu)建的過(guò)程中就渲染好弹渔,然后插入到 html 中,這樣在頁(yè)面啟動(dòng)之前首先看到的就是預(yù)渲染的頁(yè)面了溯祸。但是該方案最終也拋棄了肢专,預(yù)渲染渲染的頁(yè)面數(shù)據(jù)是在構(gòu)建過(guò)程中就已經(jīng)打包到了 html 中, 當(dāng)真實(shí)訪問(wèn)頁(yè)面的時(shí)候焦辅,真實(shí)數(shù)據(jù)可能已經(jīng)和預(yù)渲染的數(shù)據(jù)有了很大的出入博杖,而且預(yù)渲染的頁(yè)面也是一個(gè)不可交互的頁(yè)面,在頁(yè)面沒(méi)有啟動(dòng)之前筷登,用戶無(wú)法和預(yù)渲染的頁(yè)面進(jìn)行任何交互剃根,預(yù)渲染頁(yè)面中的數(shù)據(jù)反而會(huì)影響到用戶獲取真實(shí)的信息,當(dāng)涉及到一些價(jià)格前方、金額狈醉、地理位置的地方甚至?xí)?dǎo)致用戶做出一些錯(cuò)誤的決定。因此我們最終沒(méi)有選擇預(yù)渲染方案惠险。
生成骨架屏基本方案
通過(guò) puppeteer 在服務(wù)端操控 headless Chrome 打開(kāi)開(kāi)發(fā)中的需要生成骨架屏的頁(yè)面苗傅,在等待頁(yè)面加載渲染完成之后,在保留頁(yè)面布局樣式的前提下班巩,通過(guò)對(duì)頁(yè)面中元素進(jìn)行刪減或增添渣慕,對(duì)已有元素通過(guò)層疊樣式進(jìn)行覆蓋,這樣達(dá)到在不改變頁(yè)面布局下,隱藏圖片和文字逊桦,通過(guò)樣式覆蓋眨猎,使得其展示為灰色塊。然后將修改后的 HTML 和 CSS 樣式提取出來(lái)强经,這樣就是骨架屏了宵呛。
下面我將通過(guò) page-skeleton-webpack-plugin 工具中的代碼,來(lái)展示骨架屏的具體生成過(guò)程夕凝。
正如上面基本方案所描述的那樣宝穗,我們將頁(yè)面分成了不同的塊:
文本塊:僅包含文本節(jié)點(diǎn)(NodeType 為
Node.TEXT_NODE
)的元素(NodeType 為Node.ELEMENT_NODE
),一個(gè)文本塊可能是一個(gè) p 元素也可能是 div 等码秉。文本塊將會(huì)被轉(zhuǎn)化為灰色條紋逮矛。圖片塊:圖片塊是很好區(qū)分的,任何 img 元素都將被視為圖片塊转砖,圖片塊的顏色將被處理成配置的顏色须鼎,形狀也被修改為配置的矩形或者圓型。
按鈕塊:任何 button 元素府蔗、 type 為 button 的 input 元素晋控,role 為 button 的 a 元素,都將被視為按鈕塊姓赤。按鈕塊中的文本塊不在處理赡译。
svg 塊:任何最外層是 svg 的元素都被視為 svg 塊。
偽類元素塊:任何偽類元素都將視為偽類元素塊不铆,如
::before
或者::after
蝌焚。...
首先,我們?yōu)槭裁匆秧?yè)面劃分為不同的塊呢誓斥?
將頁(yè)面劃分為不同的塊只洒,然后分別對(duì)每個(gè)塊進(jìn)行處理,這樣不會(huì)破壞頁(yè)面整體的樣式和布局劳坑,當(dāng)我們最終生成骨架屏后毕谴,骨架屏的布局樣式將和真實(shí)頁(yè)面的布局樣式完全一致,這樣就達(dá)到了復(fù)用樣式及頁(yè)面布局的目的距芬。
在所有分開(kāi)處理之前涝开,我們需要完成一項(xiàng)工作,就是將我們生成骨架屏的腳本蔑穴,插入到 puppeteer 打開(kāi)的頁(yè)面中忠寻,這樣我們才能夠執(zhí)行腳本,并最終生成骨架屏存和。
值得慶幸的是,puppeteer 在其生成的 page 實(shí)例中提供了一個(gè)原生的方法。
page.addScriptTag(options)
- options<Object>
* url * path * content * type(Use 'module' in order to load a Javascript ES6 module.)
有了這種方法捐腿,我們可以插入一段 js 腳本的 url 或者是相對(duì)/絕對(duì)路徑纵朋,也可以直接是 js 腳本的內(nèi)容,在我們的實(shí)踐過(guò)程中茄袖,我們直接插入的腳本內(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)
}
有了上面插入的腳本,并且我們?cè)谀_本中提供了一個(gè)全局對(duì)象 Skeleton
宪祥,這樣我們就可以直接通過(guò) page.evaluate 方法來(lái)執(zhí)行腳本內(nèi)容并最終生成骨架頁(yè)面了聂薪。
由于時(shí)間有限,這兒不會(huì)對(duì)每個(gè)塊的生成骨架結(jié)構(gòu)進(jìn)行詳盡分析蝗羊,這兒可能會(huì)重點(diǎn)闡述下文本塊藏澳、圖片塊赌结、svg 塊如何生成骨架結(jié)構(gòu)的颈嚼,然后再談?wù)勅绾螌?duì)骨架結(jié)構(gòu)進(jìn)行優(yōu)化。
好委粉,我們?cè)賮?lái)說(shuō)下文本塊的骨架結(jié)構(gòu)生成野芒。
文本塊的骨架結(jié)構(gòu)生成
文本塊可以算是骨架屏生成中最復(fù)雜的一個(gè)區(qū)塊了蓄愁,正如上面也說(shuō)的,任何只包含文本節(jié)點(diǎn)的元素都將視為文本塊狞悲,在確定某個(gè)元素是文本塊后撮抓,下一步就是通過(guò)一些 CSS 樣式,以及元素的增減將其修改為骨架樣式摇锋。
在這張圖中胀滚,圖左邊虛線框內(nèi)是一個(gè) p 元素,可以看到其內(nèi)部有 4 行文本乱投,右圖是一個(gè)已經(jīng)生成好的帶有 4 行文本的骨架屏咽笼。在生成文本塊骨架屏之前,我們首先需要了解一些基本的參數(shù)戚炫。
單行文本內(nèi)容的高度剑刑,可以通過(guò) fontSize 獲取到。
單行文本內(nèi)容加空白間隙的高度双肤,可以通過(guò) lineHeight 獲取到施掏。
p 元素總共有多少行文本,也就是所謂行數(shù)茅糜,這個(gè)可以通過(guò) p 元素的(height - paddingTop - paddingBottom)/ lineHeight 大概算出七芭。
文本的 textAlign 屬性。
在這些參數(shù)中蔑赘,fontSize狸驳、lineHeight预明、paddingTop、paddingBottom 都可以通過(guò) getComputedStyle 獲取到耙箍,而元素的高度 height 可以通過(guò) getBoundingClientRect 獲取到撰糠,有了這些參數(shù)后我們就能夠繪制文本塊的骨架屏了。
相信很多人都讀過(guò) @Lea Verou 的 CSS Secrets 這本書(shū)辩昆,書(shū)中有一篇專門闡述怎么通過(guò)線性漸變生成條紋背景的文章阅酪,而在繪制文本塊骨架屏方案,正是受到了這篇文章的啟發(fā)汁针,文本塊的骨架屏也是通過(guò)線性漸變來(lái)繪制的术辐。核心簡(jiǎn)化代碼看屏幕:
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;
}`
我們首先計(jì)算了lineHeight 和 fontSize 等一些樣式參數(shù),通過(guò)這些參數(shù)我們計(jì)算出了文本占整個(gè)行高的比值施无,也就是 textHeightRadio辉词,有了這一比值,就可以知道灰色條紋的分界點(diǎn)帆精,正如 @lea Verou 所說(shuō):
摘自: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)
也就是說(shuō)较屿,在線性漸變中,如果我們將線性漸變的起始點(diǎn)設(shè)置小于前一個(gè)顏色點(diǎn)的起始值卓练,或者設(shè)置為0 %隘蝎,那么線性漸變將會(huì)消失,取而代之的將是兩條顏色分明的條紋襟企,也就是說(shuō)不再有線性漸變嘱么。
在我們繪制文本塊的時(shí)候,backgroundSize 寬度為 100%顽悼, 高度為 lineHeight曼振,也就是灰色條紋加透明條紋的高度是 lineHeight。雖然我們把灰色條紋繪制出來(lái)了蔚龙,但是冰评,我們的文字依然顯示,在最終骨架樣式效果出現(xiàn)之前木羹,我們還需要隱藏文字甲雅,設(shè)置 color:‘transparent’
這樣我們的文字就和背景色一致,最終顯示得也就是灰色條紋了坑填。
根據(jù) lineCount 我們可以判斷文本塊是單行文本還是多行抛人,在處理單行文本的時(shí)候,由于文本的寬度并沒(méi)有整行寬度脐瑰,因此妖枚,針對(duì)單行文本,我們還需要計(jì)算出文本的寬度苍在,然后設(shè)置灰色條紋的寬度為文本寬度绝页,這樣骨架樣式的效果才能夠更加接近文本樣式荠商。
圖片塊的骨架生成
圖片塊的繪制比文本塊要相對(duì)簡(jiǎn)單很多,但是在訂方案的過(guò)程中也踩了一些坑抒寂,這兒簡(jiǎn)單分享下采坑經(jīng)歷结啼。
最初訂的方案是通過(guò)一個(gè) DIV 元素來(lái)替換 IMG 元素掠剑,然后設(shè)置 DIV 元素背景為灰色屈芜,DIV 的寬高等同于原來(lái) IMG 元素的寬高,這種方案有一個(gè)嚴(yán)重的弊端就是朴译,原來(lái)通過(guò)元素選擇器設(shè)置到 IMG 元素上的樣式無(wú)法運(yùn)用到 DIV 元素上面井佑,導(dǎo)致最終圖片塊的骨架效果和真實(shí)的圖片在頁(yè)面樣式上有出入,特別是沒(méi)法適配不同的移動(dòng)端設(shè)備眠寿,因?yàn)?DIV 的寬高被硬編碼躬翁。
接下來(lái)我們又嘗試了一種看似「高級(jí)」的方法,通過(guò) Canvas 來(lái)繪制和原來(lái)圖片大小相同的灰色塊盯拱,然后將 Canvas 轉(zhuǎn)化為 dataUrl 賦予給 IMG 元素的 src 特性上盒发,這樣 IMG 元素就顯示成了一個(gè)灰色塊了,看似完美狡逢,當(dāng)我們將生成的骨架頁(yè)面生成 HTML 文件時(shí)宁舰,一下就傻眼了,文件大小盡然有 200 多 kb奢浑,我們做骨架頁(yè)面渲染的一個(gè)重要原因就是希望用戶在感知上感覺(jué)頁(yè)面加載快了蛮艰,如果骨架頁(yè)面都有 200 多 kb,必將導(dǎo)致頁(yè)面加載比之前要慢一些雀彼,違背了我們的初衷壤蚜,因此該方案也只能夠放棄。
最終方案徊哑,我們選擇了將一張1 * 1 像素的 gif 透明圖片袜刷,轉(zhuǎn)化成 dataUrl ,然后將其賦予給 IMG 元素的 src 特性上莺丑,同時(shí)設(shè)置圖片的 width 和 height 特性為之前圖片的寬高著蟹,將背景色調(diào)至為骨架樣式所配置的顏色值,完美解決了所有問(wèn)題窒盐。
// 最小 1 * 1 像素的透明 gif 圖片
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
這是1 * 1像素的 base64 格式的圖片草则,總共只有幾十個(gè)字節(jié),明顯比之前通過(guò) 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 塊處理起來(lái)也比較簡(jiǎn)單炕横,首先我們需要判斷 svg 元素 hidden 屬性是否為 true,如果為 true葡粒,說(shuō)明該元素不展示的份殿,所以我們可以直接刪除該元素膜钓。
if (width === 0 || height === 0 || ele.getAttribute('hidden') === 'true') {
return removeElement(ele)
}
如果不是隱藏的元素,那么我們將會(huì)把 svg 元素內(nèi)部所有元素刪除卿嘲,減少最終生成的骨架頁(yè)面體積颂斜,其次,設(shè)置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)化的細(xì)節(jié)
- 首先,由上面一些代碼可以看出梅肤,在我們生成骨架頁(yè)面的過(guò)程中司蔬,我們將所有的共用樣式通過(guò)
addStyle
方法緩存起來(lái),最后在生成骨架屏的時(shí)候姨蝴,統(tǒng)一通過(guò) style 標(biāo)簽插入到骨架屏中俊啼。這樣保證了樣式盡可能多的復(fù)用。 - 其次左医,在處理列表的時(shí)候授帕,為了生成骨架屏盡可能美觀,我們對(duì)列表進(jìn)行了同化處理浮梢,也就是說(shuō)將 list 中所有的 listItem 都是同一個(gè) listItem 的克隆跛十。這樣生成的 list 的骨架屏樣式就更加統(tǒng)一了。
- 還有就是黔寇,正如前文所說(shuō)偶器,骨架屏僅是一種加載狀態(tài),并非真實(shí)頁(yè)面缝裤,因此其并不需要完整的頁(yè)面屏轰,其實(shí)只需要首屏就好了,我們對(duì)非首屏的元素進(jìn)行了刪除憋飞,只保留了首屏內(nèi)部元素霎苗,這樣也大大縮減了生成骨架屏的體積。
- 刪除無(wú)用的 CSS 樣式榛做,只是我們只提取了對(duì)骨架屏有用的 CSS唁盏,然后通過(guò) style 標(biāo)簽引入。
關(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
}
}
可以看出厘擂,我們主要通過(guò) document.querySelector 方法來(lái)判斷該 CSS 是否被使用到,如果該 CSS 選擇器能夠選擇上元素锰瘸,說(shuō)明該 CSS 樣式是有用的刽严,保留。如果沒(méi)有選擇上元素避凝,說(shuō)明該 CSS 樣式?jīng)]有用到舞萄,所以移除眨补。
在后面的一些 slides 中,我們來(lái)聊聊怎講將構(gòu)建骨架屏和 webpack 開(kāi)發(fā)倒脓、打包結(jié)合起來(lái)撑螺,最終將我們的骨架屏打包到實(shí)際項(xiàng)目中。
通過(guò) webpack 將骨架屏打包到項(xiàng)目中
在上一個(gè)部分崎弃,我們分析了怎么去生成骨架屏甘晤,在這一部分,我們將探討如何通過(guò) webpack 將骨架屏打包的項(xiàng)目中吊履。在這過(guò)程中安皱,思考了以下一些問(wèn)題:
為什么在開(kāi)發(fā)過(guò)程中生成骨架屏调鬓?
其主要原因還是為了骨架屏的可編輯艇炎。
在上一個(gè)部分,我們通過(guò)一些樣式和元素的修改生成了骨架屏頁(yè)面腾窝,但是我們并沒(méi)有馬上將其寫入到配置的輸出文件夾中缀踪,在寫入骨架頁(yè)面到項(xiàng)目之前。我們通過(guò) memory-fs 將骨架屏寫入到內(nèi)存中虹脯,以便我們能夠通過(guò)預(yù)覽頁(yè)面進(jìn)行訪問(wèn)驴娃。同時(shí)我們也將骨架屏源碼發(fā)送到了預(yù)覽頁(yè)面,這樣我們就可以通過(guò)修改源碼循集,對(duì)骨架屏進(jìn)行二次編輯唇敞。
正如屏幕上這張圖片,這張圖是插件打開(kāi)的骨架屏的預(yù)覽頁(yè)面咒彤,從左到右依次是開(kāi)發(fā)中的真實(shí)頁(yè)面疆柔、骨架屏、骨架屏可編輯源碼镶柱。
這樣我們就可以在開(kāi)發(fā)過(guò)程中對(duì)骨架屏進(jìn)行編輯旷档,修改部分樣式,中部骨架屏可以進(jìn)行實(shí)時(shí)預(yù)覽歇拆,這之間的通信都是通過(guò)websocket 來(lái)完成的鞋屈。當(dāng)我們對(duì)生成的骨架屏滿意后,并點(diǎn)擊右上角寫入骨架屏按鈕故觅,將骨架屏寫入到項(xiàng)目中厂庇,在最后項(xiàng)目構(gòu)建時(shí),將骨架屏打包到項(xiàng)目中输吏。
如果我們同時(shí)在構(gòu)建的過(guò)程中生成骨架屏权旷,并打包到項(xiàng)目中,這時(shí)的骨架屏我們是無(wú)法預(yù)覽的评也,因此我們對(duì)此時(shí)的骨架屏一無(wú)所知炼杖,也不能夠做任何修改灭返,這就是我們?cè)陂_(kāi)發(fā)中生成骨架屏的原因所在。
演講最開(kāi)始已經(jīng)提到坤邪,目前流行的前端框架基本都是 JS 驅(qū)動(dòng)熙含,也就是說(shuō),在最初的 index.html 中我們不用寫太多的 html 內(nèi)容艇纺,而是等框架啟動(dòng)完成后怎静,通過(guò)運(yùn)行時(shí)將內(nèi)容填充到 html 中,通常我們會(huì)在 html 模板中添加一個(gè)根元素(看屏幕):
<div id="app"></div>
當(dāng)應(yīng)用啟動(dòng)后黔衡,會(huì)將真實(shí)的內(nèi)容填充到上面的元素中蚓聘。這也就給了我們一個(gè)展示骨架屏的機(jī)會(huì),我們將骨架屏在頁(yè)面啟動(dòng)之前添加到上面元素內(nèi)(看屏幕):
<div id="app"><!-- shell.html --></div>
我們?cè)陧?xiàng)目構(gòu)建的過(guò)程中盟劫,將骨架屏 插入到上面代碼注釋的位置夜牡,這樣在應(yīng)用啟動(dòng)前,就是展示的骨架屏侣签,當(dāng)應(yīng)用啟動(dòng)后塘装,通過(guò)真實(shí)數(shù)據(jù)渲染的頁(yè)面替換骨架屏頁(yè)面。
怎樣將骨架屏打包到項(xiàng)目中
Webpack 是一款優(yōu)秀的前端打包工具影所,其也提供了一些豐富的 API 讓我們可以自己編寫一些插件來(lái)讓 webpack 完成更多的工作蹦肴,比如在構(gòu)建過(guò)程中,將骨架屏打包到項(xiàng)目中猴娩。
Webpack 在整個(gè)打包的過(guò)程中提供了眾多生命周期事件阴幌,比如compilation
、after-emit
等卷中,比如我們最終將骨架屏插入到 html 中就是在after-emit
鉤子函數(shù)中進(jìn)行的矛双,簡(jiǎn)單的代碼看下屏幕:
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()
})
// 其他代碼
}
我們?cè)賮?lái)看看 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)部團(tuán)隊(duì)已經(jīng)開(kāi)始使用背零,在使用的過(guò)程中我們也得到了一些反饋信息。
首先是對(duì) SPA 多路由的支持无埃,其實(shí)現(xiàn)在插件已經(jīng)支持多路由了徙瓶,只是還沒(méi)有用到真實(shí)項(xiàng)目中,我們針對(duì)每一個(gè)路由頁(yè)面生成一個(gè)單獨(dú)的 index.html
嫉称,也就是靜態(tài)路由侦镇。然后將每個(gè)路由生成的骨架屏插入到不同的靜態(tài)路由的 html 中。
其次织阅,玩過(guò)服務(wù)端渲染的同學(xué)都知道壳繁,在 React 和 Vue 服務(wù)端渲染中有一種稱為 Client-side Hydration 的技術(shù),指的是在 Vue 在瀏覽器接管由服務(wù)端發(fā)送來(lái)的靜態(tài) HTML,使其變?yōu)橛?Vue 管理的動(dòng)態(tài) DOM 的過(guò)程闹炉。
在我們構(gòu)建骨架屏的過(guò)程中蒿赢,其 DOM 結(jié)構(gòu)和真實(shí)頁(yè)面的 DOM 結(jié)構(gòu)基本相同,只是添加了一些行內(nèi)樣式和 classname渣触,我們也在思考這些 DOM 能夠被復(fù)用羡棵,也就是在應(yīng)用啟動(dòng)時(shí)重新創(chuàng)建所有 DOM。我們只用激活這些骨架屏 DOM嗅钻,讓其能夠相應(yīng)數(shù)據(jù)的變化皂冰,這似乎就可以使骨架屏和真實(shí)頁(yè)面更好的融合。
還有养篓,在頁(yè)面啟動(dòng)后秃流,我們可能還是會(huì)通過(guò) AJAX 獲取后端數(shù)據(jù),這時(shí)候我們也可以通過(guò) 骨架屏 來(lái)作為一種加載狀態(tài)柳弄。也就是說(shuō)舶胀,其實(shí)我們可以在「非首屏骨架屏」上做一些工作。
最后语御,在項(xiàng)目中可能會(huì)有一些性能監(jiān)控的需求峻贮,比如骨架屏什么時(shí)候創(chuàng)建,什么時(shí)候被銷毀应闯,這些我們可能都希望通過(guò)一些性能監(jiān)控的工具記錄下來(lái),以便將來(lái)做一些性能上面的分析挂捻。因此將來(lái)也會(huì)提供一些骨架屏的生命周期函數(shù)碉纺,或者提供相應(yīng)的自定義事件,在生命周期不同階段刻撒,調(diào)用相應(yīng)的生命周期鉤子函數(shù)或監(jiān)聽(tīng)相應(yīng)事件骨田,這樣就可以將骨架屏的一些數(shù)據(jù)記錄到性能監(jiān)控軟件中。