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