跳到主要内容

4 篇博文 含有标签「ESM」

查看所有标签

运行时改了环境变量不生效?ESM import 把旧值缓存成常量

· 阅读需 5 分钟

登录脚本运行时成功刷新了凭据,写进了 process.env;业务模块里请求照样 401——它 import 进来的那个常量,还是进程启动时的旧值。

在为客户构建数据采集工具时遇到此问题,记录根因与解法。

TL;DR​

ESM 模块只求值一次,import 进来的是加载那一刻的只读快照——之后 process.env 怎么更新,都传导不到已经 import 的常量里。

// ❌ 加载时缓存,之后永远是旧值
import { AUTH_TOKEN } from './config.js'

// ✅ 每次使用时读现值
function getToken() {
return process.env.AUTH_TOKEN || ''
}

原则一句话:需要运行时更新的值,消费侧必须动态读 process.env,不能 import 成模块级常量。

问题现象​

三个文件的分工:config.js 集中导出配置常量,login.js 负责登录并刷新凭据,runtime.js 拿凭据发业务请求:

// config.js —— 集中导出
export const AUTH_TOKEN = process.env.AUTH_TOKEN || ''
// login.js —— 登录后刷新凭据
process.env.AUTH_TOKEN = newToken // 运行时更新
console.log('[login] token refreshed')
// runtime.js —— 业务请求
import { AUTH_TOKEN } from './config.js'

fetch(url, { headers: { Authorization: `Bearer ${AUTH_TOKEN}` } })
// → 401:AUTH_TOKEN 还是进程启动时的旧值(或空串)

日志显示 token 刷新成功,请求头里带的却是旧凭据。打印 AUTH_TOKEN 的值和 process.env.AUTH_TOKEN 的值对比,两者不一致——process.env 是新的,import 来的常量是旧的。

根因:ESM 模块只求值一次​

ESM 规范里,一个模块的代码从首次被 import 到进程结束只执行一次,第二次 import 拿到的是同一个模块实例的缓存。

所以 runtime.js 里那行 import { AUTH_TOKEN } from './config.js' 的实际语义是:加载 config.js(求值 export const AUTH_TOKEN = process.env.AUTH_TOKEN || '',此刻把 process.env 的现值固定进常量),把这个值绑定给 runtime.js 作用域里的 AUTH_TOKEN。

这条绑定有两个特征,正是坑的来源:

  1. 只读:import 绑定的值在消费方不可重新赋值(ESM 的 import 绑定虽然指向导出的「实时绑定」,但 config.js 导出的是 const 常量,永远不会有新值)
  2. 与 process.env 脱钩:login.js 后来执行的 process.env.AUTH_TOKEN = newToken 只是改了 process.env 对象上的一个属性——config.js 里的求值早就结束了,没有任何机制把这次修改传导回已导出的常量

一句话:process.env 是一个可变的运行时对象,而 export const X = process.env.Y 是对它某一行的一次性快照。快照不会跟着原件变。

解法:消费侧动态读​

改法一(最小改动):用时取现值。 把 import 常量改成读 process.env:

// runtime.js
// import { AUTH_TOKEN } from './config.js' ← 删掉

fetch(url, {
headers: { Authorization: `Bearer ${process.env.AUTH_TOKEN || ''}` },
})

改法二(多处使用):收敛成一个 getter。 使用点多时,散落的 process.env.XXX 不好维护,集中到动态读取的函数:

// config.js —— 导出函数而不是常量
export const getToken = () => process.env.AUTH_TOKEN || ''
// runtime.js —— 调用时求值,永远现值
import { getToken } from './config.js'

fetch(url, { headers: { Authorization: `Bearer ${getToken()}` } })

改法三(配置项多):导出对象、按属性取。 对象属性访问天然是动态的:

// config.js
const env = {
get token() { return process.env.AUTH_TOKEN || '' },
}
export default env

// runtime.js
import env from './config.js'
env.token // 每次访问都读 process.env

三种写法同一个原则:把「求值时机」从模块加载推迟到每次使用。

同一个工具里,.env 的存放路径还有一层打包后的坑(userData 目录而非 cwd),两件事都属「配置读取时机与位置」,见 Electron 打包后 .env 读不到?配置在 userData 目录而非项目根。

注意事项

dotenv 也一样:dotenv.config() 只在调用那一刻读一次 .env 文件,之后再改文件、再调用普通 config 都不会更新已注入的值(override: true 重调才会覆盖,但仅对之后读 process.env 的代码生效——import 成常量的照旧是死值)。「改了 .env 不生效」先查是不是进程没重启,再查是不是 import 成了常量。

常见问题​

Node.js 环境变量怎么配置和读取?​

配置走 .env 文件加 dotenv,或系统级 export;代码里统一从 process.env.XXX 动态读取。关键结论:process.env 是唯一会被运行时更新传导的通道——模块顶层 import 进来的常量在首次加载时就固定了,之后 process.env 怎么变它都不会跟着变。

为什么更新了 process.env,其他模块读到的还是旧值?​

因为消费模块写的是 import { TOKEN } from './config.js' —— 这行代码在模块首次加载时求值一次,把当时的值复制进了本地常量,之后 config.js 内部和 process.env 的任何变化都传导不过来。改成每次用时读 process.env.TOKEN 即可拿到现值。

dotenv 更新 .env 文件后需要重启进程吗?​

最稳妥是重启:dotenv.config() 只在调用那一刻读一次文件。如果必须进程内热更新,要重新调用 dotenv.config({ override: true }) 且所有消费方都动态读 process.env——只要有一处 import 成常量,热更新就在那里断掉。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

转 ESM 报 Cannot access before initialization?隐式全局赋值

· 阅读需 7 分钟

在浏览器扩展里点击「提取本页」按钮时,控制台抛出 Cannot access 'Hc' before initialization——而 typecheck、build、常规测试全部通过,本地开发时也从未复现。

在为客户开发电商自动化数据采集工具时遇到此问题——批量抓取商品图片、SKU、价格与评价,清洗后导出结构化数据,支撑库存管理与竞品分析。「提取本页」正是该工具链里的采集入口,炸的是它的内容脚本模块。

TL;DR​

第三方经典脚本(vendored classic script)里有一处隐式全局赋值(CandidateElement = function(...),未声明直接赋值)。经典脚本里这是合法的全局变量创建,但文件被内联进 ESM 模块图后在严格模式下变成 ReferenceError,模块初始化当场中断,下游通过动态 import 拿到的命名空间因此处于暂时性死区(TDZ)。修法:vendor 文件头补一行 var CandidateElement;。

问题现象:构建全绿,点击即炸​

报错发生在用户点击时,不是页面加载时:

TypeError: Cannot access 'Hc' before initialization

三个反直觉的点:

  1. typecheck 通过——类型层面没有任何问题;
  2. build 通过——打包器只做静态分析,不执行模块代码;
  3. 常规测试通过——测试没有把该模块完整 import 一遍。

报错对象是 Hc 这种 2 字母压缩名,不是任何业务符号名。这个特征先记下,后面识别时会用到。

根因:隐式全局赋值如何中断 ESM 模块图​

出问题的是第三方库里的一行代码,位于 reader-finder.js:878:

// 经典脚本语义:未声明就赋值 = 创建全局变量,合法
CandidateElement = function(e, t) { ... }

整个故障链条有 4 步,逐环递进:

第 1 步:经典脚本语义下合法。 这个文件原本以 <script> 方式加载,非严格模式下「未声明直接赋值」会静默创建全局变量,原作者依赖了这一行为。

第 2 步:进入 ESM 后变成雷区。 文件被内联进扩展的 ESM 模块图,而 ESM 代码强制运行在严格模式下——隐式全局赋值直接抛 ReferenceError,模块初始化(module evaluation)当场中断。

第 3 步:中断沿模块图扩散。 内容脚本 content.js 的内联模块图在求值到这个 vendor 模块时停摆:靠前模块的消息监听器已经注册成功,靠后的模块 facade(门面导出)还没执行——模块处于「半初始化」状态。

第 4 步:动态 import 踩进 TDZ。 用户点击按钮时,代码通过动态 import 加载命名空间 facade。由于第 3 步的中断,这个命名空间处于暂时性死区,访问即抛 Cannot access 'Hc' before initialization——压缩名 Hc 正是那个没初始化完的模块内部绑定。

这就解释了所有现象:静态检查不执行模块代码所以全绿;报错在点击时才出现,因为动态 import 发生在点击处理器里;报错名是压缩随机名,因为炸的是被打包器改名过的模块内部绑定。

关于 ESM 动态 import 的另一个高频坑(模块找不到),见这篇:Node.js ESM 动态 import 报模块找不到?检查文件扩展名。

解法:vendor 文件头补一行 var 声明​

不改第三方逻辑,只把隐式全局变成显式声明——在 vendor 文件头部补上:

var ReaderArticleFinder;
var CandidateElement;

赋值从「创建全局变量」变成「给已声明变量赋值」,严格模式下合法,模块初始化不再中断,下游动态 import 拿到的 facade 正常可用。

同文件里的 ReaderArticleFinder 早就是这样处理的——同一个坑,这个库埋了两次,第一次修了,第二次(CandidateElement)漏了。

验证:用 Vitest 让它本地复现​

修复前先要能稳定复现,否则只能等线上验证。常规测试跑不到这条路径,但用 Vitest(jsdom 环境)直接 import 该模块可以:

import { describe, it, expect } from 'vitest';

describe('vendor reader-finder strict-mode', () => {
it('模块图完整初始化,不抛 ReferenceError', async () => {
const mod = await import('./lib/vendor/reader-finder');
expect(mod).toBeDefined();
});
});

这条测试在修复前能复现报错,且给出真实文件行号堆栈(reader-finder.js:878)——比线上压缩产物的 Hc 可定位得多。修复后转绿。

修完的完整验证是把提取链端到端跑一遍:模块图完整初始化 + 提取功能实际可用,两条都过才算闭环。

防回归​

把这个复现用例固化成冒烟测试(extractor.test.ts),并立一条入库纪律:新的经典脚本 vendor 进项目前,必须先过这个测试或等价的 strict-mode 检查。

注意事项

  • vendor 文件尽量保持原样以便 diff 上游,补 var 声明时在文件头加注释说明改动原因,避免下次更新 vendor 时被当作冲突冲掉。
  • 隐式全局通常不止一个:补声明前全文搜一遍「未声明直接赋值」的模式,本例同一文件里就有 2 处。
  • 这类问题与打包器无关——换 esbuild、rollup 结果一样,因为严格模式语义是语言层面的。

如何快速识别这类 TDZ 报错​

下次见到 Cannot access 'xxx' before initialization,按两个特征判断是不是同款问题:

特征同款问题其他 TDZ 问题
报错名压缩随机短名(Hc、Wt)业务符号名(myConfig)
报错时机交互触发(动态 import)时模块加载/页面加载时
静态检查全绿通常也能查出(let/const 重复声明类)

命中左列:优先怀疑 vendor 经典脚本的隐式全局,搜「未声明赋值」+ 用 Vitest 直接 import 复现。ESM 迁移期的另一类经典报错(CJS require ESM)见:Node.js require nanoid 报 ERR_REQUIRE_ESM?v5 改纯 ESM 的替代方案。

常见问题​

为什么 typecheck 和 build 全绿,运行时才报 Cannot access before initialization?​

静态检查和打包不执行模块代码,而隐式全局赋值的 ReferenceError 只在模块真正初始化时抛出。如果出问题的模块位于动态 import 的依赖链上,报错会推迟到用户交互那一刻——本例点击扩展按钮才炸,构建期 0 报错。用 Vitest(jsdom 环境)直接 import 该模块即可本地复现并拿到真实行号。

Cannot access 'xxx' before initialization 和暂时性死区(TDZ)有什么关系?​

动态 import 返回的命名空间对象,在依赖模块初始化中断后处于暂时性死区,访问其任何导出都抛这个错。识别线索是报错名:压缩产物里是 2 个字母的随机短名(如 Hc)而不是业务符号名,说明中断发生在模块图求值阶段,而非业务代码。

怎么修复 vendor 经典脚本的隐式全局赋值?​

在 vendor 文件顶部为每个隐式全局补 var 声明(本例 2 个:ReaderArticleFinder 与 CandidateElement),让赋值落在已声明变量上。一行声明消除整个模块图的初始化中断;新 vendor 文件入库前跑一次 strict-mode 冒烟测试即可提前拦截。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Node.js require nanoid 报 ERR_REQUIRE_ESM?v5 改纯 ESM 的替代方案

· 阅读需 5 分钟

在 CommonJS 项目里 require('nanoid') 生成唯一 ID,进程一启动就抛 ERR_REQUIRE_ESM 直接退出。

在为客户开发 电商数据采集工具 时遇到此问题——浏览器端实时抓取商品图片、SKU、价格与评价数据,清洗后导出结构化文件,服务端需要为每条请求生成稳定的 traceId 做跨服务日志关联。

TL;DR​

nanoid 从 v5 起改为纯 ESM 包,CommonJS 的 require() 无法加载它,必然抛 ERR_REQUIRE_ESM。如果你的项目还是 CJS,最省事的替代是 Node 内置的 crypto.randomUUID()——零依赖、CJS/ESM 通吃、生成的就是标准 UUID。

问题现象​

CJS 项目里一行最普通的引入:

// server.js(CommonJS)
const { nanoid } = require('nanoid');

const traceId = nanoid();

启动即崩,堆栈指向 nanoid 的入口文件:

node server.js

internal/modules/cjs/loader.js:905
Error [ERR_REQUIRE_ESM]: require() of ES Module
/node_modules/nanoid/index.js from server.js not supported.

Instead change the require of index.js in server.js to a CommonJS module,
or use a dynamic import() call.

注意它不是「偶尔报错」或「某些环境下报错」,而是确定性崩溃——只要进了 v5,CJS 这条路就走不通。

根因​

nanoid 在 v5 完成了 ESM-only 迁移:包的 package.json 不再带 CommonJS 入口,只导出 ESM。Node 的 CommonJS 加载器 require() 是同步的,无法同步加载一个 ESM 模块,于是直接抛 ERR_REQUIRE_ESM。

这不是 nanoid 的 bug,而是整个生态的模块格式演进:越来越多的包选择只发 ESM(got v12+、node-fetch v3、uuid v7+ 等都一样)。只要你的宿主项目是 CommonJS,遇到这类包就会撞同一堵墙。

如果你还撞过动态 import() 找不到模块,本质也是 ESM 解析规则的问题,可以看 Node.js ESM 动态 import 报模块找不到?检查文件扩展名。

解决方案​

按「改造代价从低到高」给三个方案,按需选。

方案 1(推荐):用 crypto.randomUUID()​

生成唯一 ID 的场景下,nanoid 的核心价值就是「短且唯一」。但只要这个 ID 不需要拼进 URL、不需要极致缩短,标准 UUID 完全够用,而且 Node 14.17+ 内置、零依赖:

// CommonJS 与 ESM 都能直接用
const { randomUUID } = require('node:crypto');

const traceId = randomUUID();
// => '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed'

这一步同时解决了三个问题:

  • 依赖归零:不再引入第三方包,也就不再被它的模块格式绑架;
  • 格式对齐:UUID 是跨语言、跨服务的通用格式,做日志关联、数据库主键都顺手;
  • CJS/ESM 通吃:node:crypto 是 Node 内置模块,两种模块系统下行为一致。

唯一要权衡的是长度——UUID 36 字符,比 nanoid() 默认的 21 字符长。对 traceId、主键这类场景,长度几乎不构成成本;如果是要拼进短链,才需要继续往下看。

方案 2:锁定 nanoid v3​

nanoid 的 v3.x 是最后一个兼容 CommonJS 的大版本,require 直接可用:

// package.json —— 显式钉死 v3
{
"dependencies": {
"nanoid": "^3.3.7"
}
}
const { nanoid } = require('nanoid');
const id = nanoid(); // 21 字符短 ID

适合「就是想要短 ID、又暂时无法把项目迁到 ESM」的情况。代价是停留在旧版,拿不到 v5 的后续更新。

方案 3:异步动态 import​

如果你必须用 v5,只能走 ESM 的异步加载:

// CommonJS 里用动态 import() 加载 ESM 包
async function makeId() {
const { nanoid } = await import('nanoid');
return nanoid();
}

// 调用处本身得是 async
const id = await makeId();

能用,但 nanoid 是同步生成 ID 的工具,被迫包一层 async/await 会把调用链一路传染成异步,通常不值得。

注意事项

  • 同一个坑不只 nanoid 一个:uuid v7+、node-fetch v3、got v12+ 都是 ESM-only,CJS 项目里 require 它们会报一模一样的 ERR_REQUIRE_ESM。判断方法是看目标包的 package.json 有没有 "type": "module" 或是否只导出 "import" 入口。
  • crypto.randomUUID() 需要 Node 14.17+;如果你的运行时更老,可以用 crypto.randomBytes(16).toString('hex') 自行拼装。
  • 别用 require('nanoid') 的同时又在 ESM 项目里 import nanoid——混用会让依赖树里同时存在新旧两份,行为更难预测。

常见问题​

为什么 Node.js 中 require('nanoid') 报 ERR_REQUIRE_ESM?​

因为 nanoid 从 v5 起只发布 ESM 产物,而 Node 的 CommonJS require() 是同步加载,无法加载 ESM 模块,加载到 nanoid 入口时直接抛 ERR_REQUIRE_ESM。这是 CJS/ESM 模块系统的硬性边界,不是配置问题。

nanoid v5 还能在 CommonJS 项目里使用吗?​

可以,但要么用 await import('nanoid') 异步加载(注意整条调用链会变 async),要么把版本锁定在仍兼容 CJS 的 v3.x。如果只是要一个唯一 ID,直接用 Node 内置的 crypto.randomUUID() 最省事,零依赖且两种模块系统都支持。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Node.js ESM 动态 import 报模块找不到?检查文件扩展名

· 阅读需 4 分钟

在为客户构建 SaaS 数据分析平台时遇到此问题,记录根因与解法。

TL;DR​

Node.js ESM 模式下,import('./path/to/module') 不会自动解析 ./path/to/module.js。TypeScript 编译的 dist 产物如果遗漏 .js 后缀,模块加载时抛出 ERR_MODULE_NOT_FOUND。如果这个 import 在延迟逻辑中(如定时器、条件分支),应用启动正常但运行一段时间后崩溃,PM2 表现为重启计数飙升。

解法:确保所有 ESM 动态 import 路径包含 .js 扩展名,并在 build 流程中自动修复。

问题现象​

PM2 状态显示应用不断重启:

│ name             │ ↺    │ status │ uptime │
│ analytics-api │ 9 │ online │ 28m │

错误日志每几分钟重复:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/domains/video/cleanup'
imported from /app/dist/server.js

但文件实际存在:

$ ls dist/domains/video/
cleanup.js executor.js queue.js

根因​

ESM 不自动解析文件扩展名​

Node.js 的 CommonJS (require()) 会自动尝试 .js、.json 等后缀。ESM (import) 不会。

// ❌ ESM 模式下找不到模块
import('./domains/video/cleanup')
// Node.js 查找: ./domains/video/cleanup (精确路径,无扩展名)
// 实际文件: ./domains/video/cleanup.js

// ✅ 必须带 .js 后缀
import('./domains/video/cleanup.js')

延迟 import 隐藏了问题​

这个 import 在定时器中延迟执行:

// server.ts — 启动时不立即执行
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler(); // 几秒后才触发
});

应用启动成功(DB 连接、端口监听都正常),定时器触发时 import 报错 → 进程崩溃 → PM2 重启 → 再次启动成功 → 定时器再次触发 → 再次崩溃。形成崩溃循环。

为什么之前没问题?​

旧版本的 dist 产物是通过手动 build 生成的,build 脚本包含 .js 后缀修复步骤。某次部署时跳过了 build 后的修复步骤,直接用 tsc 输出的产物部署,tsc 不修改 import 路径。

解决方案​

1. TypeScript 源码中写 .js 后缀​

TypeScript 官方推荐:即使在 .ts 文件中,也写 .js 后缀:

// ✅ TypeScript 源码中也写 .js
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler();
});

2. Build 后自动修复(推荐)​

在 build 流程中添加后缀修复脚本,自动为没有扩展名的 import 补上 .js:

{
"scripts": {
"build": "tsc && node fix-imports.js"
}
}

fix-imports.js 核心逻辑:

import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';

function fixImports(dir) {
for (const file of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, file.name);
if (file.isDirectory()) {
fixImports(fullPath);
} else if (file.name.endsWith('.js')) {
let content = readFileSync(fullPath, 'utf8');
// 修复动态 import: from 'xxx' → from 'xxx.js'
const fixed = content.replace(
/import\(['"](\.[^'"]+)['"]\)/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
// 修复静态 import: from 'xxx' → from 'xxx.js'
const fixed2 = fixed.replace(
/from\s+['"](\.[^'"]+)['"]/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
if (fixed2 !== content) {
writeFileSync(fullPath, fixed2);
}
}
}
}

build 流程自动为所有相对路径 import 补全 .js 后缀,TypeScript 源码保持无后缀写法,ESM 部署不再出现模块找不到的错误。

注意事项

  • 此问题只在 Node.js ESM 模式("type": "module" 或 .mjs 文件)下出现,CommonJS 不受影响
  • 静态 import(import ... from './foo')同样受此限制,不仅限于动态 import();ESM 的 import 提升还会导致另一个常见问题——dotenv 在 import 链之后才执行,环境变量读不到
  • 使用 tsx、ts-node 开发时不报错(它们会自动解析后缀),但 node dist/server.js 生产运行时出错
  • PM2 崩溃循环的典型特征:重启计数(↺)持续增长,每次 uptime 不超过几分钟