“非主流”的純前端性能優(yōu)化
性能優(yōu)化一直是前端研究的主要課題之一,因為不僅直接影響用戶體驗,對于商業(yè)性公司,網(wǎng)頁性能的優(yōu)劣更關(guān)乎流量變現(xiàn)效率的高低。例如 DoubleClick by Google 發(fā)現(xiàn):
如果頁面加載時間超過 3 秒,53% 的用戶會選擇終止當(dāng)前操作并離開
網(wǎng)站加載時間在 5 秒內(nèi)的發(fā)布商比 19 秒內(nèi)的廣告收入至少多出一倍
同時,性能優(yōu)化學(xué)習(xí)的不斷深入,也同樣是一個專業(yè)前端工程師的進階之路。不過,隨著 HTTP/2 和 SSR(服務(wù)端渲染)的不斷普及,早期雅虎 35 條中的很多內(nèi)容似乎已經(jīng)顯得有些過時,不少純前端的細(xì)節(jié)優(yōu)化方案也逐漸被認(rèn)為微不足道。
但是,今天,我們依然想談幾個容易被很多前端工程師忽視,但卻卓有成效的純前端優(yōu)化細(xì)節(jié)(技術(shù)框架以 Vue 為主)。
一、self
這里想說的 self 并不是??
WindowOrWorkerGlobalScope 下的 self,或者說 window 的替身,而是? const self = this? 中的 self,或者說對象緩存。
在幾乎所有數(shù)據(jù)類型皆對象的 JavaScript 中,能有效降低屬性訪問深度的對象緩存是前端優(yōu)化最基礎(chǔ)的課程,即使在瀏覽器已經(jīng)進化到即使沒有明確地聲明緩存對象,內(nèi)核解析時也會自動緩存以增加解析效率的今天。
良好的對象緩存不僅僅只是為了避免寫出下面的代碼:
const obj = {human: {man: {}}}obj.human.man.age = 18obj.human.man.name = 'Chen'obj.human.man.career = 'programmer'
(滑動可查看)
還有一個更加重要的原因:有效減少工程上線時壓縮后的代碼量!
首先,看一下上面代碼壓縮后的結(jié)果:
var ho={human:{man:{}}};ho.human.man.age=18,ho.human.man.name="Chen",ho.human.man.career="programmer";(滑動可查看)
然后,對屬性對象 man 做一次變量緩存:
const obj = {human: {man: {}}}const man = obj.human.manman.age = 18man.name = 'Chen'man.career = 'programmer'
(滑動可查看)
再次壓縮代碼后的結(jié)果:
var ho={human:{man:{}}},yo=ho.human.man;yo.age=18,yo.name="Chen",yo.career="programmer";(滑動可查看)
可以看到,對象緩存使得代碼容量有了明顯的減少。
那么,對于實際的項目,變量緩存對總體代碼又會帶來多大容量的縮減呢?回到小節(jié)討論的開始,我們一起感受一下不緩存的 this 對象帶來的直觀震撼吧。
vivo 某個項目的一個 js 文件:

整個文件存在 3836 個 this,保存到本地大概 375 KB。如果緩存 this,代碼壓縮時 4 個字符的 this 會被壓縮成單字符變量。

整個文件的存儲大小降低到 364 KB,一個 this 對象緩存即可讓壓縮后的代碼容量下降超過 10 KB,注意,僅僅只是一個 this 對象!
二、Object.freeze()
我們知道,在 Vue 組件或者 Vuex 的 state 中定義的數(shù)據(jù)是響應(yīng)式的,當(dāng)這些數(shù)據(jù)發(fā)生改變時,會通知 View 層更新界面。
首先,簡單回憶一下 Vue 響應(yīng)式數(shù)據(jù)的原理,如下圖。

其中:
每一個組件 component 都擁有一個自己的觀察者 watcher,內(nèi)部封裝了 Vue.prototype._render() 函數(shù)
每一個響應(yīng)式數(shù)據(jù)屬性都擁有一個自己的依賴 dep 收集器,用以收集依賴該數(shù)據(jù)的組件的 watcher
響應(yīng)式數(shù)據(jù)的三個基本步驟:
(1)組件數(shù)據(jù)的響應(yīng)化流程:component(options) -> observe(data) -> Reactive Data
component 的數(shù)據(jù)部分,所有的 options.data 屬性通過 observe() 中的 Object.defineProperty() 函數(shù)轉(zhuǎn)換成訪問器屬性
在每一個數(shù)據(jù)屬性被
Object.defineProperty() 轉(zhuǎn)換時的函數(shù)閉包空間中,存在一個自己的 dep 收集器
(2)響應(yīng)式數(shù)據(jù)的依賴收集流程:component(template) -> watcher(vm._render())(get) -> Reactive Data
component 的模板字符串,通過 Vue compiler 后生成渲染函數(shù) vm._render()
每一個 component 擁有一個自己的觀察者 watcher,watcher 中封裝了
vm._render(),組件初次渲染時:
(a)watcher 實例暫存在 Dep.target 屬性上
(b)watcher 執(zhí)行 vm._render() 函數(shù),并進一步觸發(fā) vm._render() 所依賴數(shù)據(jù)屬性的 getter
(c)
watcher 實例被收集到其所有依賴數(shù)據(jù)屬性的 dep 收集器中
(3)響應(yīng)式數(shù)據(jù)改變時的重新渲染流程:Reactive Data(set) -> dep 收集器 -> watcher(vm._render()) -> 異步隊列
當(dāng)響應(yīng)式數(shù)據(jù)被修改時,觸發(fā)數(shù)據(jù)屬性的 setter 函數(shù)
數(shù)據(jù)屬性的 setter 函數(shù)會促使 dep 收集器將其收集的所有 watcher 實例推入異步隊列 queueWatcher
異步隊列會被整體放入 nextTick() 中,即在下一個 tick 時被一次性全部執(zhí)行;其實在 watcher() 中,渲染函數(shù) vm._render() 是被封裝到 vm._update() 中的,它在執(zhí)行時,會首先通過 vnode 的 diff 算法比對找到修改的最少步驟,然后將最小的差異化渲染到頁面
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {...// 如果沒有舊的虛擬節(jié)點 prevVnode,表示是初次渲染,直接渲染到頁面if (!prevVnode) {// initial rendervm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */,vm.$options._parentElm,vm.$options._refElm)// 非初次渲染,數(shù)據(jù)修改導(dǎo)致需要更新頁面時,進行 vnode diff 后將最小的差異化渲染到頁面} else {// updatesvm.$el = vm.__patch__(prevVnode, vnode)}...}
(滑動可查看)
每一個響應(yīng)式數(shù)據(jù)對象屬性都一定會經(jīng)歷三個基本步驟中的 1 和 2,不過,很多屬性在應(yīng)用的整個生命周期中可能都不會經(jīng)歷步驟 3,因為它們始終沒有改變。
但是,需要注意的是:之所以 Vue 會進行步驟 1 和 2 的操作,其實主要就是為了步驟 3 做準(zhǔn)備,如果步驟 3 得不到執(zhí)行,那么前兩步的操作就是無意義的,或者說浪費。是否有方式避免這種浪費呢?有,就是 Object.freeze()。
在將普通數(shù)據(jù)轉(zhuǎn)變成響應(yīng)式數(shù)據(jù)的核心函數(shù) defineReactive(Vue 2.6.x src/core/observer
/index.js) 中,有一個判斷,如果屬性本身不是 configurable 的,則不會被轉(zhuǎn)化成響應(yīng)式數(shù)據(jù),即不會執(zhí)行上面的流程 1,與此同時,非響應(yīng)式的數(shù)據(jù)也自然不會執(zhí)行流程 2。
/*** Define a reactive property on an Object.*/export function defineReactive (obj: Object,key: string,val: any,customSetter?: ?Function,shallow?: boolean) {const dep = new Dep()const property = Object.getOwnPropertyDescriptor(obj, key)if (property && property.configurable === false) {return}...}
(滑動可查看)
對于整個應(yīng)用生命周期中,不會改變的數(shù)據(jù),可以使用 Object.freeze() 將其 configurable 屬性置為 false;或者,將整個數(shù)據(jù)對象都 freeze 掉:
/*** 深度凍結(jié)對象*/function deepFreeze(obj) {Object.keys(obj).forEach(key => {const prop = obj[key]typeof prop === 'object' && prop !== null && deepFreeze(prop)})return Object.freeze(obj)}
(滑動可查看)
然后,“解凍”部分需要改變的數(shù)據(jù),并將其轉(zhuǎn)換成響應(yīng)式數(shù)據(jù)。
注意,如果解凍的屬性值是對象,不能通過簡單地賦值“解凍”該對象,因為對象的引用傳遞特性導(dǎo)致其 configurable 依然是 false。可以使用下面的簡單深復(fù)制方法,讓源對象丟失 configurable 屬性:
/*** 簡單對象深復(fù)制* -- 子對象引用關(guān)系丟失* -- 不適合循環(huán)引用數(shù)據(jù)*/function deepClone(obj) {return JSON.parse(JSON.stringify(obj))}
(滑動可查看)
對于 Object.freeze() 帶來的性能提升,Vue 官方的一個 big table benchmark 里,做了一個 1000 x 10 的表格渲染對照實驗,使用 Object.freeze() 的渲染速度比不使用時快了 4 倍。
三、Pre 機制
瀏覽器的 pre(預(yù))機制。
由于可動態(tài)修改 DOM 的天然屬性,JavaScript 不僅本身的執(zhí)行是單線程的,而且其加載/解析執(zhí)行時 HTML 的解析也是停止的,甚至在早期的瀏覽器中,其它資源的加載線程也會被同時阻止。
例如,在 IE7 中,頁面的瀑布流:

其他資源的加載、解析、執(zhí)行不能和 JavaScript 的加載執(zhí)行并行,這導(dǎo)致了頁面的加載時間很長。為了提高網(wǎng)絡(luò)利用率,后來的主流瀏覽器都實現(xiàn)了預(yù)加載機制,即解析 HTML 頁面的同時,啟動一個輕量級解析器優(yōu)先掃描 HTML 中的所有標(biāo)記,尋找樣式表、腳本、圖像等靜態(tài)資源,盡可能地并行加載它們。
IE8 中的頁面瀑布流:

可以很明顯地看到,靜態(tài)資源被盡可能的并行加載了,即使在腳本加載解析的時候。
不過,隨著 Web 應(yīng)用的越加復(fù)雜化,CSS 和 JavaScript 資源容量也越來越大,很多資源并不是一開始就出現(xiàn)在 HTML 中,而是后期被 CSS 和 JavaScript 動態(tài)引入的。為了盡可能提前解析/加載這些資源,瀏覽器開始提供豐富的 pre 機制。
1、Preload
瀏覽器內(nèi)核的預(yù)加載機制只適用于在 HTML 中顯式聲明的資源,對于 CSS 和 JavaScript 中定義的資源可能并不起作用。preload 很好地克服了這個問題,可以通過 preload 標(biāo)識需要瀏覽器提前加載的重要資源,例如樣式表、腳本、圖片、字體甚至文檔。
"preload" as="style" href="/assets/css/app.css">"preload" as="script" href="/assets/js/app.js">"preload" as="image" href="/assets/images/man.png">"preload" as="font" href="/assets/font/rom9.ttf">
(滑動可查看)
2、Prefetch
Prefetch 是一個低優(yōu)先級的資源提示,允許瀏覽器在后臺(空閑時)獲取將來可能用得到的資源,并且將他們存儲在瀏覽器的緩存中。有三種不同的 prefetch 類型:
(1)Link Prefetching:允許瀏覽器獲取資源并將他們存儲在緩存中。
HTML
<link rel="prefetch" href="/uploads/images/pic.png">
(滑動可查看)
HTTP Header
Link: uploads/images/pic.png>; rel=prefetch
(滑動可查看)
(2)DNS Prefetching:允許瀏覽器在用戶瀏覽頁面時在后臺運行 DNS 解析。
可以在一個 link 標(biāo)簽的屬性中添加 rel="dns-prefetch' 來對指定進行 DNS prefetching 的 URL:
<link rel="dns-prefetch" href="http://sthf.vivo.com.cn"><link rel="dns-prefetch" href="http://apph5wsdl.vivo.com.cn"><link rel="dns-prefetch" href="http://cfg-stsdk.vivo.com.cn"><link rel="dns-prefetch" href="http://trace-h5sdk.vivo.com.cn"><link rel="dns-prefetch" href="http://topicstatic.vivo.com.cn">
(滑動可查看)
DNS 請求在帶寬方面流量非常小,可是延遲會很高,尤其是在移動設(shè)備上。
(3)Prerendering:和 prefetching 非常相似,優(yōu)化可能資源的加載,區(qū)別是 prerendering 在后臺渲染整個未來可能加載的頁面。
<link rel="prerender" href="https://www.vivo.com.cn">(滑動可查看)
這三種類型中,Link Prefetching 和前文的 preload 比較相似,但是優(yōu)先級較低,而且更加專注于下一個頁面;Prerendering 會預(yù)渲染一個用戶不一定訪問的完整頁面,這會導(dǎo)致較高的帶寬浪費和資源占用,應(yīng)用的機會可能并不多;而 DNS Prefetching 是當(dāng)前我們應(yīng)用最多的。
在瀏覽一個網(wǎng)頁時,DNS 解析總是發(fā)生在一個新域名初次被解析的時候,如果域名解析是獨立串行的(如頁面主域的解析),解析時間的長短(如下圖中的 vivo 游戲大會員 supermember.vivo.com.cn)將直接影響頁面的打開速度。得益于現(xiàn)代瀏覽器的預(yù)加載機制,除頁面主域以外的其他資源域名的解析時間,一定程度上很好地掩蔽在了資源的并行加載過程中。

但是,dns 的解析并不一定是穩(wěn)定可靠的,時間跨度從幾十 ms 至過千 ms 都有可能,如果頁面主要資源的 dns 解析時間過長,就會直接影響用戶的使用體驗,所以,恰當(dāng)?shù)?DNS Prefetching 依然很有必要。

3、Preconnect
相比于 DNS Prefetching,Preconnect 除了提前完成域名的 DNS 解析,還更近一步地完成 http 連接通道的建立,這包括 TCP 握手,TLS 協(xié)商等。

使用方法:
<link rel="preconnect" href="http://sthf.vivo.com.cn"><link rel="preconnect" href="http://apph5wsdl.vivo.com.cn"><link rel="preconnect" href="http://cfg-stsdk.vivo.com.cn"><link rel="preconnect" href="http://trace-h5sdk.vivo.com.cn"><link rel="preconnect" href="http://topicstatic.vivo.com.cn">
(滑動可查看)
可以同時設(shè)置 Preconnect 和 DNS Prefetching,讓瀏覽器優(yōu)先進行 Preconnect,在不支持的前提下,優(yōu)雅回退至 DNS Prefetching。
四、并行加載
隨著 Web 應(yīng)用的復(fù)雜化大型化,使用 MV* 類框架( Vue、React、Angular 等)進行快捷開發(fā)已經(jīng)成為前端開發(fā)的主流模式。但是,這些框架都存在基礎(chǔ)框架包較大,解析時間較長的問題。
首先,我們看一個標(biāo)準(zhǔn)的 Vue 項目 - vivo 游戲大會員 Chrome 開發(fā)者工具中的瀑布流:

可以看出資源的加載存在明顯的層級結(jié)構(gòu):
第1級:獲取頁面 HTML 文檔并解析
第2級:獲取頁面 CSS 和 JavaScript 文件并解析
第3級:請求接口獲取服務(wù)端數(shù)據(jù)
第4級:頁面渲染加載主頁圖片等資源
同時,可以發(fā)現(xiàn)由于 JavaScript 文件較大,解析時間較長,第 2 級與第 3 級,以及第 3 級和第 4 級之間的時間間隔較大。如果這種串行的逐級解析加載模式能夠改變?yōu)椴⑿械募虞d模式,勢必將顯著降低頁面的加載時長。
注意,如果項目未開啟 HTTP/2,可能需要增加資源域名以突破瀏覽器對單個域名并行下載數(shù)量的限制。當(dāng)然,在下面實現(xiàn)并行加載的過程中,我們也使用了很明顯的反模式 - 通過 window 全局變量傳遞數(shù)據(jù)。不過,在沒有更好的實現(xiàn)方案前,通過有限可控的反模式實現(xiàn)更好的頁面體驗還是值得的。
下面,我們討論如何將串行加載的資源變成并行加載。
1、接口
大多數(shù)時候,接口的請求并不需要等待 Vue.js 加載解析完成,完全可以在 HTML 中通過幾行簡單的 JavaScript 代碼提前進行 Ajax 請求。
/*** 主接口請求前置*/var win = windowvar xhr = new XMLHttpRequest()xhr.open('get', '/api/member/masterpage?t=' + Date.now(), true)xhr.onerror = function () { win._mainPageData = { msg: '請求出錯', code: 10000 } }xhr.timeout = 10000xhr.ontimeout = function () { win._mainPageData = { msg: '請求超時', code: 10001 } }xhr.onreadystatechange = function () {try {var status = xhr.statusif (xhr.readyState == 4) {win._mainPageData = (status >= 200 && status < 300) || status == 304? JSON.parse(xhr.responseText): {msg: '',code: 10002}}} catch (e) { /* 請求超時時readyState可能也是4,但是訪問status可能出錯 */ }}xhr.send(null)
(滑動可查看)
需要注意的是,直接插入到 HTML 中的 JavaScript 可能不會通過 babel 的編譯,所以不要使用 ES6 語法,因為很可能一個簡單的 const 就會讓 Android 5/4.4.4 直接白屏。
2、圖片
通常,Web 應(yīng)用主頁首屏?xí)袔讖堁b飾性且容量較大的圖片,將圖片寫在 Vue 組件中,圖片的加載會推遲到組件解析完成,我們同樣可以在 HTML 中提前加載這些圖片。
一種方式是使用前文 Pre 機制中提到的 Preload:
"preload" as="image" href="/assets/images/00.png">"preload" as="image" href="/assets/images/01.png">"preload" as="image" href="/assets/images/02.png">
(滑動可查看)
盡管 Preload 擁有更簡潔且不阻塞頁面渲染的優(yōu)點,但是這種方式當(dāng)前依然存在兩個明顯的問題:
(1)低版本 Android 不支持 Preload
(2)如果項目需要判斷環(huán)境是否支持 webp 格式,以便有區(qū)分地加載圖片的 webp 格式和普通格式,Preload 就不好辦了,除非你兩種格式都加載,但很明顯這樣會造成嚴(yán)重的流量浪費。
所以,我們可以使用 JavaScript 代碼在判斷環(huán)境是否支持 webp 格式后,加載需要格式的圖片:
/*** webp 探測*/var win = windowvar doc = documentwin._supportsWebP = (function () {var mime = 'image/webp'var canvas = typeof doc === 'object' ? doc.createElement('canvas') : {}canvas.width = canvas.height = 1return canvas.toDataURL ? canvas.toDataURL(mime).indexOf(mime) === 5 : false}())/*** 圖片預(yù)加載*/var body = doc.bodyvar parentNode = document.createDocumentFragment()var imgPostfix = '.png' + (win._supportsWebP ? '.webp' : '')var linkPrefix = '//topicstatic.vivo.com.cn/f5ZUD0HxhQMn3J32/wukong/img/'var imgPreLoad = win._imgPreLoad = [linkPrefix + '5f88483c-4d76-42d4-912d-35c8c92be8e6' + imgPostfix,linkPrefix + '5ee4c220-cd98-4d8c-9cdc-5fca3e103227' + imgPostfix,linkPrefix + '131008e1-9230-480c-934a-30f9f83e17ae' + imgPostfix,linkPrefix + 'cee41d4d-853d-4677-9a20-b9b5e1c4ffbenwebp' + imgPostfix,linkPrefix + 'ddf2cad0-d334-437a-8923-7b36a65544d1nwebp' + imgPostfix]imgPreLoad.forEach(function (link) {var img = doc.createElement('img')img.src = linkimg.style.left = '-9999px'img.style.position = 'absolute'parentNode.appendChild(img)})body.insertBefore(parentNode, body.firstChild)
(滑動可查看)
此外,在合適的時候,可以嘗試使用 svg 圖片,除了永不失真的圖片質(zhì)量,更重要的是,svg 可以很好地打包到代碼中,并始終保持比 base64 更好的可讀性。
3、字體
有的時候,為了實現(xiàn)更好的視覺效果,并能應(yīng)對動態(tài)變化的接口數(shù)據(jù),我們會引入一些系統(tǒng)不支持的字體,比如數(shù)字字體 Rom9。
不過,我們可能只是用到字體中的某一部分,比如數(shù)字,此時除了使用字體編輯軟件刪除不需要的字符外,我們還可以將字體 base64 化后整合到 CSS 中以便更好地并行加載:
@font-face{src: url(data:font/truetype;charset=utf-8;base64,AA...省略...AK) format("truetype");font-style: normal;font-weight: normal;font-family: "Rom9";font-display: swap;}
(滑動可查看)
五、應(yīng)用
我們將上面有關(guān)的討論應(yīng)用到實際的項目 vivo 游戲大會員中。
首先,看一下并行加載優(yōu)化后的資源瀑布流,原本處于第 2、第 3 和第 4 級的資源并行加載了。

通過視頻可以更直觀地感受優(yōu)化帶來的改善:
優(yōu)化前:

優(yōu)化后:

可以看到,頁面的打開速度不僅更快,而且并行加載使得圖片的呈現(xiàn)也不再帶有“節(jié)奏”了。
END
猜你喜歡
推薦閱讀
我的公眾號能帶來什么價值?(文末有送書規(guī)則,一定要看)
每個前端工程師都應(yīng)該了解的圖片知識(長文建議收藏)
為什么現(xiàn)在面試總是面試造火箭?
點一下,代碼無 Bug

