🔝 靜態資源優化的整體思路javascript
隨着 Web 的發展,JavaScript 從之前只承擔簡單的腳本功能,到如今被用於構建大型、複雜的前端應用,經歷了很大的發展。這也讓它在當下的前端應用中扮演了一個很是重要的角色,所以在這一節首先來看看的咱們熟悉的 JavaScript。css
在進行 JavaScript 優化時,咱們仍是秉承整體思路,首先就是減小沒必要要的請求。html
相信熟練使用 webpack 的同窗對這一特性都不陌生。前端
雖然總體應用的代碼很是多,可是不少時候,咱們在訪問一個頁面時,並不須要把其餘頁面的組件也所有加載過來,徹底能夠等到訪問其餘頁面時,再按需去動態加載。核心思路以下所示:java
document.getElementById('btn').addEventListener('click', e => {
// 在這裏加載 chat 組件相關資源 chat.js
const script = document.createElement('script');
script.src = '/static/js/chat.js';
document.getElementsByTagName('head')[0].appendChild(script);
});
複製代碼
在按鈕點擊的監聽函數中,我動態添加了 <script>
元素。這樣就能夠實如今點擊按鈕時,才加載對應的 JavaScript 腳本。node
代碼拆分通常會配合構建工具一塊兒使用。以 webpack 爲例,在平常使用時,最多見的方式就是經過 dynamic import[1] 來告訴 webpack 去作代碼拆分。webpack 編譯時會進行語法分析,以後遇到 dynamic import 就會認爲這個模塊是須要動態加載的。相應的,其子資源也會被如此處理(除非被其餘非動態模塊也引用了)。react
在 webpack 中使用代碼拆分最多見的一個場景是基於路由的代碼拆分。目前不少前端應用都在使用 SPA(單頁面應用)形式,或者 SPA 與 MPA(多頁面應用)的結合體,這就會涉及到前端路由。而頁面間的業務差別也讓基於路由的代碼拆分紅爲一個最佳實踐。想了解如何在 react-router v4 中實現路由級別的代碼拆分,能夠看這篇文章[2]。webpack
固然,若是你不使用 webpack 之類的構建工具,你也能夠選擇一個 AMD 模塊加載器(例如 RequireJS)來實現前端運行時上的異步依賴加載。nginx
咱們在整體思路里有提到,減小請求的一個方法就是合併資源。試想一個極端狀況:咱們如今不對 node_modules 中的代碼進行打包合併,那麼當咱們請求一個腳本以前將可能會併發請求數十甚至上百個依賴的腳本庫。同域名下的併發請求數太高會致使請求排隊,同時還可能受到 TCP/IP 慢啓動的影響。git
固然,在不少流行的構建工具中(webpack/Rollup/Parcel),是默認會幫你把依賴打包到一塊兒的。不過當你使用其餘一些工具時,就要注意了。例如使用 FIS3 時,就須要經過配置聲明,將一些 common 庫或 npm 依賴進行打包合併。又或者使用 Gulp 這樣的工具,也須要注意進行打包。
總之,千萬不要讓你的碎文件散落一地。
JavaScript 代碼壓縮比較常見的作法就是使用 UglifyJS 作源碼級別的壓縮。它會經過將變量替換爲短命名、去掉多餘的換行符等方式,在儘可能不改變源碼邏輯的狀況下,作到代碼體積的壓縮。基本已經成爲了前端開發的標配。在 webpack 的 production 模式下是默認開啓的;而在 Gulp 這樣的任務流管理工具上也有 gulp-uglify 這樣的功能插件。
另外一個代碼壓縮的經常使用手段是使用一些文本壓縮算法,gzip 就是經常使用的一種方式。
上圖中響應頭的 Content-Encoding
表示其使用了 gzip。
深色的數字表示壓縮後的大小爲 22.0KB,淺色部分表示壓縮前的大小爲 91.9KB,壓縮比仍是挺大的,頗有效果。通常服務器都會內置相應模塊來進行 gzip 處理,不須要咱們單獨編寫壓縮算法模塊。例如在 Nginx 中就包含了 ngx_http_gzip_module[3] 模塊,經過簡單的配置就能夠開啓。
gzip on;
gzip_min_length 1000;
gzip_comp_level 6;
gzip_types application/javascript application/x-javascript text/javascript;
複製代碼
Tree Shaking 最先進入到前端的視線主要是由於 Rollup。後來在 webpack 中也被實現了。其本質是經過檢測源碼中不會被使用到的部分,將其刪除,從而減少代碼的體積。例如:
// 模塊 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
方法至關於成爲了 Dead Code,將它打包進去沒有意義,該方法是永遠不會被使用到的。
注意,我在上面的代碼中使用了 ESM 規範的模塊語法,而沒有使用 CommonJS。這主要是因爲 Tree Shaking 算是一種靜態分析,而 ESM 自己是一種的靜態的模塊化規範,全部依賴能夠在編譯期肯定。若是想要更好得在 webpack 中使用,能夠在查看其官網上的這部份內容[4]。關於 Tree Shaking 的介紹也能夠從這裏瞭解下[5]。
注意,剛纔說了 Tree Shaking 很是依賴於 ESM。像是前端流行的工具庫 lodash 通常直接安裝的版本是非 ESM 的,爲了支持 Tree Shaking,咱們須要去安裝它的 ESM 版本 —— lodash-es 來實現 Tree Shaking[6]。
此外,Chrome DevTools 也能夠幫助你查看加載的 JavaScript 代碼的使用覆蓋率[7]。
前端技術的一大特色就是須要考慮兼容性。爲了讓你們能順暢地使用瀏覽器的新特性,一些程序員們開發了新特性對應的 polyfill,用於在非兼容瀏覽器上也能使用新特性的 API。後續升級不用改動業務代碼,只須要刪除相應的 polyfill 便可。
這種溫馨的開發體驗也讓 polyfill 成爲了不少項目中不可或缺的一份子。然而 polyfill 也是有代價的,它增長了代碼的體積。畢竟 polyfill 也是 JavaScript 寫的,不是內置在瀏覽器中,引入的越多,代碼體積也越大。因此,只加載真正所需的 polyfill 將會幫助你減少代碼體積。
首先,不是每一個業務的兼容性要求都同樣。所以,按你業務的場景來肯定引入哪些 polyfill 是最合適的。然而,特性千千萬,手動 import 或者添加 Babel Transformer 顯然是一件成本極高的事。針對這點,咱們能夠經過 browserslist 來幫忙,許多前端工具(babel-preset-env/autoprefixer/eslint-plugin-compat)都依賴於它。使用方式能夠看這裏。
其次,在 Chrome Dev Summit 2018 上還介紹了一種 Differential Serving[8] 的技術,經過瀏覽器原生模塊化 API 來儘可能避免加載無用 polyfill。
<script type="module" src="main.mjs"></script>
<script nomodule src="legacy.js"></script>
複製代碼
這樣,在可以處理 module
屬性的瀏覽器(具備不少新特性)上就只需加載 main.mjs
(不包含 polyfill),而在老式瀏覽器下,則會加載 legacy.js
(包含 polyfill)。
最後,其實在理想上,polyfill 最優的使用方式應該是根據瀏覽器特性來分發,同一個項目在不一樣的瀏覽器,會加載不一樣的 polyfill 文件。例如 Polyfill.io 就會根據請求頭中的客戶端特性與所需的 API 特性來按實際狀況返回必須的 polyfill 集合。
webpack 如今已經成爲不少前端應用的構建工具,所以這裏單獨將其列了出來。咱們能夠經過 webpack-bundle-analyzer 這個工具來查看打包代碼裏面各個模塊的佔用大小。
不少時候,打包體積過大主要是由於引入了不合適的包,對於如何優化依賴包的引入,這裏有一些建議能夠幫助你減少 bundle 的體積[9]。
除了 JavaScript 下載須要耗時外,腳本的解析與執行也是會消耗時間的。
不少狀況下,咱們會忽略 JavaScript 文件的解析。一個 JavaScript 文件,即便內部沒有所謂的「當即執行函數」,JavaScript 引擎也是須要對其進行解析和編譯的。
從上圖能夠看出,解析與編譯消耗了好幾百毫秒。因此換一個角度來講,刪除沒必要要的代碼,對於下降 Parse 與 Compile 的負載也是頗有幫助的。
同時,咱們從前一節已經知道,JavaScript 的解析、編譯和執行會阻塞頁面解析,延遲用戶交互。因此有時候,加載一樣字節數的 JavaScript 對性能的影響可能會高於圖片,由於圖片的處理能夠放在其餘線程中並行執行。
對於一些單頁應用,在加載完核心的 JavaScript 資源後,可能會須要執行大量的邏輯。若是處理很差,可能會出現 JavaScript 線程長時間執行而阻塞主線程的狀況。
例如在上圖中,幀率降低明顯的地方出現了 Long Task,伴隨着的是有一段超過 700 ms 的腳本執行時間。而性能指標 FCP 與 DCL 處於其後,必定程度上能夠認爲,這個 Long Task 阻塞了主線程並拖慢了頁面的加載時間,嚴重影響了前端性能與體驗。
想要了解更多關於 Long Task 的內容,能夠看看 Long Task 相關的標準[10]。
相信若是如今問你們,咱們是否須要 React、Vue、Angular 或其餘前端框架(庫),大機率是確定的。
可是咱們能夠換個角度來思考這個問題。類庫/框架幫咱們解決的問題之一是快速開發與後續維護代碼,不少時候,類庫/框架的開發者是須要在可維護性、易用性和性能上作取捨的。對於一個複雜的整站應用,使用框架給你的既定編程範式將會在各個層面提高你工做的質量。可是,對於某些頁面,咱們是否能夠反其道行之呢?
例如產品經理反饋,我們的落地頁加載太慢了,用戶容易流失。這時候你會開始優化性能,用上此次「性能之旅」裏的各類措施。但你有沒有考慮過,對於像落地頁這樣的、相似靜態頁的頁面,是否是能夠「返璞歸真」?
也許你使用了 React 技術棧 —— 你加載了 React、Redux、React-Redux、一堆 Reducers…… 好吧,整個 JavaScript 可能快 1MB 了。更重要的是,這個頁面若是是用於拉新的,這也表明着訪問者並無緩存能夠用。好吧,爲了一個靜態頁(或者還有一些很是簡單的表單交互),用戶付出了高額的成本,而本來這隻須要 50 行不到的代碼。因此有時候考慮使用原生 JavaScript 來實現它也是一種策略。Netflix 有一篇文章介紹了他們是如何經過這種方式大幅縮減加載與操做響應時間的[11]。
固然,仍是強調一下,並非說不要使用框架/類庫,只是但願你們不要拘泥於某個思惟定式。作工具的主人,而不是工具的「奴隸」。
請注意,截止目前(2019.08)如下內容不建議在生產環境中使用。
還有一種優化思路是把代碼變爲最優狀態。它其實算是一種編譯優化。在一些編譯型的靜態語言上(例如 C++),經過編譯器進行一些優化很是常見。
這裏要提到的就是 facebook 推出的 Prepack。例以下面一段代碼:
(function () {
function hello() {return 'hello';}
function world() {return 'world';}
global.s = hello() + ' ' + world();
})();
複製代碼
能夠優化爲:
s = 'hello world';
複製代碼
不過不少時候,代碼體積和運行性能是會有矛盾的。同時 Prepack 也還不夠成熟,因此不建議在生產環境中使用。
JavaScript 部分的緩存與咱們在第一部分裏提到的緩存基本一致,若是你記不太清了,能夠回到我們的第一站。
這裏簡單提一下:大多數狀況下,咱們對於 JavaScript 與 CSS 這樣的靜態資源,都會啓動 HTTP 緩存。固然,可能使用強緩存,也可能使用協商緩存。當咱們在強緩存機制上發佈了更新的時候,如何讓瀏覽器棄用緩存,請求新的資源呢?
通常會有一套配合的方式:首先在文件名中包含文件內容的 Hash,內容修改後,文件名就會變化;同時,設置不對頁面進行強緩存,這樣對於內容更新的靜態資源,因爲 uri 變了,確定不會再走緩存,而沒有變更的資源則仍然可使用緩存。
上面說的主要涉及前端資源的發佈和部署,詳細能夠看這篇內容[12],這裏就不展開了。
爲了更好利用緩存,咱們通常會把不容易變化的部分單獨抽取出來。例如一個 React 技術棧的項目,可能會將 React、Redux、React-Router 這類基礎庫單獨打包出一個文件。
這樣作的優勢在於,因爲基礎庫被單獨打包在一塊兒了,即便業務代碼常常變更,也不會致使整個緩存失效。基礎框架/庫、項目中的 common、util 仍然能夠利用緩存,不會每次發佈新版都會讓用戶花費沒必要要的帶寬從新下載基礎庫。
因此一種常見的策略就是將基礎庫這種 Cache 週期較長的內容單獨打包在一塊兒,利用緩存減小新版本發佈後用戶的訪問速度。這種方法本質上是將緩存週期不一樣的內容分離了,隔離了變化。
webpack 在 v3.x 以及以前,能夠經過 CommonChunkPlugin 來分離一些公共庫。而升級到 v4.x 以後有了一個新的配置項 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'
}
}
}
}
}
複製代碼
因爲 webpack 已經成爲前端主流的構建工具,所以這裏再特別提一下使用 webpack 時的一些注意點,減小一些沒必要要的緩存失效。
咱們知道,對於每一個模塊 webpack 都會分配一個惟一的模塊 ID,通常狀況下 webpack 會使用自增 ID。這就可能致使一個問題:一些模塊雖然它們的代碼沒有變化,但因爲增/刪了新的其餘模塊,致使後續全部的模塊 ID 都變動了,文件 MD5 也就變化了。另外一個問題在於,webpack 的入口文件除了包含它的 runtime、業務模塊代碼,同時還有一個用於異步加載的小型 manifest,任何一個模塊的變化,最後必然會傳導到入口文件。這些都會使得網站發佈後,沒有改動源碼的資源也會緩存失效。
規避這些問題有一些經常使用的方式。
你可使用 HashedModuleIdsPlugin 插件,它會根據模塊的相對路徑來計算 Hash 值。固然,你也可使用 webpack 提供的 optimization.moduleIds
,將其設置爲 hash
,或者選擇其餘合適的方式。
經過 optimization.runtimeChunk
配置可讓 webpack 把包含 manifest 的 runtime 部分單獨分離出來,這樣就能夠儘量限制變更影響的文件範圍。
// webpack.config.js
module.exports = {
//...
optimization: {
runtimeChunk: {
name: 'runtime'
}
},
}
複製代碼
若是你對 webpack 模塊化 runtime 運行的原理不太瞭解,能夠看看這篇文章[13]。
你能夠經過 recordsPath
配置來讓 webpack 產出一個包含模塊信息記錄的 JSON 文件,其中包含了一些模塊標識的信息,能夠用於以後的編譯。這樣在後續的打包編譯時,對於被拆分出來的 Bundle,webpack 就能夠根據 records 中的信息來儘可能避免破壞緩存。
// webpack.config.js
module.exports = {
//...
recordsPath: path.join(__dirname, 'records.json')
};
複製代碼
若是對上述避免或減小緩存失效的方法感興趣,也能夠再讀一讀這篇文章14。在 webpack v5.x 的計劃中,也有針對 module 和 chunk ID 的一些工做計劃來提升長期緩存。
5.1. 如何針對 JavaScript 進行性能優化?(本文)
5.2. 🔜 如何針對 CSS 進行性能優化?
5.3. 圖片雖好,但也會帶來性能問題
5.4. 字體也須要性能優化麼?
5.5. 如何針對視頻進行性能優化?
如何避免運行時的性能問題?
如何經過預加載來提高性能?
尾聲
目前內容已所有更新至 ✨ fe-performance-journey ✨ 倉庫中,陸續會將內容同步到掘金上。若是但願儘快閱讀相關內容,也能夠直接去該倉庫中瀏覽。