今天是 Build Optimizations
主題的最後一篇了,到目前為止我們已經認識了 Code Splitting, Dynamic Import 還有 Tree Shaking 等技巧,那還有沒有辦法再更進一步縮小 JavaScript bundle 的檔案大小呢?
當然是有的,今天就來認識 Polyfill-less Bundling Script
與 File Compression
兩種 Build Optimizations 的技巧吧!
你知道哪些 feature 屬於 Modern JavaScript 嗎?下面這段 code 你覺得屬於 Modern JavaScript
嗎?
var name = 'kyle';
console.log(name);
肯定不是,因為我們都知道 JavaScript 在 ES6 後都建議使用 let 與 const 來更嚴謹得宣告變數,上面這段 code 使用 var 宣告變數,所以可以很肯定它不能被算到 Modern JavaScript 的範疇中。
所以說是不是使用 ES6 之後的語法就算是 Modern JavaScript 呢?
也不對,根據 Google Chrome Developer 的定義:
Modern JavaScript is JavaScript code written in syntax that is supported in all modern browser.
而所謂 modern browser 代表的是 Chrome、Firefox、Safari、Edge 這四個市占率最高的瀏覽器(大約佔了所有瀏覽器市場的 90%)再加上其他比較沒那麼知名,但是底層使用的瀏覽器引擎與前四大家大致相同(代表它們對語法與功能的支援度也會與前四大家差不多)的非主流瀏覽器(大約佔瀏覽器市場的 5 %)。
按照這樣推論,Modern JavaScript 還可以有一個廣義的定義:
Modern JavaScript 不是專指哪一個版本的 JavaScript 標準,而是一個會變動的 target,在眾多新版本的 JavaScript 標準中,能被大約 95% 左右的瀏覽器所支援的新語法或功能,就可以稱作 Modern JavaScript。
目前 ES2021 的語法大約只有 70% 的瀏覽器支援度,雖然仍然過半,卻沒辦法依賴它提供穩定的 feature。而 ES2017 卻有高達約 95% 的支援度,因此目前可以認定 ES2017 是最接近 Modern Syntax 的版本。
不過 Modern JavaScript 這個定義跟今天的主題又有什麼關聯啊?
在開發專案的時候,為了確保產出的程式可以順利運行在對新版本 JavaScript 支援度比較低的瀏覽器上,例如早期前端開發者的惡夢 - IE 瀏覽器(IE 終於要在 2022 年終止服務了,目前開發基本上可以不用再考慮 IE 的支援度了),通常會把程式碼丟進像 babel 這樣的 transpiler 裡轉譯成較舊版本的 JavaScript 例如 ES5,不過從新語法轉譯成舊語法通常會導致程式碼的長度變長。
為什麼程式碼會變長呢?通常如果瀏覽器沒有支援新的語法,就得透過 polyfill 的方式為舊瀏覽器實現或模擬現有版本已實現之功能的程式碼片段
例如從 ES6 開始實現的 string.repeat() 這個函式,如果要在沒有支援 ES6 的瀏覽器實作這個功能,得透過以下冗長的 polyfill 來實現:
if (!String.prototype.repeat) {
String.prototype.repeat = function(count) {
'use strict';
if (this == null)
throw new TypeError('can\'t convert ' + this + ' to object');
var str = '' + this;
// To convert string to integer.
count = +count;
// Check NaN
if (count != count)
count = 0;
if (count < 0)
throw new RangeError('repeat count must be non-negative');
if (count == Infinity)
throw new RangeError('repeat count must be less than infinity');
count = Math.floor(count);
if (str.length == 0 || count == 0)
return '';
// Ensuring count is a 31-bit integer allows us to heavily optimize the
// main part. But anyway, most current (August 2014) browsers can't handle
// strings 1 << 28 chars or longer, so:
if (str.length * count >= 1 << 28)
throw new RangeError('repeat count must not overflow maximum string size');
var maxCount = str.length * count;
count = Math.floor(Math.log(count) / Math.log(2));
while (count) {
str += str;
count--;
}
str += str.substring(0, maxCount - str.length);
return str;
}
}
再來看看實際用 transpilier 轉譯新語法到舊語法的例子。一段 ES2017 的 class 語法透過 babel 轉譯回 ES5 版本的前後差異如下:
其實在複雜的專案下這樣的程式碼長度差異會對 bundle size 產生很大的影響。如果我們可以不追求要達到所有的瀏覽器都能支援的程度,就以現今擁有 95% 瀏覽器支援度的 ES2017 為轉譯的目標,應該可以減少一定程度的 bundle size。
Instagram 就曾針對它們的 web app 做了一個研究,如果把 transpile 的最低 target 設為 ES2017,相較於轉譯到 ES5 的版本,減少了 5.7% 左右的 bundle size,並且讓使用 modern browser 的 user 的 page speed 提升了 3%,以網站效能來說是非常可觀的進步。
其實是有機會捍衛他們的權利的!? 我們可以在打包時分成兩份 bundle,一份轉譯成 Modern JavaScript 例如 ES2017 的版本,讓使用 Modern Browser 的使用者載入,另一份 bundle 則轉譯成較舊的 ES5 版本,專門給使用舊版瀏覽器的使用者載入。
那要怎麼根據用戶的瀏覽器版本來決定要載入哪個版本的 JavaScript Bundle 呢?
<script type="module" src="modern_module.js"></script>
<script nomodule src="fallback.js"></script>
在設有 type="module" 的 script 帶入編譯成 ES2017 的 bundle,含有 nomodule 屬性的 script 帶入編譯成 ES5 版本的 bundle,如果是看得懂 type="module" 的瀏覽器就會載入這個 script 而自動忽略 nomodule 的 script,相對的如果是看不懂 type="module" 的舊型瀏覽器,就會忽略它並載入擁有 nomodule property 的 script 當作 fallback。
在發佈一個 npm package 的時候,通常會在 package.json 中加入
{
"main": "./index.js"
}
來指定這個 package 的 entry point。
不過其實還可以再設置一些進階的設定:
{
"name": "foo",
"exports": "./modern.js",
"main": "./legacy.cjs"
}
exports 中放入的是 Modern JavaScript 的版本,main 則是放入 fallback 的 legacy JavaScript 版本。
不過這還不是完全體,還存在一些優化的空間
{
"name": "foo",
"exports": "./modern.js",
"main": "./legacy.cjs",
"module": "./module.js"
}
這次多出了一個 module 字段,是不是覺得似曾相似啊...?沒錯,昨天在介紹 Tree Shaking 時有提過它,這個字段給的 bundle 通常會是非常類似 main 字段給的 legacy bundle,也就是說它仍然是一個沒有 modern syntax 的 legacy bundle,不過差別在於它使用了 import 與 export 的語法,這也代表著它可以做 Tree Shaking,在瀏覽器不支援 Modern JavaScript 的狀況下也可以盡量做到優化。
關於採用 Modern JavaScript 的 bundle 對網站效能的影響,Google Chrome 團隊推出了一個蠻有趣的專案 - Estimator.dev,我們可以在這個服務中貼上想要檢測的網址
它會透過 reverse transpiling 的方式,推論出如果這個網站都使用 Modern JavaScript,大約可以減少多少 JavaScript 的 bundle size,進而提升多少效能。
自己覺得蠻有趣的,推薦大家去體驗看看囉~
https://estimator.dev/
接下來進入本日的第二部份,檔案壓縮 File Compression。
檔案壓縮是一個可以非常簡單又高效的減少 Network Bandwidth 還有 Page Speed 的方法 (當然,壓縮演算法本身是複雜的),基本上現在的網站在傳輸資源時一定會做檔案的壓縮,讓我們馬上來一探究竟吧!
一般來說使用者對資源發出請求的流程大致上會是以下這樣
這個流程是可以 work 的,不過有點沒有效率,因為回傳的檔案大小有點大,如果是 html 檔,回傳所需要的時間越久,連帶會影響到要載入的 CSS 與 JS 或是其他靜態資源的載入時間,拖垮網頁整體的效能。
既然問題出在回傳的檔案大小太大,我們就試著來縮小它,這時候檔案壓縮的技巧就派上用場啦!使用壓縮後的流程會變成以下這樣
看起來順利解決了檔案大小太大的問題。
基本上每一種文件類型都會有浪費的儲存空間,可以透過壓縮來重新排列達到節省空間的優化。而圖片、影片、音訊等檔案格式透過壓縮可以減少的檔案大小又比文字檔案(text file)來的更多更明顯。
還記得在 Day06 圖片最佳化的時候有提到圖片的壓縮嗎?基本上用於文件的壓縮演算法可以分為兩種:
有損壓縮
:在壓縮與解壓縮的過程中,會對原本的數據進行修改,但是會以使用者無法察覺的方式進行。例如 jpeg 的壓縮就是採用這種方式。我們也可以控制有損壓縮的程度,一般來說壓縮越多,對於原本文件品質的影響就越大。為了使我們的網站能取得效能與品質的平衡,理想狀況是維持可以接受的品質水準的前提下,盡可能提高壓縮的比率。
無損壓縮
:在壓縮與解壓縮的過程中,不會對要壓縮的數據進行修改,也就是壓縮後的數據與原來的數據是一致的(bit 與 bit 間的對應不會改變),gif 與 png 就是採用這種無損壓縮的演算法。
而有損壓縮演算法的效率通常會比無損壓縮還要高一點,通常這兩種演算法如果運用在已經經過壓縮的檔案上不但檔案大小不一定會更小,還有機會適得其反讓檔案大小變得更大(跟壓縮演算法需要用到的資料結構有關),這點也在 Day06 有稍微提過,已經壓縮過的 png 與 jpeg 通常不會再經過 gzip 壓縮就是因為這個原因。
比較各種壓縮方式,End To End Compression 是在 Web 應用中對效能提升最有幫助的方法,也就是上方 HTTP Request & Response With Compression 圖片中的方法。
所謂 end to end compression 指的是由 client side 發起壓縮請求,在 server side 完成壓縮,直到回傳給 client side 才會由 client 解壓縮得到原始檔案,中間不管經過幾個 proxy node 都不會改變壓縮後的 response body。
基本上現代瀏覽器與 web 伺服器都已經支援了這項技術,差別在於支援了哪些壓縮的演算法,目前最廣為應用的壓縮演算法廣為人知的有 gzip 還有身為後起之秀並號稱效能比 gzip 還要好的 brotli。
要做到 End To End Compression,需要靠瀏覽器與伺服器之間的協商。首先瀏覽器在發出請求時在 request header 帶上 Accept-Encoding 這個 header,並給出瀏覽器本身有支援的壓縮演算法(因為瀏覽器必須自己進行檔案的解壓縮)。
# http header
# 演算法列的先後順序也代表期望的優先層級,以下代表希望優先使用 brotil,如果伺服器不支援再用 gzip
Accept-Encoding: br, gzip
伺服器在收到這個 header 後,按照瀏覽器提供的優先級選擇一種壓縮演算法對 response body 進行壓縮,並在 response header 中帶入 Content-Encoding 這個 header 告訴瀏覽器它最後選擇了哪種壓縮演算法,讓瀏覽器知道如何正確解壓縮。在 response header 中,還可以加入 Vary 的 header,並且帶入 Accept-Encoding,如此一來,瀏覽器就可以針對經過不同壓縮演算法的文件分別進行快取。
End To End Compression 這個技術可以對網站的效能帶來很大的優化,一般會建議除了剛剛提到的已經經過壓縮的檔案格式(例如 PNG、JPEG 圖片)以外,其餘檔案都可以透過這種壓縮方式來達到效能的提升。
要實現檔案壓縮可以在 application server 這一端實現,例如前端開發者較熟悉的 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']) {
// don't compress responses with this request header
return false
}
// fallback to standard filter function
return compression.filter(req, res)
}
不過也有一種說法認為壓縮是一個極其耗費資源的行為,在 production 環境中面對大流量的情況下,對特性為 single thread 的 Node.js 來說會是一種負擔,因此會建議把壓縮拉到 reverse proxy 層級實作,例如壓縮的任務其實可以交給 Nginx Web Server 去完成:
server {
...
...
gzip on;
gzip_types text/plain application/xml application/json;
gzip_comp_level 9;
gzip_min_length 1000;
...
...
}
有興趣的讀者可以參考 nginx 的 ngx_http_gzip_module 或是 brotli 演算法的 module。
如果你不是自己架伺服器來部署網站,而是使用一些第三方服務,通常它們都會幫我們做好檔案的壓縮,可以到 devtool 的 network tab 找找資源的 response header 有沒有剛剛提到的 Content-Encoding header,使用的又是哪種壓縮演算法。
Build Optimizations
篇章在今天告一個段落了,今天說明的 Transitioning To Modern JavaScript 與 File Compression 都是很簡單實用就可以大幅減少檔案大小來提升網頁效能的方法。
明天之後的章節我認為有些主題可能會讓讀者有一些疑惑:「這些真的是一般前端工程師要懂的概念嗎?」的確,有些主題例如 HTTP2 Networking 的概念可能需要後端的配合才能達成,但我認為要成為一個頂尖的 Web 開發者,不管是前端後端還是全端,都應該對 Web 整體架構有一定的了解。這次的前端效能優化系列文的主題規劃也是以一個「我理想中的頂尖前端開發者應該要擁有的技能樹」去規劃的,況且前端能做的事越來越多也越來越複雜了,想到就讓我熱血沸騰了,一起繼續探索後面的主題吧!明天見~
https://www.youtube.com/watch?v=cLxNdLK--yI&t=631s
https://web.dev/publish-modern-javascript/
https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Compression