跳到主要内容

19 篇博文 含有标签「Node.js」

查看所有标签

Electron 打包后 .env 读不到?配置在 userData 目录而非项目根

· 阅读需 5 分钟

Electron 应用开发时配置读得好好的,用 electron-builder 打包安装后,所有 process.env.XXX 全部变 undefined,依赖配置的模块接连报错。

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

TL;DR​

打包后 .env 不在 process.cwd() 里,而在 app.getPath('userData') 目录。

三步解法:

  1. 主进程启动时检测 userData/.env 是否存在,没有就从包内 .env.example 复制一份(播种)
  2. 把 userData/.env 的完整路径写进 process.env.ENV_PATH
  3. 配置模块用 dotenv.config({ path: process.env.ENV_PATH || '.env' }) 读取

问题现象​

开发阶段(electron .)一切正常;打包安装后(双击图标或开始菜单启动),配置全空:

undefined
undefined
TypeError: Cannot read properties of undefined (reading 'xxx')

process.cwd() 跟着启动方式走:从哪个目录启动就是哪个目录。所以从应用自身目录手动启动时恰好能读到,双击图标启动就读不到——同一个包,启动方式不同行为不同。

根因:process.cwd() 在打包后不可依赖​

dotenv.config() 不传参数时读的是 process.cwd()/.env。process.cwd() 是「进程启动时所在的目录」,不是「应用安装的目录」——两者只在开发阶段恰好相同:

启动方式process.cwd()
开发:项目根目录跑 electron .项目根目录 ✓ .env 在这
Windows 双击 exeexe 所在目录
macOS 双击 .app/(根目录)

打包安装后,.env 想跟用户交互只有两条路,一条都不通:

  • asar 归档只读:electron-builder 把应用资源打進 app.asar,运行时只读。就算把 .env 打进去,用户也改不了,重新打包才能更新配置
  • 安装目录不可写:Program Files、/Applications 这类目录写文件需要提权,普通运行写不进去

Electron 为「属于这个用户的运行时数据」提供了规范目录:app.getPath('userData')。它按平台落在用户目录下(macOS ~/Library/Application Support/<应用名>、Windows %APPDATA%\<应用名>、Linux ~/.config/<应用名>),保证存在、保证可写、卸载重装不丢。.env 应该住这里。

解法:播种 + ENV_PATH 指路​

改动集中在两个文件,共十几行。

第一步:主进程启动时播种(electron/main.js,放在创建窗口、启动 Express 之前):

const fs = require('fs');
const path = require('path');
const { app } = require('electron');

// ── .env path setup ──
const userDataPath = app.getPath('userData');
const envPath = path.join(userDataPath, '.env');
// rootDir = 应用打包根目录,取 app.getAppPath() 或按项目结构定义
const envExamplePath = path.join(rootDir, '.env.example');

if (!fs.existsSync(envPath) && fs.existsSync(envExamplePath)) {
fs.copyFileSync(envExamplePath, envPath);
console.log(`[electron] .env copied to ${envPath}`);
}
process.env.ENV_PATH = envPath;

逻辑:userData 下没有 .env 就从包内的 .env.example 复制一份(.env.example 在 asar 里只读没关系,它只是模板),然后把最终路径挂到 process.env.ENV_PATH。

第二步:配置模块按指路读取(src/config.js):

import dotenv from 'dotenv';
dotenv.config({ path: process.env.ENV_PATH || '.env' });

|| 后面的 '.env' 兜底非 Electron 场景(比如同一份代码直接用 Node 跑),开发时行为不变。

第三步:用户改配置零重装。 配置错了或者要换环境,直接编辑 userData 目录里的 .env,完全退出应用再启动即可——dotenv 只在进程启动时读一次文件,改完不重启不生效。

这套「userData 存运行时状态」的模式还能装下别的:登录态、缓存、日志,都放这里。我们同一个工具里 Cookie 登录态的存放也用了它,那次的完整排查见 Puppeteer 被反爬检测拦截?从 Chrome CDP 到 Electron 的替代方案。

注意事项

process.env.ENV_PATH 必须在配置模块被 import 之前设置好——dotenv.config() 只在调用那一刻读文件。主进程入口文件的第一段就做播种,别放进 app.whenReady() 回调里:如果有模块在更早的 import 链上就 require('dotenv').config(),会读不到路径。

常见问题​

Electron 打包后配置文件放哪里?​

放 app.getPath('userData') 目录——macOS 是 ~/Library/Application Support/<应用名>,Windows 是 %APPDATA%\<应用名>,Linux 是 ~/.config/<应用名>。这是 Electron 保证的用户级可写目录;打包产物里的 asar 归档是只读的,项目根目录在用户机器上也不存在。

Electron 打包成安装包后 .env 为什么读不到?​

dotenv 默认读 process.cwd() 下的 .env,开发时 cwd 是项目根所以正常;打包安装后 cwd 是启动器所在目录(macOS 双击启动时甚至是 /),应用资源又被打进只读的 asar 归档,.env 根本不在读取路径上。解法是主进程启动时把 .env 播种到 userData,再把路径交给 dotenv.config({ path })。

.env.example 需要打包进应用吗?​

需要。它作为首次运行的播种模板随包分发(在 asar 里只读正好),主进程检测到 userData 下没有 .env 时复制一份过去,用户直接编辑 userData 里的 .env 就能改配置,不用重新安装。

CCLEE

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

合作咨询

运行时改了环境变量不生效?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能力落地于真实商业场景。

合作咨询

npm audit 报警归因错目录?多包部署先按 audited N 对包树

· 阅读需 5 分钟

在一次前后端同仓的多包项目部署时,部署日志里 npm audit 输出 3 个 high 漏洞,顺着日志把它记到了后端名下——修完才确认这 3 个 high 全在前端包树里,后端从始至终是另一组 moderate。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析平台,自动洞察市场趋势、用户行为与销售数据;前端在仓库根目录、后端在 server/ 目录,同仓多包分开部署。

TL;DR​

npm audit 的摘要行只有数量、不带路径;部署流顺序执行多个目录的 npm install 时输出串联在一起,摘要无法区分归属。解法:用每棵包树的唯一指纹——audited N packages 的 N——先对号入座,再用 package.json 的 overrides 钉住有漏洞的传递依赖,二次部署两端归零。

问题现象​

部署脚本先后在前端(仓库根)和后端(server/)执行 npm install,日志混在同一股输出里:

# 部署流输出(摘要行不带路径)
added 546 packages in 41s
found 3 high severity vulnerabilities

added 372 packages in 24s
found 4 moderate severity vulnerabilities

交接记录把「3 high」归因到了 server/。按这个方向去查后端依赖链,怎么都对不上——server/ 的 audit 无论在本地还是服务器跑,结果都是 4 moderate,从没出现过 high。「部署日志看得见、归属对不上」是混合部署流的常见病,此前踩过的前端部署后线上未更新也是这一类。

根因​

npm audit 摘要行只有「found X vulnerabilities」,不带目录信息;紧挨着的 audited 546 packages 也很少有人下意识当成归属线索。两棵包树规模差异巨大(546 vs 372),这恰好是唯一稳定的指纹。

前端仓库根装的是 Vite + React + Ant Design Pro 全家桶,包树大;@ant-design/pro-components → @ant-design/pro-layout 引用了旧版 path-to-regexp,这正是 3 个 high 的来源。后端 server/ 是 Express + tsx 的精简依赖树,唯一的问题是 tsx → @esbuild-kit/core-utils → 旧版 esbuild 这条 moderate 链。

「报错位置与真因错位」在部署排查里不止一例:另一次是 .env 密码含 # 被 dotenv 静默截断——鉴权 401 把矛头指向凭据,真因却藏在 dotenv 的解析规则里。

解决方案​

步骤 1:按 audited N 对目录​

在本地各目录分别 npm install(或直接读部署日志的 added N packages),记录包树规模:

cd <repo-root> && npm install 2>&1 | tail -2   # added 546 packages ...
cd server && npm install 2>&1 | tail -2 # added 372 packages ...

部署日志里 found 3 high 紧跟在 added 546 后面 → 前端;4 moderate 跟在 added 372 后面 → 后端。归属定对了,后面才不用白跑。

步骤 2:展开漏洞链​

npm audit                # 看 Path 字段,完整依赖链
npm ls path-to-regexp # 或反查某个包被谁依赖

前端输出确认链路:@ant-design/pro-components → @ant-design/pro-layout → path-to-regexp(旧版本,3 high)。

步骤 3:用 overrides 钉住传递依赖​

前端仓库根 package.json:

{
"overrides": {
"path-to-regexp": "^8.4.2"
}
}

后端 server/package.json(顺带把 tsx 升到新版):

{
"overrides": {
"@esbuild-kit/core-utils": {
"esbuild": "^0.25.12"
}
}
}

overrides 支持嵌套写法,只影响指定父依赖之下的子依赖版本——比全局覆盖一个包名更精准。

步骤 4:重装验证​

rm -rf node_modules package-lock.json && npm install && npm audit

两端重跑部署后,audit 均为 0 vulnerabilities。

注意事项

  • overrides 是 npm 8.3+ 的能力,只写在包根 package.json 生效;改完必须重新 npm install 刷新 lockfile,否则不生效。
  • 把依赖钉到跨大版本(如 path-to-regexp 旧版 → 8.x)时,API 可能不兼容依赖它的上层库。合入前务必跑通构建并对关键页面做回归,别只看 audit 归零。
  • 「audited N」指纹只在包树稳定时可靠:依赖一变 N 就变。用它做归属判断没问题,别把它写进长期脚本当断言。

常见问题​

npm audit fix 跑了为什么漏洞还在?​

npm audit fix 只会升级 semver 允许范围内的版本。漏洞出在传递依赖上、且被上层包的版本范围钉死时,fix 改不动它,需要用 package.json 的 overrides 强制钉版本,然后重新 npm install。

怎么定位 npm audit 报的漏洞在哪条依赖链上?​

看 npm audit 完整输出的 Path 字段,它列出从直接依赖到漏洞包的完整链路;也可以用 npm ls <包名> 反查依赖方。摘要行只有数量,不带任何路径信息。

多包项目的 npm audit 结果怎么对应到具体子项目?​

部署日志的摘要不带目录名,按各目录安装时 added N packages / audited N packages 的包树规模对号入座即可。在本地分别对每个目录 npm install 一次,记录各自的 N,之后就能稳定对应。

CCLEE

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

合作咨询

systemctl 显示 inactive 进程却在跑?裸进程探活的 false negative

· 阅读需 5 分钟

在编写服务器监控探活时,systemctl is-active redis-server 返回 inactive,但 Redis 实际正在正常服务请求。

在开发 AI 运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。监控面板需要准确实时反映各基础组件状态,而 Redis 的状态判断一开始就给出了错误信号。

TL;DR​

systemctl is-active 只对 systemd 通过 unit file 管理的服务 有效。如果 Redis(或任何服务)是裸进程启动,没有对应 .service unit,systemctl 永远查不到它的真实状态,返回 inactive(退出码 3)。可靠探活要绕开 systemctl,直接用 pgrep 查进程或 ss 查端口监听。

问题现象​

探活接口对 Nginx 和 Redis 同时调用 systemctl is-active:

$ systemctl is-active nginx
active # ✅ 正常

$ systemctl is-active redis-server
inactive # ❌ 看似 Redis 没跑
$ echo $?
3 # exit code 3 = inactive

但所有业务接口都能正常读写 Redis,/api/v1/server-monitor/status 却报告 Redis 宕机。

根因​

systemd 是一个进程管理器,它只能感知自己启动和托管的单元(unit)。当你用 systemctl start redis-server 或让 systemd 读 redis-server.service 启动时,systemd 记录了该单元的状态,is-active 才能返回 active。

而这台服务器上的 Redis 是以裸进程方式启动的——直接运行 redis-server 或通过 nohup/自定义脚本拉起,并没有注册为 systemd 服务。于是:

  • systemd 的单元列表里根本没有 redis-server.service;
  • systemctl is-active redis-server 找不到该单元,按 inactive 处理,返回 exit 3;
  • Nginx 相反,是标准的 systemd 服务,所以 is-active 正常命中 active。

一句话:is-active 查的是 systemd 视图,不是系统进程视图。进程在跑和 systemd 知道它在跑,是两回事。

解决方案​

探活逻辑改为「systemctl 主判断 → 进程/端口降级」的链式检测。systemctl 命中则直接采用;失败时用 pgrep 或 ss 兜底确认进程真实存活。用 execFileSync(不经过 shell、参数以数组传递)避免命令注入:

import { execFileSync } from "node:child_process";

/** 安全执行单条命令(不经过 shell),非零退出统一返回 null */
function sh(cmd: string, args: string[]): string | null {
try {
return execFileSync(cmd, args, {
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000,
})
.toString()
.trim();
} catch {
return null; // inactive / 进程不存在 / 超时 都走这里
}
}

/**
* 探活某服务是否在运行:systemctl 主判断,裸进程降级。
* @param unit systemd 单元名(如 "nginx")
* @param proc 进程名(如 "redis"),用于 pgrep 降级
* @param port 监听端口(如 6379),用于 ss 降级
*/
function isServiceUp(unit: string, proc?: string, port?: number): boolean {
// 1. 先走 systemctl(标准 systemd 服务)
const st = sh("systemctl", ["is-active", unit]);
if (st && st !== "inactive" && st !== "unknown") {
return true; // active 或 activating/reloading 等中间态
}

// 2. 降级 A:pgrep 按进程名找 PID
if (proc && sh("pgrep", ["-f", proc])) return true;

// 3. 降级 B:ss 按端口确认监听
if (port) {
const listening = sh("ss", ["-lnt"]);
if (listening && listening.includes(`:${port} `)) return true;
}

return false;
}

// Nginx:标准 systemd 服务,systemctl 直接命中
const nginxUp = isServiceUp("nginx");

// Redis:可能是裸进程,传进程名 + 端口兜底
const redisUp = isServiceUp("redis-server", "redis", 6379);

对应用层而言,更稳的最终确认是让服务自己回答——Redis 的 PING 命令、PostgreSQL 的 SELECT 1、HTTP 服务的健康检查端点。端口监听只能证明"进程起来了",不能证明"服务 ready",所以关键路径上建议再加一层应用层探活:

$ redis-cli ping
PONG # 进程在跑 + 能响应 = 真正存活

常见问题​

为什么 systemctl is-active 显示 inactive 但进程实际在运行?​

systemctl 只查询 systemd 通过 unit file 管理的服务。如果进程是用 nohup 或直接命令启动的裸进程,没有对应 .service unit,systemd 既不认识它、也不追踪它,is-active 就只能返回 inactive(exit 3)。这是 systemd 视角的盲区,不是进程真的挂了。

怎么可靠检测一个进程是否在运行?​

不要只依赖 systemctl。用 pgrep <进程名> 查 PID,或 ss -lntp | grep <端口> 确认端口监听;这些命令查的是系统进程/网络栈,与是否被 systemd 管理无关。对关键服务,再加一层应用层探活(如 redis-cli ping),既验证进程存在又验证服务可响应。

注意事项

  • 单元名 ≠ 进程名:systemctl is-active redis-server 里的 redis-server 是 unit 名,可能与实际进程名(redis-server 或 redis)不同,别混用。
  • 生产环境注意超时:探活命令应设置短超时(如上例的 2s)并捕获异常,避免某个命令卡住拖垮整个监控接口。
  • 容器化服务另说:跑在 Docker 里的服务在宿主机 systemctl 看不到,应直接用 docker inspect 或容器健康检查 API,不要套用本文的 pgrep 降级。
  • 治本方案:把裸进程迁到 systemd unit(配 Type=、Restart=always),既能让 is-active 准确,又能享受 systemd 的自动拉起能力。

CCLEE

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

合作咨询

JavaScript throw; 报 SyntaxError?JS 没有 bare rethrow,重新抛出必须 throw e

· 阅读需 5 分钟

在 catch 块里想"把异常原样往上抛",顺手写了 throw;——习惯了 C# 的 bare rethrow 写法——结果 tsx/esbuild 直接转换失败:Unexpected ";"。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。

TL;DR​

JavaScript 没有 bare rethrow 语法。throw;(裸 throw)在 Node、tsc、esbuild 三个工具链里都是编译期 SyntaxError。要重新抛出捕获到的异常,必须 throw e(catch 块得带绑定参数);想换成新异常就 throw new Error(...)。

问题现象​

同一个 throw;,在不同工具链里的报错措辞不同,但全是语法错误(不是运行时错误):

try {
something();
} catch {
throw; // ← 裸重抛
}
工具链报错
tsx / esbuildERROR: Unexpected ";"(转换失败)
Node.js 原生(.js / .mjs)SyntaxError: Unexpected token ';'
TypeScript 编译器(tsc)error TS1109: Expression expected.

最迷惑的是 esbuild 那条 Unexpected ";"——很容易让人以为是 "esbuild/tsx 不支持某种新语法"。但把同一段代码丢进 Node 原生跑,报的是一模一样的 SyntaxError。这不是工具的局限,是语言本身就没有这种写法。

根因​

ECMAScript 的 throw 语句语法强制要求一个表达式:

ThrowStatement : throw Expression ;

也就是说 throw 后面必须跟一个值(throw err、throw new Error()、throw "fail"),分号前不能为空。JavaScript 没有"裸 throw = 重新抛出当前异常"的语义——这是它和 C# / Java / Python 的关键区别:

语言重新抛出当前异常是否需要捕获变量
C#throw;不需要
Javathrow e;需要
Pythonraise不需要
JavaScriptthrow e;需要

一个常见的混淆点:ES2019 引入了 optional catch binding(catch {} 可以省略参数),但这和 bare throw 是两回事。即使 catch 带了绑定,写 throw; 依然报错——

try { f(); } catch (e) { throw; }   // 仍是 SyntaxError,参数 e 不会自动喂给 throw

实测在 tsx 里同样是 Unexpected ";"。throw 后面的表达式不能省,没有例外。

解决方案​

按"想干什么"对号入座:

// 1. 重新抛出原异常(rethrow)—— 最常见诉求
try {
doWork();
} catch (e) {
log(e);
throw e; // ✅ 带上 e
}

// 2. 换成新异常(wrap)
try {
doWork();
} catch (e) {
throw new Error(`处理失败: ${e.message}`); // ✅ throw + 表达式
}

// 3. 用 ES2019 的 catch {} 时,省略了参数就没法 rethrow,只能抛新异常
try {
doWork();
} catch {
throw new Error("doWork 失败"); // ✅ 这里写 throw; 是错的
}

最小可运行复现 + 修复,直接用 tsx 跑:

function risky(): void {
throw new Error("origin");
}

function rethrowOptional(): void {
try {
risky();
} catch (e) { // ← 必须接收 e
console.log("caught, rethrowing");
throw e; // ← 而不是 throw;
}
}

try {
rethrowOptional();
} catch (e) {
console.log("recovered:", (e as Error).message); // origin
}

关于调用栈:throw e 复用的是同一个 error 对象,它的 .stack 在 new Error 时就已经捕获,rethrow 不会覆盖;只有 throw new Error(...) 才会从当前抛出点重新生成栈。所以"rethrow 会不会丢栈"的答案是——不会,只要你不 new 一个新的。

异常处理的另一个常见坑,是 catch 块干脆把异常吞掉、对外表现为静默失败,见 Python 任务全标 failed 却不报错?try/except 吞掉了异常——跨语言都值得警惕。

注意事项​

注意事项

  • 元凶不是 optional catch binding:catch {}(ES2019)本身合法,问题只在 throw;。别为了"修 throw"去给 catch 强加参数,除非你确实要用那个变量。
  • async/await 同理:try { await f() } catch (e) { throw; } 在 async 函数里同样是 SyntaxError,规则不分同步异步。
  • stack 保留:throw e 保留原始栈;throw new Error(...) 刷新栈。排查时想看最早抛出点就用前者。
  • 跨语言习惯对齐:从 C#/Python 转来 JS,把 throw; / raise 直接搬过来必踩;团队里 review 时留意这个模式。

常见问题​

JavaScript 怎么重新抛出(rethrow)捕获到的异常?​

用 throw e,且 catch 必须带绑定参数:catch (e) { ...; throw e; }。JavaScript 没有 bare rethrow,单独写 throw; 是 SyntaxError,Node、tsc、esbuild 三个工具链都会在编译期拒绝,这不是任何一个工具的局限。

JavaScript 重新抛出异常会保留原始调用栈吗?​

会。throw e 复用的是同一个 error 对象,它的 .stack 在 new Error 构造时就已经捕获,rethrow 不会覆盖或重置。只有 throw new Error(...) 才会从当前抛出点重新生成调用栈——所以排查时要看最早抛出位置,就用 throw e。

JavaScript try/catch 里怎么正确 rethrow?​

catch 必须先接收参数,再把它抛回:try { ... } catch (e) { log(e); throw e; }。如果用了 ES2019 的 catch {}(省略参数),就没有变量可抛,只能 throw new Error(...) 抛一个新异常。无论哪种,throw 后都必须跟表达式,throw; 永远非法。

CCLEE

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

合作咨询

dotenv 值被 # 静默截断?.env 特殊字符加双引号并刷新进程

· 阅读需 7 分钟

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。

TL;DR​

dotenv 把未加引号的值中任意位置的 # 当作行内注释。KEY=value#hash 实际加载的值是 value,#hash 被丢弃且无任何报错。解法分两步:.env 中含 # 的值用双引号包裹,改完用 pm2 restart 应用名 --update-env 强制刷新进程环境——不刷新等于白改,PM2 会把缓存的旧值原样注入新进程。

问题现象​

后端调用上游服务,一直返回 401 Invalid credentials:

POST /api/v1/dag/trigger → 500
日志堆栈:Airflow JWT auth failed (401): {"detail":"Invalid credentials"}
at getJwtToken (airflow-client.ts)

第一反应是密码配错或账号失效。排查发现 .env 文件里写的密码是 24 位、含 # 和 &:

AIRFLOW_PASSWORD=Aq7#mZx&V3nKp9RtWu2yBc4d

但 Node.js 进程实际加载到的 process.env.AIRFLOW_PASSWORD 长度只剩 3——# 后面的 21 个字符整段消失。用完整密码直接 curl 上游鉴权接口 → 返回 201;用 .env 解析出来的残缺值 → 返回 401。账号没问题,是 .env 加载出来的值被截断了。如果你搜的是「.env 配置不生效」「环境变量值不对」「密码明明正确却提示密码错误」,都是同一类问题。

根因:dotenv 把未加引号值中的 # 当行内注释​

dotenv 解析 .env 时,未加引号的值遇到 # 就结束,# 及其后内容按行内注释丢弃,整个过程没有任何警告。这个行为符合 dotenv 文档,但静默是它最毒的地方——不报错、不告警,新版启动时多打印一行 injected env 摘要(实测 18.0.4),也只显示注入了几个键,不提示截断。

用项目里的 dotenv 16.6.1 和当时的最新版 18.0.4 各跑了一遍解析矩阵,结果完全一致:

.env 写法实际加载值
A=val#hashval
B=val #hashval
C="val#hash"val#hash
H='val#hash'val#hash
D=val&moreval&more
E=val with spaceval with space
I="val" # commentval

三件事值得注意:

  • 不需要空格。shell 里 # 前有空格才开注释,dotenv 不挑——val#hash 这种紧贴写法照样截断。密码生成器产出的强随机串(JWT_SECRET、API_KEY、DATABASE_URL)里 # 出现在任意位置,正是高发雷区。
  • 引号是字面开关。单双引号内的 # 都按普通字符保留;引号结束后再写 # comment 仍按注释处理。
  • & 和空格在 dotenv 里是安全的。实测两者都不触发截断,会咬人的只有 #;core dotenv 也不做 $VAR 变量展开(那是 dotenv-expand 插件的事)。

解决方案:值加双引号,重启强制刷新环境​

第一步,.env 里含 # 的值用双引号包裹:

# 被截断:进程里实际拿到 Aq7
AIRFLOW_PASSWORD=Aq7#mZx&V3nKp9RtWu2yBc4d

# 正确:完整保留
AIRFLOW_PASSWORD="Aq7#mZx&V3nKp9RtWu2yBc4d"

第二步,重启时带上 --update-env:

pm2 restart analytics-api --update-env

这步不能省,原因是两个「默认不覆盖」叠在一起:

  1. PM2 缓存进程首次启动时快照的环境变量,pm2 restart 默认把旧快照原样注入新进程;
  2. dotenv 默认不覆盖 process.env 里已存在的同名键(实测:先设 process.env.X='oldvalue' 再 dotenv.config(),X 仍是 oldvalue)。

改了文件但不刷新进程环境,等于白改——进程还是拿 PM2 缓存的旧值。docker compose、systemd 场景同理,改完都要让服务真正重建环境(docker compose up -d --force-recreate、systemctl restart)。

改完核对一次进程实际读到的值,别靠猜:

# 打印长度,和 .env 里的原值对比
node -e "console.log(process.env.AIRFLOW_PASSWORD.length)"

更进一步,把关键变量的长度校验放进启动流程,让静默失败变成启动失败,下次第一时间暴露:

// 启动时验证关键变量长度,提前拦截截断
const required = ['AIRFLOW_PASSWORD', 'JWT_SECRET', 'DATABASE_URL'] as const;
for (const key of required) {
const v = process.env[key];
if (!v || v.length < 16) {
throw new Error(`${key} 未正确加载(长度 ${v?.length ?? 0}),请检查 .env 引号`);
}
}

边界与变体:docker compose 的规则和 dotenv 不一样​

# 截断不是所有 env 解析器的统一行为——docker compose 的规则恰好与 dotenv 相反,同一份文件跨工具结果不同。compose-spec 明文规定:「Inline comments for unquoted values must be preceded with a space」(未加引号值的行内注释必须以空格开头),官方示例里 VAR=VAL# not a comment 的加载结果就是 VAL# not a comment,原样保留。

同一行 API_PASSWORD=Kx9#mPwdotenv 实测docker compose(spec)
加载结果Kx9Kx9#mPw

单看 dotenv,只有 # 危险;但同一份 .env 常常被多个工具读——本地 shell、docker compose、PM2、CI 各有一套解析器。与其记每个解析器的差异,我们的取舍是统一一条硬规则:值里出现 #、&、空格,一律双引号包裹。多打两个字符,换跨工具的确定性。

注意事项

  • 双引号 + 不写 ${...}:涉及密码通常想要字面值,双引号内直接写字面内容最稳。
  • 别把 dotenv 的规则当通用规则:compose 行内注释必须带空格(见上表),跨工具共用一份文件时只依赖引号。
  • 容器注入不受此坑影响:Docker/Kubernetes 通过 environment: 注入的变量不走 dotenv;CI(GitHub Actions、GitLab CI)注入到 env 上下文的 secret 同样绕过 dotenv——受影响的只有 .env 文件 + dotenv.config() 这条路径。

常见问题​

为什么 .env 中含 # 的密码会变短?​

dotenv 把未加引号值中任意位置的 # 当行内注释,# 后内容全部丢弃——KEY=value#hash 实际只加载 value,无任何报错(实测 dotenv 16.6.1 与 18.0.4 行为一致)。用双引号包裹整个值即可完整保留。

dotenv 不生效怎么排查?​

三步:先确认 dotenv.config() 在所有 import 之前执行(ES Module 的 import 是静态提升的,详见 JWT 签名静默失败排查);再确认 .env 值里没有未加引号的 #;最后打印 process.env.XXX 的长度与 .env 原值比对——长度对不上就是被截断,而不是没加载。

docker compose 的 env 文件也把 # 当注释吗?​

规则不同。compose-spec 规定未加引号值的行内注释必须以空格开头,VAL=B#C 原样保留;dotenv 实测任意位置的 # 都截断成 B。结论:同一份 env 文件跨工具结果可能不同,双引号是唯一一致的写法。

CCLEE

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

合作咨询

Node.js AsyncLocalStorage 在回调里读不到值?EventEmitter 越界丢失上下文

· 阅读需 5 分钟

请求日志中间件在 res.on('finish') 回调里读 AsyncLocalStorage 的 traceId,getStore() 返回 undefined,每条响应日志的 traceId 都是空的。

在为客户开发 电商数据采集工具 时遇到此问题——服务端用 ALS 把每条请求的 traceId 贯穿整条处理链路,但响应日志死活关联不上,排查发现是「晚回调」丢了上下文。

TL;DR​

res.on('finish') 这类 EventEmitter 回调,触发时已经脱离了注册它时的 async context,als.getStore() 自然拿不到请求的 store。最稳的解法是在同步段把值取到闭包变量,回调里直接用闭包值;需要完整 store 时则在回调内 als.run(store, fn) 重建上下文。

问题现象​

一个看起来毫无问题的请求日志中间件:

// middleware/requestLog.js
import { als } from '../utils/als.js';

app.use((req, res, next) => {
res.on('finish', () => {
const store = als.getStore();
logger.info({
traceId: store?.traceId, // 响应日志里这里永远是 undefined
statusCode: res.statusCode,
}, 'request');
});
next();
});

中间件顺序没问题,traceId 在请求处理链路里(路由、业务函数)都读得到,唯独 res.on('finish') 里读不到。更迷惑的是:把 als.getStore() 挪到 next() 之前的同步段,它就有值。

根因​

AsyncLocalStorage 靠 Node 的 async_hooks 把 store 绑定到当前激活的 async context 上,顺着异步调用链往下传。als.run(store, fn) 的语义是:在 fn 执行期间(及其派生的异步任务里),getStore() 都能拿到这个 store。

问题出在 EventEmitter。res.on('finish', cb) 做的事是把 cb 注册成监听器,等响应发送完毕后由 EventEmitter 的事件循环触发。触发 cb 的那个 async context,是 EventEmitter 派发事件时所在的上下文——不是注册它时的请求上下文。而且响应发送通常发生在请求处理链路之后,请求对应的 als.run 作用域可能已经退出。

所以 cb 里 als.getStore() 拿到的是「当前激活上下文」的 store,而那个上下文根本不属于这次请求,结果就是 undefined(或更糟,串到别的上下文)。

凡是「注册时一个上下文、触发时另一个上下文」的回调都有这个坑:res.on('finish')、once、某些 setTimeout/setInterval、chrome.alarms 监听器等等。

解决方案​

按场景给两个模式,按需选。

模式 A(推荐):同步段闭包捕获​

如果你的回调只需要 store 里的某几个值(最常见就是 traceId),最简单也最可靠——在同步段(store 一定存活的时刻)把值取出来存进闭包,回调里直接用闭包变量,彻底不依赖 ALS:

app.use((req, res, next) => {
// 同步段:此时一定在 als.run 作用域内,getStore() 必有值
const traceId = als.getStore()?.traceId;
const start = Date.now();

res.on('finish', () => {
// 回调里用闭包里的 traceId,不再碰 ALS
logger.info({
traceId, // 稳定拿到
statusCode: res.statusCode,
durationMs: Date.now() - start,
}, 'request');
});

next();
});

这一步把「异步上下文是否还活着」这个不确定性,换成了一个确定的闭包引用。回调何时触发都不影响——值已经在闭包里了。

模式 B:als.run 重建上下文​

当回调里要调用一坨内部都依赖 getStore() 的代码(比如 logger 的 mixin、Sentry 的 scope 注入),逐个改成闭包不现实,就在回调入口重建上下文:

res.on('finish', () => {
const traceId = capturedTraceId; // 同步段捕获的值
if (traceId) {
// 在回调内重新建立 ALS 上下文,后续 record() 内部 getStore() 能正常拿到
als.run({ traceId }, () => record(res, start));
} else {
record(res, start);
}
});

als.run(store, fn) 会为 fn 建立一个新的、独立的 async context 并把 store 绑上去,fn 内部及它派生的异步调用都能读到。这比 als.enterWith 更安全——后者改写的是「当前共享上下文」,在并发场景下会串值,那是另一个坑,见 AsyncLocalStorage 并发读到错误的值?enterWith 改用 run 隔离上下文。

注意事项

  • 判断某回调是否会丢上下文,看它是不是「注册和触发分离」。res.on('finish')、once、跨 tick 的 setTimeout 都要警惕;而 await、fetch().then() 这类顺着 async chain 走的则天然继承,不用处理。
  • 模式 A 优先。它把问题降维成一个普通闭包,可读性最好,也不会引入「重建上下文」的隐式行为;只有回调内部有大量依赖 getStore() 的既有代码时,才上模式 B。
  • 别用 als.enterWith 在回调里补救——它在并发下会改写共享父上下文导致串扰,是比「丢上下文」更难查的 bug。

常见问题​

为什么 res.on('finish') 回调里读不到 AsyncLocalStorage 的值?​

res.on('finish', cb) 把 cb 注册为 EventEmitter 监听器,响应发送完毕后才触发。触发时的 async context 是事件派发所在的上下文,不是注册它的请求上下文,请求的 als.run 作用域可能已退出,因此 getStore() 返回 undefined。

怎么让 EventEmitter 回调重新拿到 AsyncLocalStorage 上下文?​

最简单的是在同步段把需要的值取到闭包变量,回调里直接用闭包值;如果回调内部有大量依赖 getStore() 的代码,则在回调入口用 als.run(store, fn) 重建上下文。前者优先,后者用于改造既有逻辑。

CCLEE

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

合作咨询

Node.js AsyncLocalStorage 并发读到错误的值?enterWith 改用 run 隔离上下文

· 阅读需 6 分钟

BullMQ worker 设了 concurrency: 3,上线后发现并发的几个 job 日志和 Sentry 上报全串了——A 任务的错误堆栈挂在了 B 任务的 traceId 下,排查时对着错误的链路看了半天。

在构建 AI运营 数据分析平台时遇到此问题——为电商运营智能分析市场趋势、用户行为与销售数据,后台用 BullMQ 并发跑分析任务,每个任务都靠 AsyncLocalStorage 打 traceId 做日志关联,并发一上来 traceId 就开始错乱。

TL;DR​

als.enterWith(store) 改写的是当前激活的共享父上下文,并发任务在 await 交错时会互相覆盖,最后写的那个值「赢」,所有交错的任务都读到同一个错误的值。解法是改用 als.run(store, fn) 把整个处理器包起来——它为每次调用建立独立的新上下文,退出后自动恢复,并发再高也互不干扰。

问题现象​

每个 job 进来时往 ALS 里塞自己的 traceId,处理器内部(含 await)读这个 traceId 打日志、上报 Sentry:

// worker.js —— 串扰写法
new Worker('analytics', (job) => {
als.enterWith({ traceId: job.data.executionId }); // 进来就写
return processJob(job); // 内部多处 await + logger.info({ traceId: als.getStore().traceId })
}, { concurrency: 3 });

单跑没问题,concurrency: 3 一开就出诡异现象:

# job A (executionId: aaa) 与 job B (executionId: bbb) 几乎同时进入
[worker] job A start traceId=aaa
[worker] job B start traceId=bbb
# A 内部 await 让出,B 调了 enterWith({bbb}),A 恢复后:
[worker] job A step2 traceId=bbb ← 串到 B 了
[worker] job A error traceId=bbb ← Sentry 上报到 B 的链路下

不是偶发,是只要并发就稳定复现,而且 traceId 永远等于「最近一次 enterWith 写入的值」。

根因​

关键在于 enterWith 改写的不是「这次调用专属」的上下文,而是当前激活的那个共享父上下文。

AsyncLocalStorage 的上下文是树状的:一个 async context 可以被多个子任务共享。als.enterWith(store) 的语义是「把 store 写到我当前所处的这个 context 上」。当 worker 用 concurrency: 3 时,三个 job 的处理器共享同一个父上下文(worker 循环的上下文),于是:

  • job A 调 enterWith({aaa}) → 共享上下文被写成 aaa;
  • job A await 让出执行权;
  • job B 调 enterWith({bbb}) → 同一个共享上下文被覆盖成 bbb;
  • job A 恢复,读 getStore() → 拿到的是 bbb。

这就是经典的「last-write-wins」串扰。await 点越多、并发越高,覆盖越频繁,错乱越严重。concurrency: 1 时看似正常,是因为根本没有交错——这也是它最坑的地方:开发时单线程调试永远发现不了。

Node 官方文档对此有明确告警:enterWith() 会产生预期外的副作用,推荐用 run() 替代。

解决方案​

把 enterWith 换成 run,并且用 run 包裹整个处理器(而不是某一段):

// worker.js —— 隔离写法
new Worker('analytics', (job) => {
return als.run(
{ traceId: job.data.executionId },
() => processJob(job), // 整个处理器都在独立上下文里跑
);
}, { concurrency: 3 });

als.run(store, fn) 的语义是:新建一个独立的 async context,把 store 绑定到它上面,在 fn 执行期间(及其派生的所有异步任务里)getStore() 都能拿到这个 store,fn 返回后上下文自动恢复到调用前的状态。

因为每次调用 run 建立的都是全新的、这次调用专属的上下文,并发任务之间天然隔离——job A 的 context 里永远是 aaa,job B 的里永远是 bbb,无论怎么在 await 处交错都不会互相覆盖。

这个改动的收益是直接的:

  • 每次调用独立快照:traceId 在进入 job 时绑定,整个处理链路(含所有 await、子函数、Sentry scope)读到的都是这个 job 自己的值;
  • 退出自动恢复:job 结束后上下文归位,不会泄漏到下一个 job 或 worker 主循环;
  • 并发安全:concurrency 调到多少都不影响,行为和单线程一致。

如果处理器是抽出来的函数(比如 processWorkflowJob、processAtomicJob),同样在 Worker 构造处包一层即可,不需要改处理器内部:

new Worker(queue, (job) => als.run({ traceId: job.data.id }, () => processWorkflowJob(job)), { concurrency });

注意事项

  • 只要存在并发(worker concurrency > 1、HTTP 并发请求、Promise.all 批处理),就别用 enterWith。它是为「单线程顺序设置一次」设计的,并发下必然串扰。
  • run 要包住整个处理器,不是只包入口的同步段——否则处理器内部的 await 之后又回到共享上下文,等于没改。
  • concurrency: 1 会掩盖这个 bug。开发时务必用目标并发数压测,否则上线才暴露。
  • 另一个 ALS 高频坑是回调里读不到值(上下文丢失),见 Node.js AsyncLocalStorage 在回调里读不到值?EventEmitter 越界丢失上下文。

常见问题​

als.enterWith 和 als.run 有什么区别?​

enterWith 把 store 写到当前激活的共享父上下文上,后续并发的异步任务会互相覆盖;run 则为回调新建一个独立的新上下文并绑定 store,回调结束后自动恢复到之前的状态,每次调用互不干扰。Node 官方推荐用 run 替代 enterWith。

为什么并发任务会读到错误的 traceId,串到别的请求?​

并发任务在 await 处交错时,enterWith 写入的值会被最后一次调用覆盖,所有交错的任务读到的都是同一个错误的 traceId。改用 als.run 给每次调用建立独立上下文即可隔离,并发再高也不会串。

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 fetch 代理不生效?undici 不读 http_proxy 环境变量

· 阅读需 4 分钟

在 WSL2 环境下设置了 https_proxy 环境变量,Node.js 的 fetch() 仍然直连外网超时。

在为客户构建 AI 电商工具时遇到此问题,记录根因与解法。

TL;DR​

Node.js 22+ 内置的 fetch() 基于 undici 实现,设计上不读取 http_proxy/https_proxy 环境变量。解决方案:安装 node-fetch@3 + https-proxy-agent,创建带代理配置的 fetch 实例,生产环境无代理时自动直连。

问题现象​

WSL2 环境下,https_proxy 已正确设置,curl 能正常访问外网:

echo $https_proxy
# http://172.30.224.1:7897

curl -I https://httpbin.org/ip
# HTTP/1.1 200 OK

但 Node.js 的 fetch() 直接超时:

await fetch('https://httpbin.org/ip');
// FetchError: fetch failed
// cause: TimeoutError: Headers Timeout Error

如果你同时遇到 WSL2 代理完全不通(连 curl 也不行),先排查防火墙问题。

根因​

Node.js v22+ 的全局 fetch() 由内置 undici 7.x 提供。undici 从设计上就不读取 http_proxy/https_proxy 环境变量——这是有意为之,不是 bug。

对比不同 HTTP 客户端的代理行为:

客户端读取环境变量走代理
curl自动读取 https_proxy✅
Node.js http/https 模块不读取❌
axios / node-fetch@3读取 https_proxy✅
Node.js 内置 fetch()(undici)不读取❌

这导致在必须通过代理才能访问外网的环境(WSL2、企业内网)下,fetch() 直连超时。

解决方案​

安装 node-fetch@3 和 https-proxy-agent:

npm install node-fetch@3 https-proxy-agent

创建一个自动感知代理的 fetch 实例:

import fetch from 'node-fetch';
import { HttpsProxyAgent } from 'https-proxy-agent';

const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;

export async function fetchWithProxy(url, options = {}) {
return fetch(url, { ...options, agent });
}

使用方式和原生 fetch() 几乎一致:

// 替换前
const res = await fetch('https://httpbin.org/ip');

// 替换后
const res = await fetchWithProxy('https://httpbin.org/ip');

为什么用 node-fetch 而不是 undici 的 ProxyAgent?​

Node.js v24 内置 undici 7.x,但 npm 上的 undici@8.x 的 ProxyAgent 与内置版本不兼容:

import { ProxyAgent, setGlobalDispatcher } from 'undici';

// Node v24 下报错:UND_ERR_INVALID_ARG
// npm undici@8 的 ProxyAgent 与内置 undici@7 的 setGlobalDispatcher 不兼容
setGlobalDispatcher(new ProxyAgent(proxyUrl));

node-fetch@3 + https-proxy-agent 与 Node 版本无关,不存在兼容性问题。生产环境无代理时 agent 为 undefined,自动直连。

注意事项

  • 不要尝试用 setGlobalDispatcher 覆盖全局 fetch——在 tsx watch 热重载环境下修改不会传播到工作模块
  • npm undici@8.x 的 FormData 类型与全局 FormData 不兼容,混用会导致 TypeScript 编译报错
  • node-fetch@3 是 ESM-only 包,import 导入即可,不支持 require()

常见问题​

为什么 Node.js fetch 不读 http_proxy 环境变量?​

Node.js 22+ 内置的 fetch 基于 undici 实现,undici 设计上不读取 http_proxy/https_proxy 环境变量。需要用 node-fetch 或 undici 的 ProxyAgent 手动配置代理。

Node.js fetch 如何通过代理发送请求?​

安装 node-fetch@3 和 https-proxy-agent,创建带代理的 fetch 实例。生产环境无代理时自动直连,不依赖 Node 版本。

WSL2 环境下还有其他网络陷阱——Docker Desktop 的 host 模式也会让容器端口在 WSL2 里访问不到,排查思路类似:先确认 curl 能否到达,再查应用层配置。

同样的 Node 版本升级还可能踩到其他坑——比如 Node 24 下 JWT 密钥格式变更,升级时建议一并检查。

遇到 Node.js 网络问题?

联系合作