優(yōu)化Web應(yīng)用程序性能方案總結(jié)
在開發(fā) web 應(yīng)用程序時(shí)候,性能都是必不可少的話題。而大部分的前端優(yōu)化機(jī)制都已經(jīng)被集成到前端打包工具 webpack 中去了,當(dāng)然,事實(shí)上仍舊會(huì)有一些有趣的機(jī)制可以幫助 web 應(yīng)用進(jìn)行性能提升,在這里我們來(lái)聊一聊能夠優(yōu)化 web 應(yīng)用程序的一些機(jī)制,同時(shí)也談一談這些機(jī)制背后的原理。
Chrome Corverage 分析代碼覆蓋率
在講解這些機(jī)制前,先來(lái)談一個(gè) Chrome 工具 Corverage。該工具可以幫助查找在當(dāng)前頁(yè)面使用或者未使用的 JavaScript 和 CSS 代碼。
工具的打開流程為:
- 打開瀏覽器控制臺(tái) console
- ctrl+shift+p 打開命令窗口
- 在命令窗口輸入 show Coverage 顯示選項(xiàng)卡
webpackjs
- 其中如果想要查詢頁(yè)面加載時(shí)候使用的代碼,請(qǐng)點(diǎn)擊 reload button
- 如果您想查看與頁(yè)面交互后使用的代碼,請(qǐng)點(diǎn)擊record buton
這里以淘寶網(wǎng)為例子,介紹一下如何使用
上面兩張分別為 reload 與 record 點(diǎn)擊后的分析。
其中從左到右分別為
- 所需要的資源 URL
- 資源中包含的 js 與 css
- 總資源大小
- 當(dāng)前未使用的資源大小
左下角有一份總述。說(shuō)明在當(dāng)前頁(yè)面加載的資源大小以及沒(méi)有使用的百分比。可以看到淘寶網(wǎng)對(duì)于首頁(yè)代碼的未使用率僅僅只有 36%。
介紹該功能的目的并不是要求各位重構(gòu)代碼庫(kù)以便于每個(gè)頁(yè)面僅僅只包含所需的 js 與 css。這個(gè)是難以做到的甚至是不可能的。但是這種指標(biāo)可以提升我們對(duì)當(dāng)前項(xiàng)目的認(rèn)知以便于性能提升。
提升代碼覆蓋率的收益是所有性能優(yōu)化機(jī)制中最高的,這意味著可以加載更少的代碼,執(zhí)行更少的代碼,消耗更少的資源,緩存更少的資源。
webpack externals 獲取外部 CDN 資源
一般來(lái)說(shuō),我們基本上都會(huì)使用 Vue,React 以及相對(duì)應(yīng)的組件庫(kù)來(lái)搭建 SPA 單頁(yè)面項(xiàng)目。但是在構(gòu)建時(shí)候,把這些框架代碼直接打包到項(xiàng)目中,并非是一個(gè)十分明智的選擇。
我們可以直接在項(xiàng)目的 index.html 中添加如下代碼
- <script src="//cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.runtime.min.js" crossorigin="anonymous"></script>
- <script src="//https://cdn.jsdelivr.net/npm/vue-router@3.1.3/dist/vue-router.min.js" crossorigin="anonymous"></script>
然后可以在 webpack.config.js 中這樣配置
- module.exports = {
- //...
- externals: {
- 'vue': 'Vue',
- 'vue-router': 'VueRouter',
- }
- };
webpack externals 的作用是 不會(huì)在構(gòu)建時(shí)將 Vue 打包到最終項(xiàng)目中去,而是在運(yùn)行時(shí)獲取這些外部依賴項(xiàng)。這對(duì)于項(xiàng)目初期沒(méi)有實(shí)力搭建自身而又需要使用 CDN 服務(wù)的團(tuán)隊(duì)有著不錯(cuò)的效果。
原理
這些項(xiàng)目被打包成為第三方庫(kù)的時(shí)候,同時(shí)還會(huì)以全局變量的形式導(dǎo)出。從而可以直接在瀏覽器的 window 對(duì)象上得到與使用。即是
- window.Vue
- // ƒ bn(t){this._init(t)}
這也就是為什么我們直接可以在 html 頁(yè)面中直接使用
- <div id="app">
- {{ message }}
- </div>
- // Vue 就是 掛載到 window 上的,所以可以直接在頁(yè)面使用
- var app = new Vue({
- el: '#app',
- data: {
- message: 'Hello Vue!'
- }
- })
此時(shí)我們可以通過(guò) webpack Authoring Libraries 來(lái)了解如何利用 webpack 開發(fā)第三方包。
優(yōu)勢(shì)與缺陷
優(yōu)勢(shì)
對(duì)于這種既無(wú)法進(jìn)行代碼分割又無(wú)法進(jìn)行 Tree Shaking 的依賴庫(kù)而言,把這些需求的依賴庫(kù)放置到公用 cdn 中,收益是非常大的。
缺陷
對(duì)于類似 Vue React 此類庫(kù)而言,CDN 服務(wù)出現(xiàn)問(wèn)題意味著完全無(wú)法使用項(xiàng)目。需要經(jīng)常瀏覽所使用 CDN 服務(wù)商的公告(不再提供服務(wù)等公告),以及在代碼中添加類似的出錯(cuò)彌補(bǔ)方案。
- <script src="//cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.runtime.min.js" crossorigin="anonymous"></script>
- <script>window.Vue || ...其他處理 </script>
webpack dynamic import 提升代碼覆蓋率
我們可以利用 webpack 動(dòng)態(tài)導(dǎo)入,可以在需要利用代碼時(shí)候調(diào)用 getComponent。在此之前,需要對(duì) webpack 進(jìn)行配置。具體參考 webpack dynamic-imports。
在配置完成之后,我們就可以寫如下代碼。
- async function getComponent() {
- const element = document.createElement('div');
- /** webpackChunkName,相同的名稱會(huì)打包到一個(gè) chunk 中 */
- const { default: _ } = await import(/* webpackChunkName: "lodash" */ 'lodash');
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
- return element;
- }
- getComponent().then(component => {
- document.body.appendChild(component);
- });
優(yōu)勢(shì)與缺陷
優(yōu)勢(shì)
通過(guò)動(dòng)態(tài)導(dǎo)入配置,可以搞定多個(gè) chunk,在需要時(shí)候才會(huì)加載而后執(zhí)行。對(duì)于該用戶不會(huì)使用的資源(路由控制,權(quán)限控制)不會(huì)進(jìn)行加載,從而直接提升了代碼的覆蓋率。
缺陷
Tree Shaking,可以理解為死代碼消除,即不需要的代碼不進(jìn)行構(gòu)建與打包。但當(dāng)我們使用動(dòng)態(tài)導(dǎo)入時(shí)候,無(wú)法使用 Tree Shaking 優(yōu)化,因?yàn)閮烧咧苯影创嬖谥嫒菪詥?wèn)題。因?yàn)?webpack 無(wú)法假設(shè)用戶如何使用動(dòng)態(tài)導(dǎo)入的情況。
- 基礎(chǔ)代碼X
- 模塊A 模塊B
- -----------------------------------
- 業(yè)務(wù)代碼A 業(yè)務(wù)代碼B 業(yè)務(wù)代碼...
當(dāng)在業(yè)務(wù)中使用多個(gè)異步塊時(shí)后,業(yè)務(wù)代碼A 需求 模塊A,業(yè)務(wù)代碼 B 需求 模塊B,但是 webpack 無(wú)法去假設(shè)用戶在代碼中 A 與 B 這兩個(gè)模塊在同一時(shí)間是互斥還是互補(bǔ)。所以必然會(huì)假設(shè)同時(shí)可以加載模塊 A 與 B,此時(shí)基礎(chǔ)代碼 X 出現(xiàn)兩個(gè)導(dǎo)出狀態(tài),這個(gè)是做不到的!從這方面來(lái)說(shuō),動(dòng)態(tài)導(dǎo)入和 Tree Shaking 很難兼容。具體可以參考 Document why tree shaking is not performed on async chunks 。
當(dāng)然,利用動(dòng)態(tài)導(dǎo)入,也會(huì)有一定的性能降低,畢竟一個(gè)是本地函數(shù)調(diào)用,另一個(gè)涉及網(wǎng)絡(luò)請(qǐng)求與編譯。但是與其說(shuō)這是一種缺陷,倒不如說(shuō)是一種決策。究竟是哪一種對(duì)自身的項(xiàng)目幫助更大?
使用 loadjs 來(lái)輔助加載第三方 cdn 資源
在普通的業(yè)務(wù)代碼我們可以使用動(dòng)態(tài)導(dǎo)入,在當(dāng)今的前端項(xiàng)目中,總有一些庫(kù)是我們必需而又使用率很低的庫(kù),比如在只會(huì)在統(tǒng)計(jì)模塊出現(xiàn)的 ECharts 數(shù)據(jù)圖表庫(kù),或者只會(huì)在文檔或者網(wǎng)頁(yè)編輯時(shí)候出現(xiàn)的富文本編輯器庫(kù)。
對(duì)于這些苦庫(kù)其實(shí)我們可以使用頁(yè)面或組件掛載時(shí)候 loadjs 加載。因?yàn)槭褂脛?dòng)態(tài)導(dǎo)入這些第三方庫(kù)沒(méi)有 Tree shaking 增強(qiáng),所以其實(shí)效果差不多,但是 loadjs 可以去取公用 CDN 資源。具體可以參考 github loadjs 來(lái)進(jìn)行使用。因?yàn)樵搸?kù)較為簡(jiǎn)單,這里暫時(shí)就不進(jìn)行深入探討。
使用 output.publicPath 托管代碼
因?yàn)闊o(wú)論是使用 webpack externals 或者 loadjs 來(lái)使用公用 cdn 都是一種折衷方案。如果公司可以花錢購(gòu)買 oss + cdn 服務(wù)的話,就可以直接將打包的資源托管上去。
- module.exports = {
- //...
- output: {
- // 每個(gè)塊的前綴
- publicPath: 'https://xx/',
- chunkFilename: '[id].chunk.js'
- }
- };
- // 此時(shí)打包出來(lái)的數(shù)據(jù)前綴會(huì)變?yōu)?nbsp;
- <script src=https://xx/js/app.a74ade86.js></script>
此時(shí)業(yè)務(wù)服務(wù)器僅僅只需要加載 index.html。
利用 prefetch 在空缺時(shí)間加載資源
如果不需要在瀏覽器的首屏中使用腳本。可以利用瀏覽器新增的 prefetch 延時(shí)獲取腳本。
下面這段代碼告訴瀏覽器,echarts 將會(huì)在未來(lái)某個(gè)導(dǎo)航或者功能中要使用到,但是資源的下載順序權(quán)重比較低。也就是說(shuō)prefetch通常用于加速下一次導(dǎo)航。被標(biāo)記為 prefetch 的資源,將會(huì)被瀏覽器在空閑時(shí)間加載。
- <link rel="prefetch" href="https://cdn.jsdelivr.net/npm/echarts@4.3.0/dist/echarts.min.js"></link>
該功能也適用于 html 以及 css 資源的預(yù)請(qǐng)求。
利用 instant.page 來(lái)提前加載資源
instant.page 是一個(gè)較新的功能庫(kù),該庫(kù)小而美。并且無(wú)侵入式。 只要在項(xiàng)目的 </body> 之前加入以下代碼,便會(huì)得到收益。
- <script src="//instant.page/2.0.1" type="module" defer integrity="sha384-4Duao6N1ACKAViTLji8I/8e8H5Po/i/04h4rS5f9fQD6bXBBZhqv5am3/Bf/xalr"></script>
該方案不適合單頁(yè)面應(yīng)用,但是該庫(kù)很棒的運(yùn)用了 prefetch,是在你懸停于鏈接超過(guò)65ms 時(shí)候,把已經(jīng)放入的 head 最后的 link 改為懸停鏈接的 href。
下面代碼是主要代碼
- // 加載 prefetcher
- const prefetcher = document.createElement('link')
- // 查看是否支持 prefetcher
- const isSupported = prefetcher.relList && prefetcher.relList.supports && prefetcher.relList.supports('prefetch')
- // 懸停時(shí)間 65 ms
- let delayOnHover = 65
- // 讀取設(shè)定在 腳本上的 instantIntensity, 如果有 修改懸停時(shí)間
- const milliseconds = parseInt(document.body.dataset.instantIntensity)
- if (!isNaN(milliseconds)) {
- delayOnHover = milliseconds
- }
- // 支持 prefetch 且 沒(méi)有開啟數(shù)據(jù)保護(hù)模式
- if (isSupported && !isDataSaverEnabled) {
- prefetcher.rel = 'prefetch'
- document.head.appendChild(prefetcher)
- ...
- // 鼠標(biāo)懸停超過(guò) instantIntensit ms || 65ms 改變 href 以便預(yù)先獲取 html
- mouseoverTimer = setTimeout(() => {
- preload(linkElement.href)
- mouseoverTimer = undefined
- }, delayOnHover)
- ...
- function preload(url) {
- prefetcher.href = url
- }
延時(shí) prefetch ? 還是在鼠標(biāo)停留的時(shí)候去加載。不得不說(shuō),該庫(kù)利用了很多瀏覽器新的的機(jī)制。包括使用 type=module 來(lái)拒絕舊的瀏覽器執(zhí)行,利用 dataset 讀取 instantIntensity 來(lái)控制延遲時(shí)間。
optimize-js 跳過(guò) v8 pre-Parse 優(yōu)化代碼性能
認(rèn)識(shí)到這個(gè)庫(kù)是在 v8 關(guān)于新版本的文章中,在 github 中被標(biāo)記為 UNMAINTAINED 不再維護(hù),但是了解與學(xué)習(xí)該庫(kù)仍舊有其的價(jià)值與意義。該庫(kù)的用法十分簡(jiǎn)單粗暴。居然只是把函數(shù)改為 IIFE(立即執(zhí)行函數(shù)表達(dá)式)。 用法如下:
- optimize-js input.js > output.js
Example input:
- !function (){}()
- function runIt(fun){ fun() }
- runIt(function (){})
Example output:
- !(function (){})()
- function runIt(fun){ fun() }
- runIt((function (){}))
原理
在 v8 引擎內(nèi)部(不僅僅是 V8,在這里以 v8 為例子),位于各個(gè)編譯器的前置Parse 被分為 Pre-Parse 與 Full-Parse,Pre-Parse 會(huì)對(duì)整個(gè) Js 代碼進(jìn)行檢查,通過(guò)檢查可以直接判定存在語(yǔ)法錯(cuò)誤,直接中斷后續(xù)的解析,在此階段,Parse 不會(huì)生成源代碼的AST結(jié)構(gòu)。
- // This is the top-level scope.
- function outer() {
- // preparsed 這里會(huì)預(yù)分析
- function inner() {
- // preparsed 這里會(huì)預(yù)分析 但是不會(huì) 全分析和編譯
- }
- }
- outer(); // Fully parses and compiles `outer`, but not `inner`.
但是如果使用 IIFE,v8 引擎直接不會(huì)進(jìn)行 Pre-Parsing 操作,而是立即完全解析并編譯函數(shù)。可以參考Blazingly fast parsing, part 2: lazy parsing
優(yōu)勢(shì)與缺陷
優(yōu)勢(shì)
快!即使在較新的 v8 引擎上,我們可以看到 optimize-js 的速度依然是最快的。更不用說(shuō)在國(guó)內(nèi)瀏覽器的版本遠(yuǎn)遠(yuǎn)小于 v8 當(dāng)前版本。與后端 node 不同,前端的頁(yè)面生命周期很短,越快執(zhí)行越好。
缺陷
但是同樣的,任何技術(shù)都不是銀彈,直接完全解析和編譯也會(huì)造成內(nèi)存壓力,并且該庫(kù)也不是 js 引擎推薦的用法。相信在不遠(yuǎn)的未來(lái),該庫(kù)的收益也會(huì)逐漸變小,但是對(duì)于某些特殊需求,該庫(kù)的確會(huì)又一定的助力。
再聊代碼覆蓋率
此時(shí)我們?cè)谡勔淮未a覆蓋率。如果我們可以在首屏記載的時(shí)候可以達(dá)到很高的代碼覆蓋率。直接執(zhí)行便是更好的方式。在項(xiàng)目中代碼覆蓋率越高,越過(guò) Pre-Parsing 讓代碼盡快執(zhí)行的收益也就越大。
Polyfill.io 根據(jù)不同的瀏覽器確立不同的 polyfill
如果寫過(guò)前端,就不可能不知道 polyfill。各個(gè)瀏覽器版本不同,所需要的 polyfill 也不同,
Polyfill.io是一項(xiàng)服務(wù),可通過(guò)選擇性地填充瀏覽器所需的內(nèi)容來(lái)減少 Web 開發(fā)的煩惱。Polyfill.io讀取每個(gè)請(qǐng)求的User-Agent 標(biāo)頭,并返回適合于請(qǐng)求瀏覽器的polyfill。
如果是最新的瀏覽器且具有 Array.prototype.filter
- https://polyfill.io/v3/polyfill.min.js?features=Array.prototype.filter
- /* Disable minification (remove `.min` from URL path) for more info */
如果沒(méi)有 就會(huì)在 正文下面添加有關(guān)的 polyfill。
國(guó)內(nèi)的阿里巴巴也搭建了一個(gè)服務(wù),可以考慮使用,網(wǎng)址為 https://polyfill.alicdn.com/polyfill.min.js
type='module' 輔助打包與部署 es2015+ 代碼
使用新的 DOM API,可以有條件地加載polyfill,因?yàn)榭梢栽谶\(yùn)行時(shí)檢測(cè)。但是,使用新的 JavaScript 語(yǔ)法,這會(huì)非常棘手,因?yàn)槿魏挝粗恼Z(yǔ)法都會(huì)導(dǎo)致解析錯(cuò)誤,然后所有代碼都不會(huì)運(yùn)行。
該問(wèn)題的解決方法是
- <script type="module">。
早在 2017 年,我便知道 type=module 可以直接在瀏覽器原生支持模塊的功能。具體可以參考 JavaScript modules 模塊。但是當(dāng)時(shí)感覺(jué)只是這個(gè)功能很強(qiáng)大,并沒(méi)有對(duì)這個(gè)功能產(chǎn)生什么解讀。但是卻沒(méi)有想到可以利用該功能識(shí)別你的瀏覽器是否支持 ES2015。
每個(gè)支持 type="module" 的瀏覽器都支持你所熟知的大部分 ES2015+ 語(yǔ)法!!!!!
例如
- async await 函數(shù)原生支持
- 箭頭函數(shù) 原生支持
- Promises Map Set 等語(yǔ)法原生支持
因此,利用該特性,完全可以去做優(yōu)雅降級(jí)。在支持 type=module 提供所屬的 js,而在 不支持的情況下 提供另一個(gè)js。具體可以參考 Phillip Walton 精彩的博文,這里也有翻譯版本 https://jdc.jd.com/archives/4911.
Vue CLI 現(xiàn)代模式
如果當(dāng)前項(xiàng)目已經(jīng)開始從 webpack 陣營(yíng)轉(zhuǎn)到 Vue CLI 陣營(yíng)的話,那么恭喜你,上述解決方案已經(jīng)被內(nèi)置到 Vue CLI 當(dāng)中去了。只需要使用如下指令,項(xiàng)目便會(huì)產(chǎn)生兩個(gè)版本的包。
- vue-cli-service build --modern
具體可以參考 Vue CLI 現(xiàn)代模式
優(yōu)勢(shì)與缺陷
優(yōu)勢(shì)
提升代碼覆蓋率,直接使用原生的 await 等語(yǔ)法,直接減少大量代碼。
提升代碼性能。之前 v8 用的時(shí) Crankshaft 編譯器,隨著時(shí)間的推移,該編譯器因?yàn)闊o(wú)法優(yōu)化現(xiàn)代語(yǔ)言特性而被拋棄,之后 v8 引入了新的 Turbofan 編譯器來(lái)對(duì)新語(yǔ)言特性進(jìn)行支持與優(yōu)化,之前在社區(qū)中談?wù)摰?try catch, await,JSON 正則等性能都有了很大的提升。具體可以時(shí)常瀏覽 v8 blog 來(lái)查看功能優(yōu)化。
- Writing ES2015 code is a win for developers, and deploying ES2015 code is a win for users.
缺陷
無(wú),實(shí)在考慮不出有什么不好。
鼓勵(lì)一下
如果你覺(jué)得這篇文章不錯(cuò),希望可以給與我一些鼓勵(lì),在我的 github 博客下幫忙 star 一下。
























