跳到主要內容
前端開發

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
載入 JSONwith { 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 並沒有太大改變。

參考資料

分享這篇