Node.js 換目錄執行就 ENOENT?分清 cwd 與 import.meta.url
node scripts/report.mjs 能啟動,裡面的 readFile('./template.txt') 卻回報 ENOENT。腳本位置正確,不代表檔案讀取也以腳本為基準:Node.js 的相對檔案路徑依目前工作目錄解析;和模組一起發布的資源,可以用 new URL('./template.txt', import.meta.url) 定位。
更難發現的是另一個目錄也有同名檔案:讀取成功,內容卻錯了。因此驗收除了「不再 ENOENT」,還要比較讀到哪一份資料。
先決定這個檔案屬於誰
依 Node.js 檔案路徑文件,相對字串路徑以 process.cwd() 為基準;ESM 文件則示範以 import.meta.url 載入模組旁的檔案。兩個基準各有用途:
| 檔案契約 | 路徑基準 |
|---|---|
| 工具隨程式攜帶的模板、預設資料 | 模組位置 |
| 呼叫者在工作目錄準備的輸入檔 | cwd,或明確的 CLI 路徑參數 |
| 部署環境指定的設定檔 | 優先要求明確路徑,並記錄相對路徑如何解析 |
不要為了修好模板而把所有設定改成模組相鄰路徑。工具若原本應讀使用者專案的設定,改完可能默默讀取工具自己的預設值。
只想診斷時,可在 ESM 腳本加入以下片段;這是整合示意,實際路徑可能包含帳號或專案名稱,分享 log 前先檢查:
console.log({ cwd: process.cwd(), module: import.meta.url, resource: new URL('./template.txt', import.meta.url).href,});用同一個腳本,從兩個 cwd 啟動驗收
下面是完整的 path-check.mjs,以 node path-check.mjs 執行。只使用 Node.js 內建模組,在作業系統暫存目錄建立自己的 fixture,並輸出該目錄供檢查;不會寫進目前專案,也不會自動刪除資料。
import assert from 'node:assert/strict';import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';import { tmpdir } from 'node:os';import { join } from 'node:path';import { spawnSync } from 'node:child_process';
const fixtureRoot = await mkdtemp(join(tmpdir(), 'node-path-check-'));const appDir = join(fixtureRoot, '程式 目錄');const otherDir = join(fixtureRoot, '呼叫端');await mkdir(appDir);await mkdir(otherDir);await writeFile(join(appDir, '範本 檔.txt'), 'module-owned');await writeFile(join(otherDir, '範本 檔.txt'), 'caller-owned');const script = join(appDir, 'read.mjs');await writeFile(script, `import { readFile } from 'node:fs/promises';import { fileURLToPath } from 'node:url';const adjacent = new URL('./範本 檔.txt', import.meta.url);const fromModule = await readFile(adjacent, 'utf8');const fromPath = await readFile(fileURLToPath(adjacent), 'utf8');let fromCwd;try { fromCwd = await readFile('./範本 檔.txt', 'utf8');} catch (error) { if (error.code !== 'ENOENT') throw error; fromCwd = 'ENOENT';}console.log(JSON.stringify({ fromModule, fromPath, fromCwd }));`);
function run(cwd) { const result = spawnSync(process.execPath, [script], { cwd, encoding: 'utf8' }); if (result.error) throw result.error; assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout);}assert.deepEqual(run(appDir), { fromModule: 'module-owned', fromPath: 'module-owned', fromCwd: 'module-owned',});assert.deepEqual(run(otherDir), { fromModule: 'module-owned', fromPath: 'module-owned', fromCwd: 'caller-owned',});assert.deepEqual(run(fixtureRoot), { fromModule: 'module-owned', fromPath: 'module-owned', fromCwd: 'ENOENT',});console.log('三種啟動目錄檢查通過,fixture:', fixtureRoot);第二次從「呼叫端」啟動時,相對字串成功讀到另一份同名檔案;模組 URL 仍讀到 module-owned。第三次則讓相對路徑失敗,用來區分「不存在」與「存在但選錯」。子行程的 cwd 模擬啟動環境差異,沒有改動父行程的工作目錄。
URL 可以直接交給 fs,需要字串才轉檔案路徑
readFile() 接受 file: URL 物件。若其他函式只收檔案路徑字串,用 fileURLToPath()轉換;不要直接取 .pathname。URL 可能包含空白與中文的百分比編碼,Windows 磁碟機與 UNC 路徑也有自己的規則。上面同時驗證 URL 物件和轉換後字串讀到相同內容。
此方法適用本機 file: ESM,不會把遠端 URL 自動下載成檔案。CommonJS 有自己的 __dirname;若程式經過 bundler、打包成單一執行檔或部署到不同 runtime,還須確認素材是否實際被帶進產物。修改基準無法補出缺少的檔案,也不會修復權限或大小寫差異。
本文 fixture 由維護流程於 2026-10-28 在 macOS、Node.js v24.13.0 執行通過,包含中文與空白路徑、同名錯檔及 ENOENT。未執行 Windows、排程服務、容器或 bundler;正式環境應讀回已知內容或 hash,再確認部署檔案清單。若錯誤是 ERR_PACKAGE_PATH_NOT_EXPORTED,請改查 Node.js 套件公開入口,那是 import 契約,不能只靠更換 cwd 解決。
參考資料: