
隨著 Web 的發(fā)展,JavaScript 從以前只承擔(dān)簡(jiǎn)單的腳本功能,到現(xiàn)在被用于構(gòu)建大型、復(fù)雜的前端應(yīng)用,經(jīng)歷了很大的發(fā)展。這也讓它在當(dāng)下的前端應(yīng)用中扮演了一個(gè)非常重要的角色,因此在這一節(jié)首先來(lái)看看的我們熟悉的 JavaScript。
1. 減少不必要的請(qǐng)求
在進(jìn)行 JavaScript 優(yōu)化時(shí),我們還是秉承總體思路,首先就是減少不必要的請(qǐng)求。
1.1. 代碼拆分(code split)與按需加載
相信熟練使用 webpack 的同學(xué)對(duì)這一特性都不陌生。
雖然整體應(yīng)用的代碼非常多,但是很多時(shí)候,我們?cè)谠L(fǎng)問(wèn)一個(gè)頁(yè)面時(shí),并不需要把其他頁(yè)面的組件也全部加載過(guò)來(lái),完全可以等到訪(fǎng)問(wèn)其他頁(yè)面時(shí),再按需去動(dòng)態(tài)加載。核心思路如下所示:
document.getElementById('btn').addEventListener('click', e => {
// 在這里加載 chat 組件相關(guān)資源 chat.js
const script = document.createElement('script');
script.src = '/static/js/chat.js';
document.getElementsByTagName('head')[0].appendChild(script);
});
在按鈕點(diǎn)擊的監(jiān)聽(tīng)函數(shù)中,我動(dòng)態(tài)添加了 <script> 元素。這樣就可以實(shí)現(xiàn)在點(diǎn)擊按鈕時(shí),才加載對(duì)應(yīng)的 JavaScript 腳本。
代碼拆分一般會(huì)配合構(gòu)建工具一起使用。以 webpack 為例,在日常使用時(shí),最常見(jiàn)的方式就是通過(guò) dynamic import[1] 來(lái)告訴 webpack 去做代碼拆分。webpack 編譯時(shí)會(huì)進(jìn)行語(yǔ)法分析,之后遇到 dynamic import 就會(huì)認(rèn)為這個(gè)模塊是需要?jiǎng)討B(tài)加載的。相應(yīng)的,其子資源也會(huì)被如此處理(除非被其他非動(dòng)態(tài)模塊也引用了)。
在 webpack 中使用代碼拆分最常見(jiàn)的一個(gè)場(chǎng)景是基于路由的代碼拆分。目前很多前端應(yīng)用都在使用 SPA(單頁(yè)面應(yīng)用)形式,或者 SPA 與 MPA(多頁(yè)面應(yīng)用)的結(jié)合體,這就會(huì)涉及到前端路由。而頁(yè)面間的業(yè)務(wù)差異也讓基于路由的代碼拆分成為一個(gè)最佳實(shí)踐。想了解如何在 react-router v4 中實(shí)現(xiàn)路由級(jí)別的代碼拆分,可以看這篇文章[2]。
當(dāng)然,如果你不使用 webpack 之類(lèi)的構(gòu)建工具,你也可以選擇一個(gè) AMD 模塊加載器(例如 RequireJS)來(lái)實(shí)現(xiàn)前端運(yùn)行時(shí)上的異步依賴(lài)加載。
1.2. 代碼合并
我們?cè)诳傮w思路里有提到,減少請(qǐng)求的一個(gè)方法就是合并資源。試想一個(gè)極端情況:我們現(xiàn)在不對(duì) node_modules 中的代碼進(jìn)行打包合并,那么當(dāng)我們請(qǐng)求一個(gè)腳本之前將可能會(huì)并發(fā)請(qǐng)求數(shù)十甚至上百個(gè)依賴(lài)的腳本庫(kù)。同域名下的并發(fā)請(qǐng)求數(shù)過(guò)高會(huì)導(dǎo)致請(qǐng)求排隊(duì),同時(shí)還可能受到 TCP/IP 慢啟動(dòng)的影響。
當(dāng)然,在很多流行的構(gòu)建工具中(webpack/Rollup/Parcel),是默認(rèn)會(huì)幫你把依賴(lài)打包到一起的。不過(guò)當(dāng)你使用其他一些工具時(shí),就要注意了。例如使用 FIS3 時(shí),就需要通過(guò)配置聲明,將一些 common 庫(kù)或 npm 依賴(lài)進(jìn)行打包合并。又或者使用 Gulp 這樣的工具,也需要注意進(jìn)行打包。
總之,千萬(wàn)不要讓你的碎文件散落一地。
2. 減少包體大小
2.1. 代碼壓縮
JavaScript 代碼壓縮比較常見(jiàn)的做法就是使用 UglifyJS 做源碼級(jí)別的壓縮。它會(huì)通過(guò)將變量替換為短命名、去掉多余的換行符等方式,在盡量不改變?cè)创a邏輯的情況下,做到代碼體積的壓縮?;疽呀?jīng)成為了前端開(kāi)發(fā)的標(biāo)配。在 webpack 的 production 模式下是默認(rèn)開(kāi)啟的;而在 Gulp 這樣的任務(wù)流管理工具上也有 gulp-uglify 這樣的功能插件。
另一個(gè)代碼壓縮的常用手段是使用一些文本壓縮算法,gzip 就是常用的一種方式。
上圖中響應(yīng)頭的 Content-Encoding 表示其使用了 gzip。
深色的數(shù)字表示壓縮后的大小為 22.0KB,淺色部分表示壓縮前的大小為 91.9KB,壓縮比還是挺大的,很有效果。一般服務(wù)器都會(huì)內(nèi)置相應(yīng)模塊來(lái)進(jìn)行 gzip 處理,不需要我們單獨(dú)編寫(xiě)壓縮算法模塊。例如在 Nginx 中就包含了 ngx_http_gzip_module[3] 模塊,通過(guò)簡(jiǎn)單的配置就可以開(kāi)啟。
gzip on;
gzip_min_length 1000;
gzip_comp_level 6;
gzip_types application/javascript application/x-javascript text/javascript;
2.2. Tree Shaking
Tree Shaking 最早進(jìn)入到前端的視線(xiàn)主要是因?yàn)?Rollup。后來(lái)在 webpack 中也被實(shí)現(xiàn)了。其本質(zhì)是通過(guò)檢測(cè)源碼中不會(huì)被使用到的部分,將其刪除,從而減小代碼的體積。例如:
// 模塊 A
export function add(a, b) {
return a + b;
}
export function minus(a, b) {
return a - b;
}
// 模塊 B
import {add} from 'module.A.js';
console.log(add(1, 2));
可以看到,模塊 B 引用了模塊 A,但是只使用了 add 方法。因此 minus 方法相當(dāng)于成為了 Dead Code,將它打包進(jìn)去沒(méi)有意義,該方法是永遠(yuǎn)不會(huì)被使用到的。
注意,我在上面的代碼中使用了 ESM 規(guī)范的模塊語(yǔ)法,而沒(méi)有使用 CommonJS。這主要是由于 Tree Shaking 算是一種靜態(tài)分析,而 ESM 本身是一種的靜態(tài)的模塊化規(guī)范,所有依賴(lài)可以在編譯期確定。如果想要更好得在 webpack 中使用,可以在查看其官網(wǎng)上的這部分內(nèi)容[4]。關(guān)于 Tree Shaking 的介紹也可以從這里了解下[5]。
注意,剛才說(shuō)了 Tree Shaking 非常依賴(lài)于 ESM。像是前端流行的工具庫(kù) lodash 一般直接安裝的版本是非 ESM 的,為了支持 Tree Shaking,我們需要去安裝它的 ESM 版本 —— lodash-es 來(lái)實(shí)現(xiàn) Tree Shaking[6]。
此外,Chrome DevTools 也可以幫助你查看加載的 JavaScript 代碼的使用覆蓋率[7]。
2.3. 優(yōu)化 polyfill 的使用
前端技術(shù)的一大特點(diǎn)就是需要考慮兼容性。為了讓大家能順暢地使用瀏覽器的新特性,一些程序員們開(kāi)發(fā)了新特性對(duì)應(yīng)的 polyfill,用于在非兼容瀏覽器上也能使用新特性的 API。后續(xù)升級(jí)不用改動(dòng)業(yè)務(wù)代碼,只需要?jiǎng)h除相應(yīng)的 polyfill 即可。
這種舒適的開(kāi)發(fā)體驗(yàn)也讓 polyfill 成為了很多項(xiàng)目中不可或缺的一份子。然而 polyfill 也是有代價(jià)的,它增加了代碼的體積。畢竟 polyfill 也是 JavaScript 寫(xiě)的,不是內(nèi)置在瀏覽器中,引入的越多,代碼體積也越大。所以,只加載真正所需的 polyfill 將會(huì)幫助你減小代碼體積。
首先,不是每個(gè)業(yè)務(wù)的兼容性要求都一樣。因此,按你業(yè)務(wù)的場(chǎng)景來(lái)確定引入哪些 polyfill 是最合適的。然而,特性千千萬(wàn),手動(dòng) import 或者添加 Babel Transformer 顯然是一件成本極高的事。針對(duì)這點(diǎn),我們可以通過(guò) browserslist 來(lái)幫忙,許多前端工具(babel-preset-env/autoprefixer/eslint-plugin-compat)都依賴(lài)于它。使用方式可以看這里。
其次,在 Chrome Dev Summit 2018 上還介紹了一種 Differential Serving[8] 的技術(shù),通過(guò)瀏覽器原生模塊化 API 來(lái)盡量避免加載無(wú)用 polyfill。
<script type="module" src="main.mjs"></script>
<script nomodule src="legacy.js"></script>
這樣,在能夠處理 module 屬性的瀏覽器(具有很多新特性)上就只需加載 main.mjs(不包含 polyfill),而在老式瀏覽器下,則會(huì)加載 legacy.js(包含 polyfill)。
最后,其實(shí)在理想上,polyfill 最優(yōu)的使用方式應(yīng)該是根據(jù)瀏覽器特性來(lái)分發(fā),同一個(gè)項(xiàng)目在不同的瀏覽器,會(huì)加載不同的 polyfill 文件。例如 Polyfill.io 就會(huì)根據(jù)請(qǐng)求頭中的客戶(hù)端特性與所需的 API 特性來(lái)按實(shí)際情況返回必須的 polyfill 集合。
2.4. webpack
webpack 現(xiàn)在已經(jīng)成為很多前端應(yīng)用的構(gòu)建工具,因此這里單獨(dú)將其列了出來(lái)。我們可以通過(guò) webpack-bundle-analyzer 這個(gè)工具來(lái)查看打包代碼里面各個(gè)模塊的占用大小。
很多時(shí)候,打包體積過(guò)大主要是因?yàn)橐肓瞬缓线m的包,對(duì)于如何優(yōu)化依賴(lài)包的引入,這里有一些建議可以幫助你減小 bundle 的體積[9]。
3. 解析與執(zhí)行
除了 JavaScript 下載需要耗時(shí)外,腳本的解析與執(zhí)行也是會(huì)消耗時(shí)間的。
3.1. JavaScript 的解析耗時(shí)
很多情況下,我們會(huì)忽略 JavaScript 文件的解析。一個(gè) JavaScript 文件,即使內(nèi)部沒(méi)有所謂的“立即執(zhí)行函數(shù)”,JavaScript 引擎也是需要對(duì)其進(jìn)行解析和編譯的。
從上圖可以看出,解析與編譯消耗了好幾百毫秒。所以換一個(gè)角度來(lái)說(shuō),刪除不必要的代碼,對(duì)于降低 Parse 與 Compile 的負(fù)載也是很有幫助的。
同時(shí),我們從前一節(jié)已經(jīng)知道,JavaScript 的解析、編譯和執(zhí)行會(huì)阻塞頁(yè)面解析,延遲用戶(hù)交互。所以有時(shí)候,加載同樣字節(jié)數(shù)的 JavaScript 對(duì)性能的影響可能會(huì)高于圖片,因?yàn)閳D片的處理可以放在其他線(xiàn)程中并行執(zhí)行。
3.2. 避免 Long Task
對(duì)于一些單頁(yè)應(yīng)用,在加載完核心的 JavaScript 資源后,可能會(huì)需要執(zhí)行大量的邏輯。如果處理不好,可能會(huì)出現(xiàn) JavaScript 線(xiàn)程長(zhǎng)時(shí)間執(zhí)行而阻塞主線(xiàn)程的情況。
例如在上圖中,幀率下降明顯的地方出現(xiàn)了 Long Task,伴隨著的是有一段超過(guò) 700 ms 的腳本執(zhí)行時(shí)間。而性能指標(biāo) FCP 與 DCL 處于其后,一定程度上可以認(rèn)為,這個(gè) Long Task 阻塞了主線(xiàn)程并拖慢了頁(yè)面的加載時(shí)間,嚴(yán)重影響了前端性能與體驗(yàn)。
想要了解更多關(guān)于 Long Task 的內(nèi)容,可以看看 Long Task 相關(guān)的標(biāo)準(zhǔn)[10]。
3.3. 是否真的需要框架
相信如果現(xiàn)在問(wèn)大家,我們是否需要 React、Vue、Angular 或其他前端框架(庫(kù)),大概率是肯定的。
但是我們可以換個(gè)角度來(lái)思考這個(gè)問(wèn)題。類(lèi)庫(kù)/框架幫我們解決的問(wèn)題之一是快速開(kāi)發(fā)與后續(xù)維護(hù)代碼,很多時(shí)候,類(lèi)庫(kù)/框架的開(kāi)發(fā)者是需要在可維護(hù)性、易用性和性能上做取舍的。對(duì)于一個(gè)復(fù)雜的整站應(yīng)用,使用框架給你的既定編程范式將會(huì)在各個(gè)層面提升你工作的質(zhì)量。但是,對(duì)于某些頁(yè)面,我們是否可以反其道行之呢?
例如產(chǎn)品經(jīng)理反饋,咱們的落地頁(yè)加載太慢了,用戶(hù)容易流失。這時(shí)候你會(huì)開(kāi)始優(yōu)化性能,用上這次「性能之旅」里的各種措施。但你有沒(méi)有考慮過(guò),對(duì)于像落地頁(yè)這樣的、類(lèi)似靜態(tài)頁(yè)的頁(yè)面,是不是可以“返璞歸真”?
也許你使用了 React 技術(shù)棧 —— 你加載了 React、Redux、React-Redux、一堆 Reducers…… 好吧,整個(gè) JavaScript 可能快 1MB 了。更重要的是,這個(gè)頁(yè)面如果是用于拉新的,這也代表著訪(fǎng)問(wèn)者并沒(méi)有緩存可以用。好吧,為了一個(gè)靜態(tài)頁(yè)(或者還有一些非常簡(jiǎn)單的表單交互),用戶(hù)付出了高額的成本,而原本這只需要 50 行不到的代碼。所以有時(shí)候考慮使用原生 JavaScript 來(lái)實(shí)現(xiàn)它也是一種策略。Netflix 有一篇文章介紹了他們是如何通過(guò)這種方式大幅縮減加載與操作響應(yīng)時(shí)間的[11]。
當(dāng)然,還是強(qiáng)調(diào)一下,并不是說(shuō)不要使用框架/類(lèi)庫(kù),只是希望大家不要拘泥于某個(gè)思維定式。做工具的主人,而不是工具的“奴隸”。
3.4. 針對(duì)代碼的優(yōu)化
<font style="color:#d65">請(qǐng)注意,截止目前(2019.08)以下內(nèi)容不建議在生產(chǎn)環(huán)境中使用。</font>
還有一種優(yōu)化思路是把代碼變?yōu)樽顑?yōu)狀態(tài)。它其實(shí)算是一種編譯優(yōu)化。在一些編譯型的靜態(tài)語(yǔ)言上(例如 C++),通過(guò)編譯器進(jìn)行一些優(yōu)化非常常見(jiàn)。
這里要提到的就是 facebook 推出的 Prepack。例如下面一段代碼:
(function () {
function hello() {return 'hello';}
function world() {return 'world';}
global.s = hello() + ' ' + world();
})();
可以?xún)?yōu)化為:
s = 'hello world';
不過(guò)很多時(shí)候,代碼體積和運(yùn)行性能是會(huì)有矛盾的。同時(shí) Prepack 也還不夠成熟,所以不建議在生產(chǎn)環(huán)境中使用。
4. 緩存
JavaScript 部分的緩存與我們?cè)诘谝徊糠掷锾岬降木彺婊疽恢拢绻阌洸惶辶耍?a target="_blank">可以回到咱們的第一站。
4.1. 發(fā)布與部署
這里簡(jiǎn)單提一下:大多數(shù)情況下,我們對(duì)于 JavaScript 與 CSS 這樣的靜態(tài)資源,都會(huì)啟動(dòng) HTTP 緩存。當(dāng)然,可能使用強(qiáng)緩存,也可能使用協(xié)商緩存。當(dāng)我們?cè)趶?qiáng)緩存機(jī)制上發(fā)布了更新的時(shí)候,如何讓瀏覽器棄用緩存,請(qǐng)求新的資源呢?
一般會(huì)有一套配合的方式:首先在文件名中包含文件內(nèi)容的 Hash,內(nèi)容修改后,文件名就會(huì)變化;同時(shí),設(shè)置不對(duì)頁(yè)面進(jìn)行強(qiáng)緩存,這樣對(duì)于內(nèi)容更新的靜態(tài)資源,由于 uri 變了,肯定不會(huì)再走緩存,而沒(méi)有變動(dòng)的資源則仍然可以使用緩存。
上面說(shuō)的主要涉及前端資源的發(fā)布和部署,詳細(xì)可以看這篇內(nèi)容[12],這里就不展開(kāi)了。
4.2. 將基礎(chǔ)庫(kù)代碼打包合并
為了更好利用緩存,我們一般會(huì)把不容易變化的部分單獨(dú)抽取出來(lái)。例如一個(gè) React 技術(shù)棧的項(xiàng)目,可能會(huì)將 React、Redux、React-Router 這類(lèi)基礎(chǔ)庫(kù)單獨(dú)打包出一個(gè)文件。
這樣做的優(yōu)點(diǎn)在于,由于基礎(chǔ)庫(kù)被單獨(dú)打包在一起了,即使業(yè)務(wù)代碼經(jīng)常變動(dòng),也不會(huì)導(dǎo)致整個(gè)緩存失效。基礎(chǔ)框架/庫(kù)、項(xiàng)目中的 common、util 仍然可以利用緩存,不會(huì)每次發(fā)布新版都會(huì)讓用戶(hù)花費(fèi)不必要的帶寬重新下載基礎(chǔ)庫(kù)。
所以一種常見(jiàn)的策略就是將基礎(chǔ)庫(kù)這種 Cache 周期較長(zhǎng)的內(nèi)容單獨(dú)打包在一起,利用緩存減少新版本發(fā)布后用戶(hù)的訪(fǎng)問(wèn)速度。這種方法本質(zhì)上是將緩存周期不同的內(nèi)容分離了,隔離了變化。
webpack 在 v3.x 以及之前,可以通過(guò) CommonChunkPlugin 來(lái)分離一些公共庫(kù)。而升級(jí)到 v4.x 之后有了一個(gè)新的配置項(xiàng) optimization.splitChunks:
// webpack.config.js
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'all',
minChunks: 1,
cacheGroups: {
commons: {
minChunks: 1,
automaticNamePrefix: 'commons',
test: /[\\/]node_modules[\\/]react|redux|react-redux/,
chunks: 'all'
}
}
}
}
}
4.3. 減少 webpack 編譯不當(dāng)帶來(lái)的緩存失效
由于 webpack 已經(jīng)成為前端主流的構(gòu)建工具,因此這里再特別提一下使用 webpack 時(shí)的一些注意點(diǎn),減少一些不必要的緩存失效。
我們知道,對(duì)于每個(gè)模塊 webpack 都會(huì)分配一個(gè)唯一的模塊 ID,一般情況下 webpack 會(huì)使用自增 ID。這就可能導(dǎo)致一個(gè)問(wèn)題:一些模塊雖然它們的代碼沒(méi)有變化,但由于增/刪了新的其他模塊,導(dǎo)致后續(xù)所有的模塊 ID 都變更了,文件 MD5 也就變化了。另一個(gè)問(wèn)題在于,webpack 的入口文件除了包含它的 runtime、業(yè)務(wù)模塊代碼,同時(shí)還有一個(gè)用于異步加載的小型 manifest,任何一個(gè)模塊的變化,最后必然會(huì)傳導(dǎo)到入口文件。這些都會(huì)使得網(wǎng)站發(fā)布后,沒(méi)有改動(dòng)源碼的資源也會(huì)緩存失效。
規(guī)避這些問(wèn)題有一些常用的方式。
4.3.1. 使用 Hash 來(lái)替代自增 ID
你可以使用 HashedModuleIdsPlugin 插件,它會(huì)根據(jù)模塊的相對(duì)路徑來(lái)計(jì)算 Hash 值。當(dāng)然,你也可以使用 webpack 提供的 optimization.moduleIds,將其設(shè)置為 hash,或者選擇其他合適的方式。
4.3.2. 將 runtime chunk 單獨(dú)拆分出來(lái)
通過(guò) optimization.runtimeChunk 配置可以讓 webpack 把包含 manifest 的 runtime 部分單獨(dú)分離出來(lái),這樣就可以盡可能限制變動(dòng)影響的文件范圍。
// webpack.config.js
module.exports = {
//...
optimization: {
runtimeChunk: {
name: 'runtime'
}
},
}
如果你對(duì) webpack 模塊化 runtime 運(yùn)行的原理不太了解,可以看看這篇文章[13]。
4.3.3. 使用 records
你可以通過(guò) recordsPath 配置來(lái)讓 webpack 產(chǎn)出一個(gè)包含模塊信息記錄的 JSON 文件,其中包含了一些模塊標(biāo)識(shí)的信息,可以用于之后的編譯。這樣在后續(xù)的打包編譯時(shí),對(duì)于被拆分出來(lái)的 Bundle,webpack 就可以根據(jù) records 中的信息來(lái)盡量避免破壞緩存。
// webpack.config.js
module.exports = {
//...
recordsPath: path.join(__dirname, 'records.json')
};
如果對(duì)上述避免或減少緩存失效的方法感興趣,也可以再讀一讀這篇文章14。在 webpack v5.x 的計(jì)劃中,也有針對(duì) module 和 chunk ID 的一些工作計(jì)劃來(lái)提高長(zhǎng)期緩存。
「性能優(yōu)化」系列內(nèi)容
-
5.1. 如何針對(duì) JavaScript 進(jìn)行性能優(yōu)化?(本文)
5.2. ?? 如何針對(duì) CSS 進(jìn)行性能優(yōu)化?
5.3. 圖片雖好,但也會(huì)帶來(lái)性能問(wèn)題
5.4. 字體也需要性能優(yōu)化么?
5.5. 如何針對(duì)視頻進(jìn)行性能優(yōu)化?
如何避免運(yùn)行時(shí)的性能問(wèn)題?
如何通過(guò)預(yù)加載來(lái)提升性能?
尾聲
目前內(nèi)容已全部更新至 ? fe-performance-journey ? 倉(cāng)庫(kù)中,陸續(xù)會(huì)將內(nèi)容同步到掘金上。如果希望盡快閱讀相關(guān)內(nèi)容,也可以直接去該倉(cāng)庫(kù)中瀏覽。
參考資料
- Proposal Dynamic Import
- 在 react-router4 中進(jìn)行代碼拆分
- Module ngx_http_gzip_module
- Tree Shaking - webpack
- Tree Shaking 性能優(yōu)化實(shí)踐 - 原理篇
- Tree Shaking for Lodash
- CSS and JS code coverage - Chrome DevTools
- Chrome Dev Summit 2018
- Optimize your libraries with webpack
- Long Tasks API 1
- A Netflix Web Performance Case Study
- 大公司里怎樣開(kāi)發(fā)和部署前端代碼?
- webpack進(jìn)階:前端運(yùn)行時(shí)的模塊化設(shè)計(jì)與實(shí)現(xiàn)
- Separating a Manifest
- The cost of JavaScript in 2019
- [譯] 2019 年的 JavaScript 性能
- webpack 4: Code Splitting, chunk graph and the splitChunks optimization
- 文本壓縮算法的對(duì)比和選擇
- 簡(jiǎn)單聊聊 GZIP 的壓縮原理與日常應(yīng)用
- Text Compression
- Better tree shaking with deep scope analysis
- How we reduced our initial JS/CSS size by 67%