原文: Using JavaScript modules on the web
如今 全部主流現代瀏覽器都已經支持 JavaScript 模塊。本文將介紹如何使用 JS 模塊,如何有效地部署,以及 Chrome 團隊如何使 JS 模塊在將來變得更好用。javascript
JS 模塊(也稱爲「ES 模塊」或「ECMAScript模塊」)是 ES6 中一項很是重要的語言特性。在此之前,你可能使用過用戶級別的 JavaScript 模塊系統,好比在 Node.js 中的 CommonJS,或者是 AMD,或者其餘別的實現。全部的模塊系統都包含一個共同點:容許導入和導出內容。css
如今,JavaScript 擁有標準化的語法來完成這些事。在一個模塊中,可使用 export
關鍵字來導出任何內容,好比一個 const
,一個 function
或任何其餘變量綁定或是聲明。只需在變量語句或聲明前面加上 export
便可:html
// 📁 lib.mjs export const repeat = (string) => `${string} ${string}`; export function shout(string) { return `${string.toUpperCase()}!`; }
而後可使用 import
關鍵字從另外一個模塊導入模塊。在這裏,咱們從 lib
模塊導入 repeat
和 shout
函數,並在 main
模塊中使用它們 :java
// 📁 main.mjs import {repeat, shout} from './lib.mjs'; repeat('hello'); // → 'hello hello' shout('Modules in action'); // → 'MODULES IN ACTION!'
還也能夠從模塊中導出 default 值:node
// 📁 lib.mjs export default function(string) { return `${string.toUpperCase()}!`; }
這時候 default
導出可使用任何名稱導入:jquery
// 📁 main.mjs import shout from './lib.mjs'; // ^^^^^
模塊與經典腳本有點不太同樣:webpack
模塊中不支持 HTML 註釋語法。git
// Don’t use HTML-style comment syntax in JavaScript! const x = 42; <!-- TODO: Rename x to y. // Use a regular single-line comment instead: const x = 42; // TODO: Rename x to y.
var foo = 42;
不會建立一個名爲 foo
,能夠在瀏覽器經過window.foo
訪問的全局變量。import
和 export
語法僅在模塊中可用,不適用於經典腳本。正是因爲這些差別,相同的 JavaScript 代碼在模塊與經典腳本時可能會在處理上存在差別。所以,JavaScript runtime 須要區分哪些腳本是模塊。es6
在 Web 上,能夠將 <script>
元素中的 type
屬性設置爲 module
,經過這種方式來告訴瀏覽器將其視爲模塊。github
<script type="module" src="main.mjs"></script> <script nomodule src="fallback.js"></script>
可以解析 type="module"
的瀏覽器將忽略具備 nomodule
屬性的腳本。這意味着,能夠對支持模塊的瀏覽器提供基於模塊的代碼,同時對其餘不支持瀏覽器的提供 fallback 腳本。這個特性在性能上是有很大好處的,因爲只有現代瀏覽器支持模塊,若是瀏覽器可以解析模塊,那應該還支持模塊以前的語言特性,好比箭頭函數或是 async
- await
。這樣就沒必要在基於模塊的包中編譯這些語言特性,就能爲現代瀏覽器提供體積更小而且沒有通過編譯的代碼。只有在傳統瀏覽器纔會降級使用帶有 nomodule
的腳本。
如今已經瞭解到模塊與經典腳本的不一樣之處,除了上面列出的平臺無關差別以外,還有一些瀏覽器特定差別。
好比,模塊只會執行一次,而經典腳本則只要將其添加到 DOM 多少次就會執行多少次。
<script src="classic.js"></script> <script src="classic.js"></script> <!-- classic.js executes multiple times. --> <script type="module" src="module.mjs"></script> <script type="module" src="module.mjs"></script> <script type="module">import './module.mjs';</script> <!-- module.mjs executes only once. -->
另外,模塊腳本及其依賴關係經過 CORS 獲取。也就是說,任何跨域模塊腳本都必須提供正確的 HTTP 頭部信息,好比 Access-Control-Allow-Origin: *
。
另外一個區別與 async
屬性有關,它可讓腳本下載而不阻止 HTML 解析器(就像 defer
),但會當即執行腳本,不保證順序,而且不須要等待 HTML 解析完成。async
屬性不適用於內聯經典腳本,但仍適用於內聯 <script type="module">
。
可能已經注意到咱們正在使用 .mjs
做爲模塊的文件擴展名。在 Web 上,文件擴展名可有可無,只要該文件是 JavaScript MIME 類型 text/javascript
。從 script 元素的 type
屬性,瀏覽器就能知道它是一個模塊。
不過,咱們建議在模塊使用 .mjs
擴展名,緣由有兩個:
.mjs
擴展名的文件。注意:在 Web 上部署 .mjs
,須要在 Web 服務器將此擴展名配置成 Content-Type: text/javascript
。另外,你可能還但願配置編輯器將 .mjs
文件視爲 .js
文件來得到語法高亮顯示,事實上大多數現代編輯已經默認這樣作了。
在 import
模塊時,指定模塊位置的字符串稱爲「模塊標識符」或「導入標識符」。在以前的例子中,模塊標識符是 './lib.mjs'
:
import {shout} from './lib.mjs'; // ^^^^^^^^^^^
在瀏覽器中使用模塊標識符有一些限制。目前不支持「純」模塊標識符,這個限制能夠參考 HTML 規範 ,以便未來瀏覽器能夠容許自定義模塊加載器爲純模塊標識符賦予特殊含義,以下所示:
// Not supported (yet): import {shout} from 'jquery'; import {shout} from 'lib.mjs'; import {shout} from 'modules/lib.mjs';
另外一方面,下面的例子都是支持的:
// Supported: import {shout} from './lib.mjs'; import {shout} from '../lib.mjs'; import {shout} from '/modules/lib.mjs'; import {shout} from 'https://simple.example/modules/lib.mjs';
如今,模塊標識符必須是完整的 URL,或是相似 /
,./
或 ../
這樣的相對 URL 。
經典 <script>
元素默認阻止 HTML 解析器。咱們能夠經過添加 defer
屬性 來保證了腳本下載與 HTML 解析同時進行。
而模塊腳本默認 defer。所以,不須要添加 defer
到 <script type="module">
標籤。不只主模塊的下載與 HTML 解析並行,全部依賴模塊也是如此。
import()
到目前爲止,咱們只使用靜態 import
。使用靜態 import
時,整個模塊須要在主代碼運行以前下載並執行。但有時,可能並不但願預先加載模塊,而是按需加載模塊,只有在用到時才加載,好比時在用戶單擊連接或按鈕時,以此到達提升初始化性能的需求。這就是 動態 import()
。
<script type="module"> (async () => { const moduleSpecifier = './lib.mjs'; const {repeat, shout} = await import(moduleSpecifier); repeat('hello'); // → 'hello hello' shout('Dynamic import in action'); // → 'DYNAMIC IMPORT IN ACTION!' })(); </script>
與靜態 import
不一樣,動態 import()
能在常規腳本中使用,能夠從這裏開始在現有代碼庫中逐步使用 JS 模塊。有關更多相關信息,請參閱咱們關於動態 import 的文章。
注意:webpack 有它本身的實現版本,巧妙地將導入的模塊分割成獨立於主包的 chunk。
import.meta
另外一個與模塊相關的新特性是 import.meta
,提供有關當前模塊的元數據。元數據不是 ECMAScript 規範的一部分,這取決於宿主環境,好比在瀏覽器中跟在 Node.js 中可能會得到與不一樣的元數據。
這是一個在 web 上使用 import.meta
的例子。默認狀況下,圖像是相對於當前 URL 加載,使用 import.meta.url
使它能夠相對於當前模塊路徑加載。
function loadThumbnail(relativePath) { const url = new URL(relativePath, import.meta.url); const image = new Image(); image.src = url; return image; } const thumbnail = loadThumbnail('../img/thumbnail.png'); container.append(thumbnail);
藉助 JS 模塊,能夠在不使用打包工具(如 webpack,Rollup 或 Parcel)的狀況下開發網站。在如下狀況下,直接使用原生 JS 模塊會更具優點:
可是,正如咱們以前所瞭解到的,在加載由〜300個模塊組成的模塊化庫,對 Chrome 加載管道進行性能瓶頸分析時,打包以後的加載性能優於非打包。
其中一個緣由是靜態 import
/ export
語法是靜態可分析的,所以打包工具優能夠經過消除未使用的導出優化代碼。靜態 import
和 export
不只僅是語法,它們是一個很是關鍵的特性。
注意:咱們的通常建議是在模塊部署到生產以前繼續使用打包工具。在某種程度上,打包是一種壓縮代碼的優化方式,會帶來性能上的好處,由於最終將傳輸更少的代碼。
固然,DevTools 的代碼覆蓋功能能夠幫助肯定是否有將沒必要要的代碼推送給用戶。咱們還建議使用代碼拆分來拆分包,並延遲加載非關鍵路徑的腳本。
在 web 開發中,使用非打包模塊可能會下降初始加載性能(冷緩存),但實際上能夠提升後續訪問(熱緩存)的加載性能。對於一個沒有使用代碼拆分的 200 KB 的代碼庫,在更改一個細粒度的模塊時,從服務器單獨獲取這個模塊要比從新獲取整個 bundle 更好。
若是你更關心使用熱緩存的訪問者的體驗,而不是首次訪問的性能,而且網站的細粒度模塊少於幾百個,那麼能夠嘗試使用非打包模塊。須要作的是評估冷緩存和熱緩存的加載性能,而後作出以數據驅動的決定。
瀏覽器工程師正在努力改進即開即用的模塊性能。隨着時間的推移,預計在更多狀況下,使用非打包模塊將會成爲可行。
應該養成使用小而細的模塊編寫代碼的習慣。在開發過程當中,更好的方式是在一個文件中使用更少的導出。
好比一個名爲 ./util.mjs
的模塊,導出三個函數 drop
,pluck
以及 zip
:
export function drop() { /* … */ } export function pluck() { /* … */ } export function zip() { /* … */ }
若是代碼庫僅僅須要 pluck
函數,那麼可能會按以下方式進行導入:
import { pluck } from './util.mjs';
在這種狀況下(沒有使用構建時打包步驟),瀏覽器仍然須要下載,解析和編譯整個 ./util.mjs
模塊,即便須要的只是其中的一個導出。這很浪費性能。
若是 pluck
不和 drop
和 zip
共享任何代碼,更好的方式是把它移動到本身的細粒度模塊,好比 ./pluck.mjs
:
export function pluck() { /* … */ }
而後咱們能夠導入 pluck
而不須要處理 drop
和 zip
:
import { pluck } from './pluck.mjs';
注意:能夠在這裏使用 default
導出而不是命名導出,取決於我的喜愛。
這不只可讓源代碼更加簡單,並且還能夠減小打包工具消除死代碼的需求。若是源代碼樹中的某個模塊未被使用,那麼它永遠不會被導入,因此瀏覽器永遠不會下載它。而且,瀏覽器能夠單獨爲該模塊進行代碼緩存。
使用小而細的模塊有助於爲未來的原生打包解決方案作好準備。
還能夠經過使用 <link rel="modulepreload">
進一步優化模塊。這樣,瀏覽器能夠預加載,甚至能夠預解析以及預編譯模塊及其依賴關係。
<link rel="modulepreload" href="lib.mjs"> <link rel="modulepreload" href="main.mjs"> <script type="module" src="main.mjs"></script> <script nomodule src="fallback.js"></script>
對於一個更大的依賴樹,這一點尤爲重要。若是沒有 rel="modulepreload"
,瀏覽器須要執行多個 HTTP 請求來找出完整的依賴關係樹。可是,若是聲明依賴模塊腳本的完整列表 rel="modulepreload"
,那瀏覽器就沒必要逐步去發現這些依賴關係。
若是可能的話,使用 HTTP/2 是一個很好的性能建議,好比僅僅是爲了支持多路複用。經過 HTTP/2 多路複用,多個請求和響應消息能夠同時在運行,這對加載模塊樹頗有幫助。
Chrome 團隊調研了另外一個 HTTP/2 的功能,HTTP/2 服務器推送功能是否能夠成爲部署高度模塊化應用程序的實用解決方案。可是,HTTP/2 服務器推送還有不少問題,Web 服務器和瀏覽器的實現目前尚未針對高度模塊化的 Web 應用進行優化。好比,很難只推送用戶還沒有緩存的資源,解決這個問題的一種方式是將源的整個緩存狀態傳達服務器,但這會帶來隱私風險。
因此應該繼續堅持推動使用 HTTP/2,但 HTTP/2 服務器推送並非銀彈。
在 web 上使用 JS 模塊正在逐漸變得流行起來,但在咱們的使用狀況統計 顯示目前僅有 0.08% 的頁面在使用 <script type="module">
,這份數據不包含像是動態 import()
或 worklets 的使用 JS 模塊的方式。
Chrome 團隊正在嘗試各類方式改善 JS 模塊的開發體驗,如今來討論其中的一些點。
咱們提出了改進模塊解析算法的提案,以解決目前在速度和肯定性上的不足。新算法既存在於 HTML 規範中,也存在於 ECMAScript 規範中,並在 Chrome 63 中實現。預計這種改進很快就會在更多的瀏覽器中得以實現。
新算法效率更高,速度更快。舊算法的複雜度是依賴圖大小的二次方,即 O(n²),Chrome 的實現也是如此。新算法的複雜度是線性的,即 O(n)。
並且,新算法會以更肯定的方式報告解析錯誤。給定一個包含多個錯誤的依賴圖,舊算法在不一樣運行狀況下可能會報告不一樣的錯誤,由於它們都會致使解析失敗,這使調試很是困難,而新算法每次都報告相同的錯誤。
Chrome 如今實現了 wokelets,它容許 web 開發人員自定義瀏覽器底層級別的硬編碼邏輯。經過使用 worklets,Web 開發人員能夠將 JS 模塊提供給渲染管道或音頻處理管道(將來可能還會有更多管道)。
Chrome 65 支持 PaintWorklet
(又名 CSS Paint API)來控制 DOM 元素的繪製方式。
const result = await css.paintWorklet.addModule('paint-worklet.mjs');
Chrome 66 支持 AudioWorklet
,可以使用自定義代碼控制音頻處理。同時加入 OriginTrial forAnimationWorklet
,能夠用來建立 scroll-linked 效果以及其餘高性能程序動畫。
最後,LayoutWorklet
(也就是 CSS Layout API)在 Chrome 67 中實現。
咱們正在實現爲 Chrome 中的專用 web worker 提供對 JS 模塊的支持。能夠在 chrome://flags/#enable-experimental-web-platform-features
啓用後嘗試此功能 。
const worker = new Worker('worker.mjs', { type: 'module' });
shared worker 和 service worker 也即將支持 JS 模塊:
const worker = new SharedWorker('worker.mjs', { type: 'module' }); const registration = await navigator.serviceWorker.register('worker.mjs', { type: 'module' });
在 Node.js/npm 中,一般經過「包名稱」來導入 JS 模塊。好比:
import moment from 'moment'; import { pluck } from 'lodash-es';
目前,根據 HTML 規範,這種「純導入標識符」會引起異常。咱們提出包名稱映射的提案容許此類代碼在 Web 上運行,包括生產環境。包名稱映射是一個 JSON 文件,用來幫助瀏覽器將純導入標識符轉換爲完整的 URL。
目前包名稱映射仍處於提案階段,並且尚未完整的規範。
Chrome 瀏覽器團隊目前正在探索一種原生的 web 打包格式做爲分發網絡應用的新方式。其核心功能是:
簽名的 HTTP Exchange容許瀏覽器相信單個 HTTP 請求/響應對聲稱的來源生成; 打包的 HTTP Exchange,即一組交換信息,每一個交換信息均可以是簽名或未簽名的,其中包含一些元數據描述如何將包做爲一個總體解析。
這樣的組合打包格式將使 多個同源的資源 被 安全地嵌入 單個 HTTP GET
響應。
現有的打包工具(如 webpack,Rollup 或 Parcel)將生成單個 JavaScript 包,其中原始模塊的語義丟失。使用原生打包,瀏覽器能夠將資源分解回原來的形式。簡而言之,能夠將 Bundled HTTP Exchange 想象成可經過內容目錄以任意順序訪問的資源束,而且所包含的資源能夠根據其重要性進行有效存儲和標記,同時保持獨立文件的概念。正由於如此,原生打包能夠改善調試體驗,在 DevTools 中查看資源時,瀏覽器能夠精肯定位原始模塊,而不依賴複雜的 source map。
因爲原生打包格式的透明度,能夠輕鬆地完成一些優化。好比,當瀏覽器已經緩存了包的一部分時,能夠通知 Web 服務器,而後只下載缺失的部分。
Chrome 已經支持提案(SignedExchanges
)的一部分,但打包格式自己以及其對高度模塊化的應用仍處於探索階段。
發佈新特性和 Web API 會致使持續的維護和運行時成本,每一個新特性都會污染瀏覽器名稱空間,增長啓動成本,而且可能在整個代碼庫中引入 bug。Layered API 但願以更具擴展性的方式實現和發佈一些 Web 瀏覽器的高級 API。JS 模塊是 Layered API 中的關鍵技術:
模塊和 Layered API 如何協同工做的細節仍在制定中,但目前的提案看起來像這樣:
<script type="module" src="std:virtual-scroller|https://example.com/virtual-scroller.mjs" ></script>
這個 <script>
元素能夠從瀏覽器的內置 Layered API(std:virtual-scroller
)或是指向 polyfill 的 fallback URL 加載 virtual-scroller
API 。而後就能夠建立一個自定義 <virtual-scroller>
元素,以下:
<virtual-scroller> <!-- Content goes here. --> </virtual-scroller>