瀏覽器已原生支援 ES 模組,這對前端開發來說意味著什麼?

rianma發表於2018-07-09

2017 年,主流瀏覽器陸續開始原生支援 ES2015 模組,這意味著——是時候重新學習 script 標籤了。以及,我保證這絕不是又一篇只講 ES module 語法不談實踐的“月經”文。

還記得當初入門前端開發的時候寫過的 Hello World 麼?一開始我們先建立了一個 HTML 檔案,在 <body> 標籤裡寫上網頁內容;後來需要學習頁面互動邏輯時,在 HTML markup 裡增加一個 <script src="script.js"> 標籤引入外部 script.js 程式碼,script.js 負責頁面互動邏輯。

隨著前端社群 JavaScript 模組化的發展,我們現在的習慣是拆分 JS 程式碼模組後使用 Webpack 打包為一個 bundle.js 檔案,再在 HTML 中使用 <script src="bundle.js"> 標籤引入打包後的 JS。這意味著我們的前端開發工作流從“石器時代”跨越到了“工業時代”,但是對瀏覽器來說並沒有質的改變,它所載入的程式碼依然一個 bundle.js ,與我們在 Hello World 時載入指令碼的方式沒什麼兩樣。

——直到瀏覽器對 ES Module 標準的原生支援,改變了這種情況。目前大多數瀏覽器已經支援通過 <script type="module"> 的方式載入標準的 ES 模組,正是時候讓我們重新學習 script 相關的知識點了。

複習:defer 和 async 傻傻分不清楚?

請聽題:

Q:有兩個 script 元素,一個從 CDN 載入 lodash,另一個從本地載入 script.js,假設總是本地指令碼下載更快,那麼以下 plain.html、async.html 和 defer.html 分別輸出什麼?

// script.js
try {
    console.log(_.VERSION);
} catch (error) {
    console.log('Lodash Not Available');
}
console.log(document.body ? 'YES' : 'NO');
複製程式碼
// A. plain.html
<head>
	<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js"></script>
    <script src="script.js"></script>
</head>

// B. async.html
<head>
	<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js" async></script>
    <script src="script.js" async></script>
</head>

// C. defer.html
<head>
	<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js" defer></script>
    <script src="script.js" defer></script>
</head>
複製程式碼

如果你知道答案,恭喜你可以跳過這一節了,否則就要複習一下了。

首先 A. plain.html 的輸出是:

4.17.10
NO
複製程式碼

也就是說 script.js 在執行時,lodash 已下載並執行完畢,但 document.body 尚未載入。

在 defer 和 async 屬性誕生之前,最初瀏覽器載入指令碼是採用同步模型的。瀏覽器解析器在自上而下解析 HTML 標籤,遇到 script 標籤時會暫停對文件其它標籤的解析而讀取 script 標籤。此時:

  • 如果 script 標籤無 src 屬性,為內聯指令碼,解析器會直接讀取標籤的 textContent,由 JS 直譯器執行 JS 程式碼
  • 如果 script 有 src 屬性,則從 src 指定的 URI 發起網路請求下載指令碼,然後由 JS 直譯器執行

無論哪種情況,都會阻塞瀏覽器的解析器,剛剛說到瀏覽器是自上而下解析 HTML Markup 的,所以這個阻塞的特性就決定了,script 標籤中的指令碼執行時,位於該 script 標籤以上的 DOM 元素是可用的,位於其以下的 DOM 元素不可用。

如果我們的指令碼的執行需要操作前面的 DOM 元素,並且後面的 DOM 元素的載入和渲染依賴該指令碼的執行結果,這樣的阻塞是有意義的。但如果情況相反,那麼指令碼的執行只會拖慢頁面的渲染。

正因如此,2006 年的《Yahoo 網站優化建議》中有一個著名的規則:

把指令碼放在 body 底部

但現代瀏覽器早已支援給 <script> 標籤加上 defer 或 async 屬性,二者的共同點是都不會阻塞 HTML 解析器。

當文件只有一個 script 標籤時,defer 與 async 並沒有顯著差異。但當有多個 script 標籤時,二者表現不同:

  • async 指令碼每個都會在下載完成後立即執行,無關 script 標籤出現的順序
  • defer 指令碼會根據 script 標籤順序先後執行

所以以上問題中,後兩種情況分別輸出:

// B. async.html
Lodash Not Available
YES

// C. defer.html
4.17.10
YES
複製程式碼

因為 async.html 中 script.js 體積更小下載更快,所以執行時間也比從 CDN 載入的 lodash 更早,所以 _.VERSION 上不可用,輸出 Lodash Not Available;而 defer.html 中的 script.js 下載完畢後並不立即執行,而是在 lodash 下載和執行之後才執行。

以下這張圖片可以直觀地看出 Default、defer、async 三種不同 script 指令碼的載入方式的差異,淺藍色為指令碼下載階段,黃色為指令碼執行階段。

瀏覽器已原生支援 ES 模組,這對前端開發來說意味著什麼?

One more thing...

上文只分析了包含 src 屬性的 script 標籤,也就是需要發起網路請求從外部載入指令碼的情況,那麼當內聯 <script> 標籤遇到 async 和 defer 屬性時又如何呢?

答案就是簡單的不支援,把 async 和 defer 屬性用以下這種方式寫到 script 標籤中沒有任何效果,意味著內聯的 JS 指令碼一定是同步阻塞執行的。

// defer attribute is useless
<script defer>
    console.log(_.VERSION)
</script>

// async attribute is useless
<script async>
    console.log(_.VERSION)
</script>
複製程式碼

這一點之所以值得單獨拎出來講,是因為稍後我們會發現瀏覽器處理 ES Module 時與常規 script 相反,預設情況下是非同步不阻塞的。

改變遊戲規則的 <script type=module>

TLDR;

  • 給 script 標籤新增 type=module 屬性,就可以讓瀏覽器以 ES Module 的方式載入指令碼
  • type=module 標籤既支援內聯指令碼,也支援載入指令碼
  • 預設情況下 ES 指令碼是 defer 的,無論內聯還是外聯
  • 給 script 標籤顯式指定 async 屬性,可以覆蓋預設的 defer 行為
  • 同一模組僅執行一次
  • 遠端 script 根據 URL 作為判斷唯一性的 Key
  • 安全策略更嚴格,非同域指令碼的載入受 CORS 策略限制
  • 伺服器端提供 ES Module 資源時,必須返回有效的屬於 JavaScript 型別的 Content-Type 頭

#1 ES Module 101

匯入與匯出

ES 標準的模組使用 importexport 實現模組匯入和匯出。

export 可以匯出任意可用的 JavaScript 識別符號(idendifier),顯式的匯出方式包括宣告(declaration)語句和 export { idendifier as name } 兩種方式。

// lib/math.js
export function sum(x, y) {
    return x + y;
}
export let pi = 3.141593;
export const epsilon = Number.EPSILON;
export { pi as PI };
複製程式碼

在另一個檔案中,使用 import ... from ... 可以匯入其他模組 export 的識別符號,常用的使用方式包括:

  • import * as math from ... 匯入整個模組,並通過 math 名稱空間呼叫
  • import { pi, epsilon } from ... 部分匯入,可直接呼叫 pi, epsilon 等變數
// app.js
import * as math from './lib/math.js';
import { pi, PI, epsilon } from './lib/math.js';
console.log(`2π = ${math.sum(math.pi, math.pi)}`);
console.log(`epsilon = ${epsilon}`);
console.log(`PI = ${PI}`);
複製程式碼

default

ES 模組支援 default 關鍵詞實現無命名的匯入,神奇的點在於它可以與其他顯式 export 的變數同時匯入。

// lib/math.js
export function sum(x, y) {
    return x + y;
}
export default 123;
複製程式碼

對於這種模組,匯入該模組有兩種方式,第一種為預設匯入 default 值。

import oneTwoThree from './lib/math.js';
// 此時 oneTwoThree 為 123
複製程式碼

第二種為 import * 方式匯入 default 與其他變數。

import * as allDeps from './lib/math.js'
// 此時 allDeps 是一個包含了 sum 和 default 的物件,allDeps.default 為 123
// { sum: ..., default: 123}
複製程式碼

語法限制

ES 模組規範要求 import 和 export 必須寫在指令碼檔案的最頂層,這是因為它與 CommonJS 中的 module.exports 不同,export 和 import 並不是傳統的 JavaScript 語句(statement)。

  • 不能像 CommonJS 一樣將匯出程式碼寫在條件程式碼塊中

    // ./lib/logger.js
    
    // 正確
    const isError = true;
    let logFunc;
    if (isError) {
        logFunc = (message) => console.log(`%c${message}`, 'color: red');
    } else {
        logFunc = (message) => console.log(`%c${message}`, 'color: green');
    }
    export { logFunc as log };
    
    const isError = true;
    const greenLog = (message) => console.log(`%c${message}`, 'color: green');
    const redLog = (message) => console.log(`%c${message}`, 'color: red');
    // 錯誤!
    if (isError) {
        export const log = redLog;
    } else {
        export const log = greenLog;
    }
    複製程式碼
  • 不能把 import 和 export 放在 try catch 語句中

    // 錯誤!
    try {
        import * as logger from './lib/logger.js';
    } catch (e) {
        console.log(e);
    }
    複製程式碼

另外 ES 模組規範中 import 的路徑必須是有效的相對路徑、或絕對路徑(URI),並且不支援使用表示式作為 URI 路徑。

// 錯誤:不支援類 npm 的“模組名” 匯入
import * from 'lodash'

// 錯誤:必須為純字串表示,不支援表示式形式的動態匯入
import * from './lib/' + vendor + '.js'
複製程式碼

#2 來認識一下 type=module

以上是 ES 標準模組的基礎知識,這玩意屬於標準先行,實現滯後,瀏覽器支援沒有馬上跟上。但正如本文一開始所說,好訊息目前業界最新的幾個主流瀏覽器 Chrome、Firefox、Safari、Microsoft Edge 都已經支援了,我們要學習的就是 <script> 標籤的新屬性:type=module。

只要在常規 <script> 標籤裡,加上 type=module 屬性,瀏覽器就會將這個指令碼視為 ES 標準模組,並以模組的方式去載入、執行。

一個簡單的 Hello World 是這樣子的:

<!-- type-module.html -->
<html>
    <head>
        <script type=module src="./app.js"></script>
    </head>
    <body>
    </body>
</html>
複製程式碼
// ./lib/math.js
const PI = 3.14159;
export { PI as PI };

// app.js
function sum (a, b) {
    return a + b;
}
import * as math from './lib/math.js';
document.body.innerHTML = `PI = ${math.PI}`;
複製程式碼

開啟 index.html 會發現頁面內容如下:

瀏覽器已原生支援 ES 模組,這對前端開發來說意味著什麼?

可以從 Network 皮膚中看到資源請求過程,瀏覽器從 script.src 載入 app.js,在 Initiator 可以看到,app.js:1 發起了 math.js 的請求,即執行到 app.js 第一行 import 語句時去載入依賴模組 math.js。

模組指令碼中 JavaScript 語句的執行與常規 script 所載入的指令碼一樣,可以使用 DOM API,BOM API 等介面,但有一個值得注意的知識點是,作為模組載入的指令碼不會像普通的 script 指令碼一樣汙染全域性作用域

例如我們的程式碼中 app.js 定義了函式 sum,math.js 定義了常量 PI,如果開啟 Console 輸入 PI 或 sum 瀏覽器會產生 ReferenceError 報錯。

瀏覽器已原生支援 ES 模組,這對前端開發來說意味著什麼?

(Finally...)

#3 type=module 模組支援內聯

在我們以上的示例程式碼中,如果把 type-module.html 中引用的 app.js 程式碼改為內聯 JavaScript,效果是一樣的。

<!-- type-module.html -->
<html>
    <head>
        <script type=module>
            import * as math from './lib/math.js';
	        document.body.innerHTML = `PI = ${math.PI}`;
        </script>
    </head>
    <body>
    </body>
</html>
複製程式碼

當然內聯的模組指令碼只在作為 “入口” 指令碼載入時有意義,這樣做可以免去一次下載 app.js 的 HTTP 請求,此時 import 語句所引用的 math.js 路徑自然也需要修改為相對於 type-module.html 的路徑。

#4 預設 defer,支援 async

細心的你可能注意到了,我們的 Hello World 示例中 script 標籤寫在 head 標籤中,其中用到了 document.body.innerHTML 的 API 去操作 body,但無論是從外部載入指令碼,還是內聯在 script 標籤中,瀏覽器都可以正常執行沒有報錯。

這是因為 <script type=module> 預設擁有類似 defer 的行為,所以指令碼的執行不會阻塞頁面渲染,因此會等待 document.body 可用時執行。

之所以說 類似 defer 而非確定,是因為我在瀏覽器 Console 中嘗試檢查預設 script 元素的 defer 屬性(執行 script.defer),得到的結果是 false 而非 true。

這就意味著如果有多個 <script type=module> 指令碼,瀏覽器下載完成指令碼之後不一定會立即執行,而是按照引入順序先後執行。

另外,與傳統 script 標籤類似,我們可以在 <script> 標籤上寫入 async 屬性,從而使瀏覽器按照 async 的方式載入模組——下載完成後立即執行

#5 同一模組執行一次

ES 模組被多次引用時只會執行一次,我們執行多次 import 語句獲取到的內容是一樣的。對於 HTML 中的 <script> 標籤來說也一樣,兩個 script 標籤先後匯入同一個模組,只會執行一次。

例如以下指令碼讀取 count 值並加一:

// app.js
const el = document.getElementById('count');
const count = parseInt(el.innerHTML.trim(), 10);
el.innerHTML = count + 1;
複製程式碼

如果重複引入 <script src="app.js"> 只會執行一次 app.js 指令碼,頁面顯示 count: 1

<!-- type-module.html -->
<html>
    <head>
        <script type=module src="app.js"></script>
        <script type=module src="app.js"></script>
    </head>
    <body>
        count: <span id="count">0</span>
    </body>
</html>
複製程式碼

問題來了?如何定義“同一個模組”呢,答案是相同的 URL,不僅包括 pathname 也包括 ? 開始的引數字串,所以如果我們給同一個指令碼加上不同的引數,瀏覽器會認為這是兩個不同的模組,從而會執行兩次。

如果將上面 HTML 程式碼中第二個 app.js 加上 url 引數:

<script type=module src="app.js"></script>
<script type=module src="app.js?foo=bar"></script>
複製程式碼

瀏覽器會執行兩次 app.js 指令碼,頁面顯示 count: 2

瀏覽器已原生支援 ES 模組,這對前端開發來說意味著什麼?

#6 CORS 跨域限制

我們知道常規的 script 標籤有一個重要的特性是不受 CORS 限制,script.src 可以是任何非同域的指令碼資源。正因此,我們早些年間利用這個特性“發明”了 JSONP 的方案來實現“跨域”。

但是 type=module 的 script 標籤加強了這方面的安全策略,瀏覽器載入不同域的指令碼資源時,如果伺服器未返回有效的 Allow-Origin 相關 CORS 頭,瀏覽器會禁止載入改指令碼。

如下 HTML 通過 5501 埠 serve,而去載入 8082 埠的 app.js 指令碼:

<!-- http://localhost:5501/type-module.html -->
<html>
    <head>
        <script type=module src="http://localhost:8082/app.js"></script>
    </head>
    <body>
        count: <span id="count">0</span>
    </body>
</html>
複製程式碼

瀏覽器會禁止載入這個 app.js 指令碼。

瀏覽器已原生支援 ES 模組,這對前端開發來說意味著什麼?

#7 MIME 型別

瀏覽器請求遠端資源時,可以根據 HTTP 返回頭中的 Content-Type 確定所載入資源的 MIME 型別(指令碼、HTML、圖片格式等)。

因為瀏覽器一直以來的寬容特性,對於常規的 script 標籤來說,即使伺服器端未返回 Content-Type 頭指定指令碼型別為 JavaScript,瀏覽器預設也會將指令碼作為 JavaScript 解析和執行。

但對於 type=module 型別的 script 標籤,瀏覽器不再寬容。如果伺服器端對遠端指令碼的 MIME 型別不屬於有效的 JavaScript 型別,瀏覽器會禁止執行該指令碼。

用事實說話:如果我們把 app.js 重新命名為 app.xyz,會發現頁面會禁止執行這個指令碼。因為在 Network 皮膚中可以看到瀏覽器返回的 Content-Type 頭為 chemical/x-xyz,而非有效的 JavaScript 型別如:text/javascript

<html>
<head>
    <script type="module" src="app.xyz"></script>
</head>
<body>
    count: <span id="count">0</span>
</body>
</html>
複製程式碼

頁面內容依然是 count: 0,數值未被修改,可以在控制檯和 Network 看到相關資訊:

瀏覽器已原生支援 ES 模組,這對前端開發來說意味著什麼?

真實世界裡的 ES Module 實踐

向後相容方案

OK 現在來聊現實——舊版本瀏覽器的相容性問題,瀏覽器在處理 ES 模組時有非常巧妙的相容性方案。

首先在舊版瀏覽器中,在 HTML markup 的解析階段遇到 <script type="module"> 標籤,瀏覽器認為這是自己不能支援的指令碼格式,會直接忽略掉該標籤;出於瀏覽器的寬恕性特點,並不會報錯,而是靜默地繼續向下解析 HTML 餘下的部分。

所以針對舊版瀏覽器,我們還是需要新增一個傳統的 <script> 標籤載入 JS 指令碼,以實現向後相容。

其次,而這種用於向後相容的第二個 <script> 標籤需要被新瀏覽器忽略,避免新瀏覽器重複執行同樣的業務邏輯。

為了解決這個問題,script 標籤新增了一個 nomodule 屬性。已支援 type=module 的瀏覽器版本,應當忽略帶有 nomodule 屬性的 script 標籤,而舊版瀏覽器因為不認識該屬性,所以它是無意義的,不會干擾瀏覽器以正常的邏輯去載入 script 標籤中的指令碼。

<script type="module" src="app.js"></script>
<script nomodule src="fallback.js"></script>
複製程式碼

如上程式碼所示,新版瀏覽器載入第一個 script 標籤,忽略第二個;舊版不支援 type=module 的瀏覽器則忽略第一個,載入第二個。

相當優雅對不對?不需要自己手寫特性檢測的 JS 程式碼,直接使用 script 的屬性即可。

正因如此,進一步思考,我們可以大膽地得出這樣的結論:

不特性檢驗,我們可以立即在生產環境中使用 <script type=module>

帶來的益處

聊到這裡,我們是時候來思考瀏覽器原生支援 ES 模組能給我們帶來的實際好處了。

#1 簡化開發工作流

在前端工程化大行其道的今天,前端模組化開發已經是標配工作流。但是瀏覽器不支援 type=module 載入 ES 模板時,我們還是離不開 webpack 為核心的打包工具將本地模組化程式碼打包成 bundle 再載入。

但由於最新瀏覽器對 <script type=module> 的天然支援,理論上我們的本地開發流可以完全脫離 webpack 這類 JS 打包工具了,只需要這樣做:

  1. 直接將 entry.js 檔案使用 <script> 標籤引用
  2. 從 entry.js 到所有依賴的模組程式碼,全部採用 ES Module 方案實現

當然,之所以說是理論上,是因為第 1 點很容易做到,第 2 點要求我們所有依賴程式碼都用 ES 模組化方案,在目前前端工程化生態圈中,我們的依賴管理是採用 npm 的,而 npm 包大部分是採用 CommonJS 標準而未相容 ES 標準的。

但毋庸置疑,只要能滿足以上 2 點,本地開發可以輕鬆實現真正的模組化,這對我們的除錯體驗是相當大的改善,webpack --watch、source map 什麼的見鬼去吧。

現在你開啟 devtools 裡的 Source 皮膚就可以直接打斷點了朋友!Just debug it!

#2 作為檢查新特性支援度的水位線

ES 模組可以作為一個天然的、非常靠譜的瀏覽器版本檢驗器,從而在檢查其他很多新特性的支援度時,起到水位線 的作用。

這裡的邏輯其實非常簡單,我們可以使用 caniuse 查到瀏覽器對 <script type="module"> 的支援狀況,很顯然對瀏覽器版本要求很高。

~> caniuse typemodule
JavaScript modules via script tag ✔ 70.94% ◒ 0.99% [WHATWG Living Standard]
  Loading JavaScript module scripts using `<script type="module">` Includes support for the `nomodule` attribute. #JS

  IE ✘
  Edge ✘ 12+ ✘ 15+¹ ✔ 16+
  Firefox ✘ 2+ ✘ 54+² ✔ 60+
  Chrome ✘ 4+ ✘ 60+¹ ✔ 61+
  Safari ✘ 3.1+ ◒ 10.1+⁴ ✔ 11+
  Opera ✘ 9+ ✘ 47+¹ ✔ 48+

    ¹Support can be enabled via `about:flags`
    ²Support can be enabled via `about:config`
    ⁴Does not support the `nomodule` attribute
複製程式碼

PS: 推薦一個 npm 工具:caniuse-cmd,呼叫 npm i -g caniuse-cmd 即可使用命令列快速查詢 caniuse,支援模糊搜尋哦

這意味著,如果一個瀏覽器支援載入 ES 模組,其版本號一定大於以上表格中指定的這些版本。

以 Chrome 為例,進一步思考,這也就意味著我們在 ES 模板的程式碼中可以脫離 polyfill 使用所有 Chrome 61 支援的特性。這個列表包含了相當豐富的特性,其中有很多是我們在生產環境中不敢直接使用的,但有了 <script type=module> 的保證,什麼 Service Worker,Promise,Cache API,Fetch API 等都可以大膽地往上懟了。

這裡是一張來自 Google 工程師 Sam Thorogood 在 Polymer Summit 2017 上的分享 ES6 Modules in the Real World 的 slides 截圖,大致描述了當時幾款主要瀏覽器對 type=module 與其他常見新特性支援度對比表格,可以幫我們瞭解個大概。

瀏覽器已原生支援 ES 模組,這對前端開發來說意味著什麼?

面臨的挑戰——重新思考前端構建

OK 現在是時候再來思考不那麼好玩的部分了,軟體開發沒有銀彈,今天我們討論的 ES 模板也不例外。來看如果讓瀏覽器原生引入 ES 模板可能帶來的新的問題,以及給我們帶來的新的挑戰。

#1 請求數量增加

對於已支援 ES 模板的瀏覽器,如果我們從 script 標籤開始引入 ES Module,就自然會面臨這樣的問題。假設我們有這樣的依賴鏈,就意味著瀏覽器要先後載入 6 個模組:

entry.js
├──> logger.js -> util.js -> lodash.js
├──> constants.js
└──> event.js -> constants.js
複製程式碼

對於傳統的 HTTP 站點,這就意味著要傳送 6 個獨立的 HTTP 請求,與我們常規的效能優化實踐背道而馳。

因此這裡的矛盾點實際是減少 HTTP 請求數提高模組複用程度之間的矛盾:

  • 模組化開發模式下,隨著程式碼自然增長會有越來越多模組
  • 模組越多,瀏覽器要發起的請求數也就越多

面對這個矛盾,需要我們結合業務特點思考優化策略,做出折衷的決策或妥協。

一個值得思考的方向是藉助 HTTP 2 技術進行模組載入的優化。

藉助 Server Push 技術,可以選出應用中複用次數最多的公用模組,儘可能提早將這些模組 push 到瀏覽器端。例如在請求 HTML 時,伺服器使用同一個連線將以上示例中的 util.js、lodash.js、constants.js 模組與 HTML 文件一併 push 到瀏覽器端,這樣瀏覽器在需要載入這些模組時,可以免去再次主動發起請求的過程,直接執行。

PS: 強烈推薦閱讀 Jake Archibald 大神的文章:HTTP/2 push is tougher than I thought

藉助 HTTP/2 的合併請求和頭部壓縮功能,也可以改善請求數增加導致的載入變慢問題。

當然使用 HTTP/2 就對我們的後端 HTTP 服務提供方提出了挑戰,當然這也可以作為一個契機,推動我們學習和應用 HTTP/2 協議。

PS:其他文章也有討論使用prefetch 快取機制來進行資源載入的優化,可以作為一個方向進一步探索

#2 警惕依賴地獄——版本與快取管理

軟體工程有一個著名的笑話:

There are only two hard things in Computer Science: cache invalidation and naming things.

-- Phil Karlton

可見快取的管理是一件不應當被小看的事。

傳統上我們如何進行 JS 指令碼的版本控制部署呢?結合 HTTP 快取機制,一般的最佳實踐是這樣的:

  • 檔案命名加上版本號
  • 設定 max-age 長快取
  • 有版本更新時,修改檔名中的版本號部分,修改 script.src 路徑

如果我們每次只引入一兩個穩定的 *.js 庫指令碼,再引入業務指令碼 bundle.xxx.js,這樣的實踐可以說問題不大。

但設想我們現在要直接向新版瀏覽器 ship ES 模組了,隨著業務的發展,我們要管理的就是十幾個甚至幾十個依賴模組了,對於一個大型的站點來說,幾十個頁面,擁有幾百個模組也一點不意外。

依賴圖譜這麼複雜,模組數量這麼多的情況下,JS 檔案的快取管理和版本更新還那麼容易做麼?

例如我們有這樣的依賴圖譜:

./page-one/entry.js
├──> logger.js -> util.js -> lodash.js
├──> constants.js
├──> router.js -> util.js
└──> event.js -> util.js

./page-two/entry.js
├──> logger.js -> util.js -> lodash.js
└──> router.js -> constants.js
複製程式碼

現在我們修改了一個公用元件 util.js,在生產環境,瀏覽器端存有舊版的 util-1.0.0.js 的長快取,但由於 logger、router、event 元件都依賴 util 元件,這就意味著我們在生成 util-1.1.0.js 版本時,要相應修改其他元件中的 import 語句,也要修改 HTML 中的 <script> 標籤。

// router-2.0.0.js -> router-2.1.0.js
import * as util from './util-1.1.0.js'

// page-one/entry-3.1.2.js -> page-one/entry-3.2.0.js
import * as util from './util-1.1.0.js'

// page-one.html
<script type="module" src="./page-one/entry-3.2.0.js">

// ... page-two 相關指令碼也要一起修改
複製程式碼

這些依賴元件的版本號,沿著這個依賴圖譜一直向上追溯,我們要一修改、重構。這個過程當然可以結合我們的構建工具實現,免去手動修改,需要我們開發構建工具外掛或使用 npm scripts 指令碼實現。

#3 必須保持向後相容

在上文我們已經提到這點,在實踐中我們務必要記得在部署到生產環境時,依然要打包一份舊版瀏覽器可用的 bundle.js 檔案,這一步是已有工作流,只需要給 script 標籤加一個 nomodule 屬性即可。

那麼問題來了,有時候為了儘可能減少頁面發起請求的數量,我們會將關鍵 JS 指令碼直接內聯到 HTML markup 中,相比 <script src=...> 引入外部指令碼的方式,再次減少了一次請求。

如果我們採用 <nomodule> 屬性的 script 標籤,會被新版瀏覽器忽略,所以對於新版瀏覽器來說,這裡 nomodule 指令碼內容最好不要內聯,否則徒增檔案體積,卻不會執行這部分指令碼,why bother?

所以這裡 <script nomodule> 指令碼是內聯還是外聯,依然要由開發者來做決策。

#4 升級 CommonJS 模組為 ES 標準模組

如果我們在生產環境使用 script 標籤引入了 ES 標準模組,那麼一定地,我們要把所有作為依賴模組、依賴庫的程式碼都重構為 ES 模組的形式,而目前,前端生態的現狀是:

  • 大部分依賴庫模組都相容 CommonJS 標準,少數才相容 ES 標準。
  • 依賴包部署在 npm 上,安裝在 node_modules 目錄中。
  • 已有的業務程式碼採用 require(${npm模組名}) 方式引用 node_modules 中的 package。

給我們帶來的挑戰是:

  • 需重構大量 CommonJS 模組為 ES 標準模組,工作量大。
  • 需重構 node_modules 的引用方式,使用相對路徑方式引用。

#5 別忘了壓縮 ES 模組檔案

生產環境部署傳統 JS 靜態資源的另一個重要的優化實踐是 minify 處理程式碼,以減小檔案體積,因為毋庸置疑檔案越小傳輸越快。

而如果我們要向新版瀏覽器 ship 原生 ES 模組,也不可忽略壓縮 ES 模組檔案這一點。

OK 我們想到處理 ES5 程式碼時常用的大名鼎鼎的 uglify 了,不幸的是 uglify 對 ES6 程式碼的 minify 支援度並不樂觀。目前 uglify 常用的場景,是我們先使用 babel 轉義 ES6 程式碼得到 ES5 程式碼,再使用 uglify 去 minify ES5 程式碼。

要壓縮 ES6 程式碼,更好的選擇是來自 babel 團隊的 babel-minify (原名 Babili)。

瀏覽器已原生支援 ES 模組,這對前端開發來說意味著什麼?

#6 結論?

大神說寫文章要有結論,聊到現在,我們驚喜地發現問題比好處的篇幅多得多(我有什麼辦法,我也很無奈啊)。

所以我對瀏覽器載入 ES 模組的態度是:

  • 開發階段,只要瀏覽器支援,儘管激進地使用!Just do it!
  • 不要丟掉 webpack 本地構建 bundle 那一套,本地構建依然是並將長期作為前端工程化的核心
  • 即使生產環境直接 serve 原生模組,也一樣需要構建流程
  • 生產環境不要盲目使用,首先要設計出良好的依賴管理和快取更新方案,並且部署好後端 HTTP/2 支援

ES 模組的未來?

有一說一,目前我們目前要在生產環境中擁抱 ES 模組,面臨的挑戰還不少,要讓原生 ES Module 發揮其最大作用還需要很多細節上的優化,也需要踩過坑,方能沉澱出最佳實踐。還是那句話——沒有銀彈。

但是在前端模組化這一領域,ES 模組毫無疑問代表著未來。

EcmaScript 標準委員會 TC39 也一直在推進模組標準的更新,關注標準發展的同學可以進一步去探索,一些值得提及的點包括:

  • tc39/proposal-dynamic-import 動態匯入特性支援,已進入 Stage 3 階段
  • tc39/proposal-import-meta 指定 import.meta 可以程式設計的方式,在程式碼中獲取模組相關的元資訊
  • tc39/tc39-module-keys 用於第三方模組引用時,進行安全性方面的強化,現處於 Stage 1 階段
  • tc39/proposal-modules-pragma 類似 "user strict" 指令指明嚴格模式,使用 "use module" 指令來指定一個常規的檔案以模組模式載入,現處於 Stage 1 階段
  • tc39/proposal-module-get 類似 Object.defineProperty 定義某一個屬性的 getter,允許 export get prop() { return ... } 這種語法實現動態匯出

參考資源

注:題圖來自 Contentful

相關文章