瀏覽器已原生支持 ES 模塊,這對前端開發來講意味着什麼?

2017 年,主流瀏覽器陸續開始原生支持 ES2015 模塊,這意味着——是時候從新學習 script 標籤了。以及,我保證這毫不是又一篇只講 ES module 語法不談實踐的「月經」文。javascript

還記得當初入門前端開發的時候寫過的 Hello World 麼?一開始咱們先建立了一個 HTML 文件,在 <body> 標籤裏寫上網頁內容;後來須要學習頁面交互邏輯時,在 HTML markup 裏增長一個 <script src="script.js"> 標籤引入外部 script.js 代碼,script.js 負責頁面交互邏輯。html

隨着前端社區 JavaScript 模塊化的發展,咱們如今的習慣是拆分 JS 代碼模塊後使用 Webpack 打包爲一個 bundle.js 文件,再在 HTML 中使用 <script src="bundle.js"> 標籤引入打包後的 JS。這意味着咱們的前端開發工做流從「石器時代」跨越到了「工業時代」,可是對瀏覽器來講並無質的改變,它所加載的代碼依然一個 bundle.js ,與咱們在 Hello World 時加載腳本的方式沒什麼兩樣。前端

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

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

請聽題:node

Q:有兩個 script 元素,一個從 CDN 加載 lodash,另外一個從本地加載 script.js,假設老是本地腳本下載更快,那麼如下 plain.html、async.html 和 defer.html 分別輸出什麼?webpack

// 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>
複製代碼

若是你知道答案,恭喜你能夠跳過這一節了,不然就要複習一下了。git

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

4.17.10
NO
複製代碼

也就是說 script.js 在執行時,lodash 已下載並執行完畢,但 document.body 還沒有加載。github

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

  • 若是 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 腳本的加載方式的差別,淺藍色爲腳本下載階段,黃色爲腳本執行階段。

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 會發現頁面內容以下:

能夠從 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 報錯。

(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

#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 腳本。

#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 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 與其餘常見新特性支持度對比表格,能夠幫咱們瞭解個大概。

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

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)。

#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

相關文章
相關標籤/搜索