還能再瘦下去嗎#
Code Splitting、動態載入、Tree Shaking 已經把 JavaScript bundle 瘦了好幾輪。如果想繼續推極致,還有兩個方向值得探索:減少為了相容性而增加的轉譯/Polyfill 體積,以及在傳輸階段把檔案壓得更小。本章會把這兩件事一次講完。
什麼是 Modern JavaScript#
先回答一個簡單的問題:以下這段算不算 Modern JavaScript?
var name = "kyle";
console.log(name);當然不算,因為 ES6 之後我們已經用 let 與 const 取代 var。
那是不是「ES6 以後的語法」就一定算 Modern JavaScript?也不對。Google Chrome Developer 給的定義是:
Modern JavaScript 是被「所有 modern browser」都支援的語法所撰寫的程式碼。
而所謂 modern browser,指的是 Chrome、Firefox、Safari、Edge 這四個合計約 90% 市佔率的主流瀏覽器,再加上其他底層引擎相同、支援度差不多的非主流瀏覽器(約 5%)。
換句話說,Modern JavaScript 不是固定指某個 ES 版本,而是「在一段時間內、被約 95% 瀏覽器支援的新語法集合」,這個界線會隨時間滑動。以目前情況來說,ES2017 大約有 95% 支援度,最接近 Modern JavaScript 的標準;ES2021 雖然功能更新,但支援度只有約 70%,還不能放心當作 baseline。
Transpilation 的代價#
為了讓 bundle 能在舊瀏覽器跑,開發過程通常會把程式碼丟進 Babel 這類 transpiler 轉成較舊版本(例如 ES5)。但「新轉舊」會讓程式碼明顯變長:很多新語法在舊瀏覽器沒有對應實作,需要透過 Polyfill 補齊。
例如 ES6 才實作的 String.prototype.repeat,要在不支援的瀏覽器補齊得寫一段相當冗長的 Polyfill。原本一行可以解決的事情,轉譯後可能變成幾十行。class 語法、async/await 等也有同樣問題。Instagram 曾分享過一份研究:把 transpile 的最低 target 從 ES5 提高到 ES2017,bundle size 降了 5.7%、現代瀏覽器使用者的 page speed 提升了約 3%。
差異化載入:兼顧新舊瀏覽器#
那麼為了效能,要不要犧牲舊版瀏覽器使用者?其實有更聰明的辦法。我們可以在打包時產出兩份 bundle:
- 一份保留 Modern JavaScript(例如以 ES2017 為 target)給支援的瀏覽器。
- 一份轉譯到 ES5 作為 fallback,給舊瀏覽器使用。
這個策略稱作差異化載入(Differential Serving),瀏覽器層級透過 type="module" 與 nomodule 兩個屬性合作完成:
<script type="module" src="modern_module.js"></script>
<script nomodule src="fallback.js"></script>支援 ES Module 的瀏覽器只會執行 type="module" 的 script,並自動忽略 nomodule 那個;不支援的舊瀏覽器則相反,會跳過它看不懂的 module script,去載入 nomodule 對應的 fallback。
自己發 npm package 時也可以差異化#
package.json 上有些欄位專門給這個情境用:
{
"name": "foo",
"exports": "./modern.js",
"main": "./legacy.cjs",
"module": "./module.js"
}exports:Modern JavaScript 版本的入口。main:相容舊環境的 fallback 版本(CommonJS)。module:與main內容類似但採用 ESM 的版本。bundler 會優先讀它,因為這份能配合 Tree Shaking 做進一步瘦身。
Google Chrome 團隊提供了一個叫做 estimator.dev 的線上服務,可以反向推算「如果你的網站全部使用 Modern JavaScript,能瘦下多少體積、提升多少效能」,是個方便的決策參考工具。
從另一個方向著手:檔案壓縮#
接下來換個視角:與其在程式碼層級瘦身,不如在傳輸層級直接壓縮。檔案壓縮是現代網頁傳輸幾乎必備的優化,原因很簡單——一份 1MB 的 HTML 在啟用 gzip 後可能只剩 300KB,少傳的這 700KB 就是節省下來的延遲與頻寬。
壓縮的兩種型態#
從用途看,壓縮可以區分為兩大類。
檔案格式壓縮(File Format Compression)
每種文件格式或多或少都有可被重新編排的冗餘空間。圖片、影片、音訊等媒體檔的壓縮空間又比文字檔大得多。常見的壓縮方式可細分成兩類演算法:
- 有損壓縮:壓縮過程會修改原始資料,但以使用者感官可接受的方式進行。jpeg 是經典代表。可以調整壓縮率,犧牲品質換取更小體積。
- 無損壓縮:壓縮與解壓縮過程不改動任何 bit,原始資料可以完美還原。gif、png 採用這種演算法。
已經壓縮過的檔案再丟進另一種壓縮演算法,效果通常不大,甚至可能因為元資料反而變大。這就是為什麼 png、jpeg 通常不會再用 gzip 壓一次。
端到端壓縮(End To End Compression)
這是 Web 應用最有感的優化。它指的是「client 發出請求 ➡️ server 完成壓縮 ➡️ 傳到 client ➡️ 由 client 解壓還原」整段流程,過程中即使經過任何 proxy node,response body 都維持壓縮狀態。
現代瀏覽器與 web 伺服器已經幾乎都支援這項機制,差別只在所支援的演算法。目前最常見的是 gzip,新一代的 Brotli 則在多數情境下能達到比 gzip 更高的壓縮率。
瀏覽器與伺服器的協商#
實作端到端壓縮需要兩端的協商。瀏覽器在 request header 帶上 Accept-Encoding,告訴伺服器自己支援哪些壓縮演算法(順序也代表偏好):
Accept-Encoding: br, gzip伺服器收到後選一種演算法壓縮 response body,並在 response header 用 Content-Encoding 告訴瀏覽器它選了哪種演算法,讓瀏覽器知道怎麼解壓。常搭配的還有 Vary: Accept-Encoding,讓 proxy / 瀏覽器可以為不同壓縮版本各自快取。
怎麼實際開啟壓縮#
要在哪一層做壓縮取決於部署架構。Node.js 後端(例如 Express、Koa)有現成的 middleware:
const compression = require("compression");
const express = require("express");
const app = express();
app.use(compression({ filter: shouldCompress }));
function shouldCompress(req, res) {
if (req.headers["x-no-compression"]) {
return false;
}
return compression.filter(req, res);
}但壓縮本身是相當吃 CPU 的操作。在面對大量流量、且 Node.js 又是單執行緒的情境下,把壓縮工作交給 reverse proxy(例如 Nginx)通常是更穩妥的做法:
server {
gzip on;
gzip_types text/plain application/xml application/json;
gzip_comp_level 9;
gzip_min_length 1000;
}如果不是自架伺服器而是用第三方靜態網站服務或 CDN,多半都會自動處理壓縮。可以在 DevTool 的 Network tab 觀察 response header 是否帶有 Content-Encoding,並確認用的是 gzip 還是 Brotli。
除了已經壓縮過的圖片格式(PNG、JPEG),其他文字類資源(HTML、CSS、JS、JSON、SVG 等)幾乎都該開啟端到端壓縮。投入成本低、收益顯著。
一個小總結#
Build 階段的優化壓軸有兩條路:一條是在程式碼產出時就避免 Polyfill 與舊語法塞進 Modern Browser 的 bundle,靠差異化載入兼顧相容性;另一條是在傳輸階段把檔案再壓一次,用 gzip 或 Brotli 把體積與時間進一步壓縮。兩者疊加起來,能在不犧牲使用者支援度的前提下大幅縮短資源傳輸時間。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10275720