Node.js + Typescript with ESM in 2023
最近有一些寫簡單後端的需求,但 Express 用膩了,想說玩一下 Koa 或 Fastify。結果還沒開始就先被 Typescript 使用 ESM 的問題給 gank 了一整天,於是來筆記一下踩坑記錄……
ps. 這篇文章以 Node.js v18.14
為基準,v16 以上跟以下差很多。
以前也寫過一篇 TS 入門的文章,側重點比較在前端。新的這篇會提一些比較新的設定方式,且側重在後端。
快速建立 Typescript 環境
自己接觸 Typescript 也有一小段時間了,現在做專案傾向能用 TS 就會盡量上 TS,就連平常的小練習,也都讓自己多去使用 TS 來寫。
medium.com
ESM vs CJS (CommonJS)
自從 Vite 問世了之後,全面採用 ES Module 系統,可以發現許多 lib 都開始對應 ESM,整個 module 體系有向 ESM 靠攏的趨勢。
前端對於 module 體系的變化,可能沒那麼有感覺,畢竟 Build Tool 幫我們把一切都處理好了。但偶爾還是會碰到一些 lib 是採用 CJS,很難互相引用的情況。
例如 TailwindCSS + PostCSS 的組合,是用 CJS 寫的。平常使用不會有甚麼問題,但如果想要將一些設定從 tailwind config 提出給 UI 庫共用時,就會碰到 ESM vs CJS 不太兼容的問題。
例如之前 po 過的這篇文章:
Node.js 後端體系,從 v16 開始也逐漸對應 ESM。但後端有很多套件原本就不是給瀏覽器使用的,所以始終都沒有對應 ESM,依舊使用 CJS。因此 Node 端為了安全起見,使用 CJS 比較不會撞坑。
但由於前端 ESM 寫習慣了,想挑戰看看引入 Typescript 的同時,採用 ESM 機制,結果搞到快吐血。
Node.js 的 module 設定
瀏覽器一律使用 ESM,不支援 CJS。而 Node 環境從 v16 開始有了新的機制。我們有兩種方式可以告訴 Node.js 要使用的是 CJS 還是 ESM。
1. 在 package.json 設定 type 屬性
// package.json
{
type: "module", // default is "commonjs"
// ...
}
可以直接在主設定檔明確定義該專案的基調是使用何種系統。之後所有的 .js 都會默認使用該機制。
ESM 會有一些與 CJS 不同的功能:
● 只有 ESM 才能使用 top-level await
● ESM 底下無法使用 __dirname
這類 Node.js 的全域變數
如果想在 ESM 使用的話,會有一些替代方法。(參考)
import { dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
2. 使用副檔名 .mjs or .cjs 控制
也可以透過副檔名明確告知這支檔案使用的模塊系統,那 Node.js 就會使用該系統去解析模塊。
// fileA.mjs
export const name = 'esm'
// fileB.cjs
exports.name = 'cjs'
// main.mjs
import { name } from './fileA.mjs'
import fileB from './fileB.cjs'
console.log(name) // "esm"
console.log(fileB.name) // "cjs"
ESM 是比較新的系統,他其實是可以兼容 CJS 的,所以我們可以直接導入。只是會有些限制,並且一定要寫副檔名。
// NG
import fileB from './fileB'
可能看到這會覺得,瀏覽器匯入檔案本來就要寫副檔名啊? 對,沒錯,但是 Typescript 預設是不寫副檔名的,這就是一個坑點,後面再來細談。
ESM vs CJS 兼容問題
從上面的例子可以知道,ESM 是可以向下兼容 CJS 的。但是要 CJS 向上兼容,導入 ESM 模塊,就會很困難了,必須要使用 dynamic import()
。
// main.cjs
import('./fileA.mjs').then((ctx) => {
console.log(ctx.name) // "mjs"
})
使用 dynamic import() 就會變成 Promise,在使用上會增加許多困難度。
實際拉套件時,情況會複雜許多,還是很容易會發生 ESM vs CJS 導不進來,直接報錯說 node_modules
裡面的檔案,require
是未定義之類的狀況……
Typescript 有自己的 module 系統
接著來到 TS,要先有個概念,TS 有自己的 module I/O 寫法,例如這種。
// main.ts
import moduleA = require('./moduleA')
// or ESM-like
import moduleA from './moduleA'
TS 並不是一個獨立的新語言,他只是一種 JS 預處理器,有些類似 Sass 那種感覺,最終還是會編譯回純 CSS 與 JS。
具體有哪些寫法我就沒細查了,現在基本上都是用類 ESM 的方式去寫。雖然長的像 ESM,但 TS 會根據你的設定,編譯成不同的 module 系統。
// tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS", // 指定 CJS
"moduleResolution": "node",
// ...
}
}
// fileA.ts
export const name = 'cjs'
// main.ts
import { fileA } from './fileA'
console.log(fileA)
設定檔中指定了 CJS,所以編譯後就會出現 require 跟 exports 這類 CJS 的模塊寫法。
// fileA.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.fileA = void 0;
exports.fileA = 'esm';
// main.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const fileA_1 = require("./fileA");
console.log(fileA_1.fileA);
所以引入 TS 之後,如果碰到模塊衝突問題,是真的會搞得很混亂,滿頭問號。
Typescript + ESM
先給不熟 TS 的人簡單提一下,後端專案一般不會去弄 Vite 這些玩意,因為沒有要給瀏覽器用。且 Node.js 是不能執行 .ts
檔案的。
所以一般會安裝 ts-node 來執行,這樣就不用一直跑 tsc
去進行編譯。watch 模式的部分,可以用 ts-node-dev 或是 nodemon 都可以。ts-node 目前跟 Node.js v18 不同,尚未內建 watch 機制。
# 需要依賴 nodejs 的 types 定義包
$ pnpm i -D typescript ts-node nodemon @types/node
最後 deploy 的時候,直接用 ts-node 跑 .ts
效能鐵定不好,所以還是會編譯回 .js
再去部署。因此 TS 相關的套件裝在 devDependencies
是比較好的。
ts-node 可以直接執行 TS 檔案,操作方式跟 node 差不多。只是要注意,因為不是裝在 global 上,需要使用 npx 或 pnpm 來讓終端機知道,要調用的是 npm 套件的指令。
$ pnpm ts-node main.ts
// or
$ npx ts-node main.ts
而 nodemon 在跑 ts 檔時,會自動調用 ts-node,所以正常寫就好。
$ pnpm nodemon main.ts
// 同義於
$ pnpm nodemon --exec "ts-node main.ts"
如果希望在 nodemon 執行 ts-node 時,追加一些副參數,就得用 exec 來設定,這可以寫在 nodemon 設定檔上。可參考這篇討論:
使用 ts-node 開發時,默認是會進行 type check 的,所以比較慢。一般會推薦下 --transpileOnly
屬性,也可以直接寫在 tsconfig。這邊推薦後者,寫在 tsconfig 上時,nodemon 一樣吃的到。
// flag
$ ts-node --transpileOnly main.ts
// or tsconfig
{
"ts-node": {
"transpileOnly": true,
},
"compilerOptions": {
// ...
}
}
再來是 ESM 設定,除了 package.json
要設定 type: module
之外。也需要在 tsconfig 指定是 ESM,參考 Vite 的 TS 建構來做設定,直接指定最新版本 ESNext。還要注意,ts-node 也需要指定為 esm 模式。
// package.josn
{
"type": "module",
}
// tsconfig.json
{
"ts-node": {
"transpileOnly": true,
"esm": true // 指定 ts-node 用 esm 執行, 也可用 --esm 在 cmd 下指令
},
"compilerOptions": {
"target": "ESNext", // 要編譯成的 JS 目標版本
"module": "ESNext", // 要編譯成的 module
"moduleResolution": "node", // module 解析方式, default "classic"
"esModuleInterop": true, // module 兼容, 會影響 build 方式
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules"],
}
那個 moduleResolution
我還沒完全理解,原本主要分為 classic 與 node 兩種。前者應該是 TS 傳統的模塊系統,現在一般推薦用 node,不過其實還有更新的設定,現在單純先抄 Vite 的寫法。
羅生門的副檔名
現在定義完要使用 ESM 之後,神奇的坑來了。用原本的方式去寫模塊……
// ./src/fileA.ts
export const fileA = 'esm'
// ./src/main.ts
import { fileA } from './fileA'
console.log(fileA)
然後一執行,直接噴錯。
$ pnpm ts-node ./src/main.ts
throw new ERR_MODULE_NOT_FOUND(
^
CustomError: Cannot find module "...(略)\src\fileA"
好的,這看起來是找不到 fileA 這檔案。去查了一下會發現,ESM 要求需要寫副檔名,畢竟這系統是給瀏覽器看的,得給上才行。
來改一下,加上副檔名 .ts
。
// ./src/main.ts
import { fileA } from './fileA.ts'
^^^
// An import path cannot end with a '.ts' extension.
// Consider importing './fileA.js' instead.
然後會看到 VSCode 直接在這行出現紅線警告,跟你說不可以加上副檔名,因為 TS 的機制是不寫副檔名的。
但是直接執行卻是可以跑的,可是,無法編譯……
$ pnpm ts-noode ./src/main.ts
esm
$ pnpm tsc
Error: An import path cannot end with a '.ts' extension...
挖哩勒,這可怎麼辦!? ESM 規定要寫副檔名,可 TS 規定不能寫副檔名,這啥鬼?? 我就卡在這卡了快一整天,真的要瘋了。
後來搜尋到一個解法是,「不寫副檔名」但是要開啟一個實驗性功能。
Customizing ESM specifier resolution algorithm — Node.js
— experimental-specifier-resolution
# ok
$ pnpm ts-node --experimental-specifier-resolution=node ./src/main
esm
# compeile ok
$ pnpm tsc
# 但編譯後也須依賴這設定
$ node -experimental-specifier-resolution=node ./dist/main
這個官方文件就直接說不建議使用,這顯然不是個好方法。
而另一個做法是後來意外發現的,理論上最終都會編譯成 .js
來執行,所以要寫副檔名的話,應該要寫的是 .js
而不是 .ts
,否則編譯之後該如何執行? TS 是編譯器,並不是打包工具,他不會處理我們寫的 path 的。
測試一下,還真可以了!! 且這個做法比上面使用實驗性功能要合理的多。
// ./src/fileA.ts
export const fileA = 'esm'
// ./src/main.ts
import { fileA } from './fileA.js'
console.log(fileA)
// exec
$ pnpm ts-node ./src/main.ts
esm
// build and exec in node
$ pnpm tsc
$ node ./dist/main.js
esm
Typescript 新設定 NodeNext
上面那個做法雖然解決了,但還是有些疑點,就是 TS 應該要告訴我們,需要寫副檔名 .js
,而不是出現矛盾大對決。
撞了一堆坑之後,才發現原來有更新的設定方式,之前 google 或是問 chatGPT,拿到的資訊都過時了。現在有一個全新的 module 設定 node16/nodenext。
// tsconfig.jsoon
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext", // 新設定
"moduleResolution": "nodenext", // 新設定
"esModuleInterop": true,
// ...
}
}
這個設定會以 node 16+ 以上版本的方式來處理模塊,並支持解析新的副檔名 .mts
與 .cts
。原本用 ESNext 或 CommoJS 的話,是不吃副檔名設定的。
改用 NodeNext 之後,會參照 package.json
的 type 設定來決定要編譯成 ESM 還是 CJS,而這也影響是否要寫副檔名。(要注意 ts-node 還是要保留 esm 設定,才會跟著 nodenext 走)
// only work in CJS
import { fileA } from './fileA'
// only work in ESM
import { fileA } from './fileA.js'
有趣的是,type 設定為 module 啟用 ESM 時,現在會提示應該要加副檔名。但反過來,用 CJS 的時候如果加上副檔名,ts-check 不會有任何警告,可是在執行時會報錯。(苦笑)
基本上採用 NodeNext 的解析方式之後,就可以比較輕鬆的以 package.json 的設定為主,然後用副檔名設定為輔的方式,來控制 module 系統。
ESM 下的 dotenv 動態啟用
最後補充一下環境變數設定相關的問題。原本使用 CJS 的時候,應該很多人會用這種寫法來區分環境:
// 進入點置頂
if (process.env.NODE_ENV !== 'production') {
require('dotenv').config()
}
然後把 dotenv 裝在 devDependencies
上,這樣在正式環境時就可以不安裝 dotenv,執行 code 也不會有問題。
但是 ESM 的運作原理不太一樣,沒辦法重現這個操作。必須要裝在 dependencies
上才行,不然正式環境下第一行會掛掉。
import dotenv from 'dotenv'
if (process.env.NODE_ENV !== 'production') {
dotenv.config()
}
如果用 ESM 的動態 import() 寫,其實也不行。因為 ESM 會先把 import 都跑完,才去跑動態 import()。
// .env
FOO="BAR"
// ./src/fileA.ts
console.log('fileA FOO', process.env.FOO)
export const fileA = 'esm'
// ./src/main.ts
if (process.env.NODE_ENV !== 'production') {
const dotenv = await import('dotenv')
dotenv.config()
console.log('setup FOO', process.env.FOO)
}
import { fileA } from './fileA.js'
// ...
// =====================
// exec:
// fileA FOO undefined
// setup Foo BAR
就算使用 top-level await 也沒用,固定的 import 關鍵字會先被提升並執行,而導致被 import 的檔案吃不到 .env
的內容。
那怎麼辦? 這樣寫起來會超級麻煩,難道不能用 ESM 了!?
其實 dotenv 可以在 cmd 下指令去 preload
,這樣就不用寫在 code 裡面了。
# -r 是 -require 的 alias, node 跟 ts-node 都支持
$ pnpm ts-node -r dotenv/config ./src/main.ts
以上!!
相關參考: