1010 字
5 分鐘

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 解決。

參考資料:

Node.js:File paths

Node.js:process.cwd()

Node.js:import.meta.url

Node.js:fileURLToPath()

Node.js 換目錄執行就 ENOENT?分清 cwd 與 import.meta.url
https://laplusda.com/posts/node-enoent-cwd-import-meta-url/
作者
Zero
發佈於
2026-10-28
許可協議
CC BY-NC-SA 4.0