概述
- ESM 已經逐步得到各大瀏覽器廠商以及 Node.js 的原生支持,正在成為主流前端模塊化方案。
而 Vite 本身就是借助瀏覽器原生的 ESM 解析能力( type=“module” )實現了開發階段的 no-bundle ,即不用打包也可以構建 Web 應用。不過我們對于原生 ESM 的理解僅僅停留在 type=“module” 這個特性上面未免有些狹隘了,一方面瀏覽器和 Node.js 各自提供了不同的 ESM 使用特性,如 import maps 、package.json 的 imports 和exports 屬性等等,另一方面前端社區開始逐漸向 ESM 過渡,有的包甚至僅留下 ESM產物, Pure ESM 的概念隨之席卷前端圈,而與此同時,基于 ESM 的 CDN 基礎設施也如雨后春筍般不斷涌現,諸如 esm.sh 、 skypack 、 jspm 等等。 - 因此你可以看到,ESM 已經不僅僅局限于一個模塊規范的概念,它代表了前端社區生態的走向以及各項前端基礎設施的未來,不管是瀏覽器、Node.js 還是 npm 上第三方包生態的發展,無一不在印證這一點。那么,作為一名 2022 年的前端,我覺得深入地了解ESM 的高級特性、社區生態都是有必要的,一方面彌補自己對于 ESM 認知上的不足,另一方面也能享受到社區生態帶給我們的紅利。在接下來的內容中,我將給你詳細介紹瀏覽器和 Node.js 中基于 ESM 實現的一些 高級特性 ,然后分析什么是 Pure ESM 模式,這種模式下存在哪些痛點,以及我們作為開發者,如何去擁抱 Pure ESM 的趨勢。
高階特性
1 )import map
- 在瀏覽器中我們可以使用包含 type=“module” 屬性的 script 標簽來加載 ES 模塊,而模塊路徑主要包含三種:
- 絕對路徑,如 https://cdn.skypack.dev/react
- 相對路徑,如 ./module-a
- bare import 即直接寫一個第三方包名,如 react 、 lodash
- 對于前兩種模塊路徑瀏覽器是原生支持的,而對于 bare import ,在 Node.js 能直接執行,因為 Node.js 的路徑解析算法會從項目的 node_modules 找到第三方包的模塊路徑,但是放在瀏覽器中無法直接執行。而這種寫法在日常開發的過程又極為常見,除了將bare import 手動替換為一個絕對路徑,還有其它的解決方案嗎?
- 答案是有的。現代瀏覽器內置的 import map 就是為了解決上述的問題,我們可以用一個簡單的例子來使用這個特性
<!DOCTYPE html> <html lang="en"> <head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title> </head> <body><div id="root"></div><script type="importmap">{"imports": {"react": "https://cdn.skypack.dev/react"}}</script><script type="module">import React from 'react';console.log(React)</script> </body> </html>
- 在瀏覽器中執行這個 HTML,如果正常執行,那么你可以看到瀏覽器已經從網絡中獲取
了 react 的內容,如下圖所示 - 注意: importmap 可能存在瀏覽器兼容性問題,這里出現瀏覽器報錯也屬于正常情況,后文會介紹解決方案。
- 在支持 import map 的瀏覽器中,在遇到 type=“importmap” 的 script 標簽時,瀏覽器會記錄下第三方包的路徑映射表,在遇到 bare import 時會根據這張表拉取遠程的依賴代碼。如上述的例子中,我們使用 skypack 這個第三方的 ESM CDN 服務,通過 https://cdn.skypack.dev/react 這個地址我們可以拿到 React 的 ESM 格式產物。import map 特性雖然簡潔方便,但瀏覽器的兼容性卻是個大問題,在 CanIUse 上的兼容性數據如下:
- 它只能兼容市面上 68% 左右的瀏覽器份額,而反觀 type=“module” 的兼容性(兼容 95%
以上的瀏覽器), import map 的兼容性實屬不太樂觀。但幸運的是,社區已經有了對應的
Polyfill 解決方案——es-module-shims,完整地實現了包含 import map 在內的各大ESM 特性,還包括:-
dynamic import 。即動態導入,部分老版本的 Firefox 和 Edge 不支持
-
import.meta 和 import.meta.url 。當前模塊的元信息,類似 Node.js 中的 __dirname 、 __filename
-
modulepreload 。以前我們會在 link 標簽中加上 rel=“preload” 來進行資源預加載,即在瀏覽器解析 HTML 之前就開始加載資源,現在對于 ESM 也有對應的modulepreload 來支持這個行為
-
JSON Modules 和 CSS Modules ,即通過如下方式來引入 json 或者 css :
<script type="module"> // 獲取 json 對象 import json from 'https://site.com/data.json' assert { type: 'json' }; // 獲取 CSS Modules 對象 import sheet from 'https://site.com/sheet.css' assert { type: 'css' }; </script>
-
- 值得一提的是, es-module-shims 基于 wasm 實現,性能并不差,相比瀏覽器原生的行
為沒有明顯的性能下降, 可以去這個地址查看具體的 benchmark 結果 - 由此可見, import map 雖然并沒有得到廣泛瀏覽器的原生支持,但是我們仍然可以通過Polyfill 的方式在支持 type=“module” 的瀏覽器中使用 import map
2 ) Nodejs 包導入導出策略
-
在 Node.js 中( >=12.20 版本 )有一般如下幾種方式可以使用原生 ES Module:
- 文件以 .mjs 結尾;
- package.json 中聲明 type: “module” 。
-
那么,Nodejs 在處理 ES Module 導入導出的時候,如果是處理 npm 包級別的情況,其中的細節可能比你想象中更加復雜。
-
首先來看看如何導出一個包,你有兩種方式可以選擇: main 和 exports 屬性。這兩個屬性均來自于 package.json ,并且根據 Node 官方的 resolve 算法,exports 的優先級 main 更高,也就是說如果你同時設置了這兩個屬性,那么 exports 會優先生效。
-
main 的使用比較簡單,設置包的入口文件路徑即可,如:
"main": "./dist/index.js"
-
需要重點梳理的是 exports 屬性,它包含了多種導出形式: 默認導出 、 子路徑導出 和 條件導出 ,這些導出形式如以下的代碼所示:
// package.json {"name": "package-a","type": "module","exports": {// 默認導出,使用方式: import a from 'package-a'".": "./dist/index.js",// 子路徑導出,使用方式: import d from 'package-a/dist'"./dist": "./dist/index.js","./dist/*": "./dist/*", // 這里可以使用 `*` 導出目錄下所有的文件// 條件導出,區分 ESM 和 CommonJS 引入的情況"./main": {"import": "./main.js","require": "./main.cjs"},} }
-
其中,條件導出可以包括如下常見的屬性:
- node : 在 Node.js 環境下適用,可以定義為嵌套條件導出,如:
{"exports": {{".": {"node": {"import": "./main.js","require": "./main.cjs"} }}}, }
- import : 用于 import 方式導入的情況,如 import(“package-a”) ;
- require : 用于 require 方式導入的情況,如 require(“package-a”) ;
- default ,兜底方案,如果前面的條件都沒命中,則使用 default 導出的路徑
- node : 在 Node.js 環境下適用,可以定義為嵌套條件導出,如:
-
當然,條件導出還包含 types 、 browser 、 develoment 、 production 等屬性,大家可以參考 Node.js 的詳情文檔,這里就不一一贅述了。
-
在介紹完"導出"之后,我們再來看看 “導入” ,也就是 package.json 中的 imports 字段,一般是這樣聲明的:
{"imports": {// key 一般以 # 開頭// 也可以直接賦值為一個字符串: "#dep": "lodash-es""#dep": {"node": "lodash-es","default": "./dep-polyfill.js"},},"dependencies": {"lodash-es": "^4.17.21"} }
-
這樣你可以在自己的包中使用下面的 import 語句:
// index.js import { cloneDeep } from "#dep"; const obj = { a: 1 }; // { a: 1 } console.log(cloneDeep(obj));
-
Node.js 在執行的時候會將 #dep 定位到 lodash-es 這個第三方包,當然,你也可以將其定位到某個內部文件。這樣相當于實現了 路徑別名 的功能,不過與構建工具中的 alias 功能不同的是,“imports” 中聲明的別名必須全量匹配,否則 Node.js 會直接拋錯。
Pure ESM
首先,什么是 Pure ESM ? Pure ESM 最初是在 Github 上的一個帖子中被提出來的,其中有兩層含義,一個是讓 npm 包都提供 ESM 格式的產物,另一個是僅留下 ESM 產物,拋棄 CommonJS 等其它格式產物
1 ) 對 Pure ESM 的態度
-
當這個概念被提出來之后社區當中出現了很多不同的聲音,有人贊成,也有人不滿。但不
管怎么樣,社區中的很多 npm 包已經出現了 ESM First 的趨勢,可以預見的是越來越多的包會提供 ESM 的版本,來擁抱社區 ESM 大一統的趨勢,同時也有一部分的 npm包做得更加激進,直接采取 Pure ESM 模式,如大名鼎鼎的 chalk 和 imagemin ,最新版本中只提供 ESM 產物,而不再提供 CommonJS 產物。對于 Pure ESM,我們到底應該支持還是反對呢? -
首先拋出結論:
- 對于沒有上層封裝需求的大型框架,如 Nuxt、Umi,在保證能上 Pure ESM 的情況下,直接上不會有什么問題
- 但如果是一個底層基礎庫,最好提供好 ESM 和 CommonJS 兩種格式的產物
-
接下來,我們就來分析這個結論是怎么得出來的, 在 ESM 中,我們可以直接導入 CommonJS 模塊,如
// react 僅有 CommonJS 產物 import React from 'react'; console.log(React)
-
Node.js 執行以上的原生 ESM 代碼并沒有問題,但反過來,如果你想在 CommonJS 中
require 一個 ES 模塊,就行不通了:
-
其根本原因在于 require 是同步加載的,而 ES 模塊本身具有異步加載的特性,因此兩者
天然互斥,即我們無法 require 一個 ES 模塊 -
那是不是在 CommonJS 中無法引入 ES 模塊了呢? 也不盡然,我們可以通過 dynamic import 來引入:
-
不知道你注意到沒有,為了引入一個 ES 模塊,我們必須要將原來同步的執行環境改為 異步 的,這就帶來如下的幾個問題:
- 如果執行環境不支持異步,CommonJS 將無法導入 ES 模塊;
- jest 中不支持導入 ES 模塊,測試會比較困難;
- 在 tsc 中,對于 await import() 語法會強制編譯成 require 的語法(詳情),只能靠 eval(‘await import()’) 繞過去。
-
總而言之,CommonJS 中導入 ES 模塊比較困難。因此,如果一個基礎底層庫使用 Pure ESM ,那么潛臺詞相當于你依賴這個庫時(可能是直接依賴,也有可能是間接依賴),你自己的庫/應用的產物最好為 ESM 格式。也就是說, Pure ESM 是具有傳染性的,底層的庫出現了 Pure ESM 產物,那么上層的使用方也最好是 Pure ESM,否則會有上述的種種限制。
-
但從另一個角度來看,對于大型框架(如 Nuxt)而言,基本沒有二次封裝的需求,框架本身如果能夠使用 Pure ESM ,那么也能帶動社區更多的包(比如框架插件)走向 Pure ESM,同時也沒有上游調用方的限制,反而對社區 ESM 規范的推動是一件好事情。
-
當然,上述的結論也帶來了一個潛在的問題: 大型框架畢竟很有限,npm 上大部分的包還是屬于基礎庫的范疇,那對于大部分包,我們采用導出 ESM/CommonJS 兩種產物的方案,會不會對項目的語法產生限制呢?
-
我們知道,在 ESM 中無法使用 CommonJS 中的 __dirname 、 __filename 、require.resolve 等全局變量和方法,同樣的,在 CommonJS 中我們也沒辦法使用ESM 專有的 import.meta 對象,那么如果要提供兩種產物格式,這些模塊規范相關的語法怎么處理呢?在傳統的編譯構建工具中,我們很難逃開這個問題,但新一代的基礎庫打包器 tsup 給了我們解決方案
2 ) 新一代的基礎庫打包器
- tsup 是一個基于 Esbuild 的基礎庫打包器,主打無配置(no config)打包。借助它我們可以輕易地打出 ESM 和 CommonJS 雙格式的產物,并且可以任意使用與模塊格式強相關的一些全局變量或者 API,比如某個庫的源碼如下:
export interface Options {data: string; } export function init(options: Options) {console.log(options);console.log(import.meta.url); }
- 由于代碼中使用了 import.meta 對象,這是僅在 ESM 下存在的變量,而經過 tsup 打包后的 CommonJS 版本卻被轉換成了下面這樣:
var getImportMetaUrl = () =>typeof document === "undefined" ?new URL("file:" + __filename).href :(document.currentScript && document.currentScript.src) ||new URL("main.js", document.baseURI).href; var importMetaUrl = /* @__PURE__ */ getImportMetaUrl(); // src/index.ts function init(options) {console.log(options);console.log(importMetaUrl); }
- 可以看到,ESM 中的 API 被轉換為 CommonJS 對應的格式,反之也是同理。最后,我們可以借助之前提到的條件導出,將 ESM、CommonJS 的產物分別進行導出,如下所示
{"scripts": {"watch": "npm run build -- --watch src","build": "tsup ./src/index.ts --format cjs,esm --dts --clean"},"exports": {".": {"import": "./dist/index.mjs","require": "./dist/index.js",// 導出類型"types": "./dist/index.d.ts"}} }
- tsup 在解決了雙格式產物問題的同時,本身利用 Esbuild 進行打包,性能非常強悍,也能生成類型文件,同時也彌補了 Esbuild 沒有類型系統的缺點,還是非常推薦大家使用的
- 當然,回到 Pure ESM 本身,我覺得這是一個未來可以預見的趨勢,但對于基礎庫來說,現在并不適合切到 Pure ESM ,如今作為過渡時期,還是發 ESM/CommonJS 雙格式的包較為靠譜,而 tsup 這種工具能降低基礎庫構建上的成本。當所有的庫都有 ESM 產物的時候,我們再來落地 Pure ESM 就輕而易舉了