ES Module 現在的樣子:Node ESM、exports 與 import maps
整理現代 JavaScript ES Module 的常見用法,包括 Node.js 原生 ESM、package.json exports、瀏覽器 import maps、JSON import attributes,以及 Vite 動態載入的注意事項。
W
早期接觸 ES Module 時,重點多半只有瀏覽器裡的 import、export 與 <script type="module">,也常會覺得這是「瀏覽器搭配打包工具」才會使用的東西。現在 Node.js 本身已經完整走向 ESM,瀏覽器也有 import maps 等原生功能。
先複習 import 與 export
一個模組可以有多個具名匯出,以及一個預設匯出:
// tools.js
export const name = 'madebyw'
export function add(a, b) {
return a + b
}
export default function init() {
// ...
}
載入時,具名匯出要使用 {},預設匯出的名稱則由載入端決定:
import setup, { name, add } from './tools.js'
只寫路徑的 import './setup.js' 會執行模組但不取得匯出值,常用於載入初始化程式或樣式等 side effect。瀏覽器中的 <script type="module"> 也有自己的模組作用域,頂層變數不會自動掛到 window。
Node.js 使用 ESM
最常見的方式是在 package.json 加上:
{
"type": "module"
}
之後 .js 就會按照 ES Module 解析:
import { readFile } from 'node:fs/promises'
export function loadFile() {
// ...
}
如果沒有設定 "type": "module",也可以使用:
.mjs → ES Module
.cjs → CommonJS
Node 內建模組現在也建議加上 node::
import path from 'node:path'
import { readFile } from 'node:fs/promises'
ESM 裡沒有 CommonJS 的 __dirname,現在可以使用:
console.log(import.meta.dirname)
ES Module 也可以直接使用 top-level await:
const config = await loadConfig()
不需要再包一層 async function。
package.json 的 exports
如果自己發布 npm 套件,現在可以使用 exports 定義公開入口:
{
"name": "my-lib",
"type": "module",
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js"
}
}
使用者可以:
import something from 'my-lib'
import utils from 'my-lib/utils'
但不能直接存取沒有公開的內部檔案:
import internal from 'my-lib/src/internal.js'
// 會被 exports 擋下來
exports 可以視為比 main 更完整的套件入口設定。
如果兩者同時存在,在支援 exports 的 Node.js 版本中會優先使用 exports。
瀏覽器的 import maps
以前瀏覽器看到這種寫法:
import { debounce } from 'lodash-es'
不知道 lodash-es 在哪裡。
現在可以透過 import map 告訴瀏覽器:
<script type="importmap">
{
"imports": {
"lodash-es": "https://cdn.jsdelivr.net/npm/lodash-es@4/lodash.js",
"#utils/": "/js/utils/"
}
}
</script>
之後就能直接:
<script type="module">
import { debounce } from 'lodash-es'
</script>
比較適合小工具、Demo,或不想引入完整打包工具的頁面。
一般使用 Vite、Nuxt 等框架時,大多還是交給工具鏈處理。
import attributes:載入 JSON
現在可以直接使用 import attributes 載入 JSON:
import config from './config.json' with { type: 'json' }
with { type: 'json' } 用來告訴執行環境這個模組應該按照 JSON 處理。
以前曾經看過:
import config from './config.json' assert { type: 'json' }
assert 是舊語法,現在使用 with。
動態 import
需要時才載入模組,可以使用:
const { default: Chart } = await import('./heavy-chart.js')
在 Vue 裡也常搭配:
const HeavyChart = defineAsyncComponent(
() => import('./HeavyChart.vue')
)
這可以讓打包工具進行 code splitting,不必一開始就載入所有程式碼。
Vite 的 import.meta.glob
如果要一次取得多個模組,Vite 提供:
const modules = import.meta.glob('./components/*.vue')
預設會使用 lazy loading,每個項目其實是一個動態 import():
{
'./components/A.vue': () => import('./components/A.vue'),
'./components/B.vue': () => import('./components/B.vue')
}
如果希望建置時直接載入,可以加:
const modules = import.meta.glob(
'./components/*.vue',
{ eager: true }
)
Vite 會改成靜態 import。
這在 SSR 或 Workers 環境遇到動態模組載入問題時特別值得檢查。
我曾經在 Cloudflare Workers 部署時遇到 lazy glob 在 SSR 行為與開發環境不同,最後改成:
const modules = import.meta.glob(
'~/components/taiwan/*.vue',
{ eager: true }
)
問題就消失了。
這類問題比較適合理解成:
開發環境正常,不代表 SSR / Worker 建置後的模組載入方式完全相同。
遇到問題時應檢查實際 build 結果與部署環境,而不是直接認定 import() 本身不能使用。
簡單對照
| 要做的事 | 現在常見方式 |
|---|---|
| Node 使用 ES Module | "type": "module" |
| Node 內建模組 | node:fs、node:path |
| ESM 取得目錄 | import.meta.dirname |
| 套件公開入口 | package.json 的 exports |
| 瀏覽器使用裸模組名 | import maps |
| 載入 JSON | with { type: 'json' } |
| 延遲載入模組 | import() |
| Vite 批次載入模組 | import.meta.glob() |
| Vite 靜態載入 glob | { eager: true } |
小結
現在 ES Module 已經不只是:
import ...
export ...
Node.js、瀏覽器與打包工具都直接建立在 ESM 上。
日常開發比較值得記住的是:
Node 專案
→ "type": "module"
發布 npm 套件
→ exports
瀏覽器不用 bundler
→ import maps
載入 JSON
→ with { type: 'json' }
需要時才載入
→ import()
Vite 一次找多個模組
→ import.meta.glob()
至於 export default、具名匯出等基本語法,和以前的 ES Module 並沒有太大改變。