Node SSR 报错:ERR_MODULE_NOT_FOUND(antd/es + @ant-design/icons)排查与通用修复指南
适用于:Node 16+(尤其 Node 20),SSR/CJS 环境,使用 antd 4.x 与 @ant-design/icons 4.x 典型错误:Cannot find module ... @ant-design/icons/es/components/Context 关键词:antd/es、@ant-design/icons、exports、external、SSR、pnpm
———
1. 错误现象(典型日志)
Error [ERR_MODULE_NOT_FOUND]: Cannot find module .../node_modules/@ant-design/icons/es/components/Context imported from .../node_modules/antd/es/config-provider/index.js Did you mean to import "@ant-design/icons/es/components/Context.js"?
关键点:
- Node 在 SSR 运行时加载了 antd/es/config-provider
- 其中又引用了 @ant-design/icons/es/components/Context(无 .js 后缀)
- Node 20 的 ESM 解析不接受无后缀路径 → 抛错
———
2. 结论先行:为什么会爆
本质原因: 在 CJS/SSR 运行时,实际执行到了 antd 的 ESM 子路径 antd/es/*。 而 antd 的 ESM 文件内部存在 无后缀导入,Node ESM 解析严格导致失败。
———
3. 为什么“以前没报错,现在报错”?——最常见的触发条件
这里的关键不是 exports/sideEffects 本身,而是 dist 产物里保留了 antd/es/*。
常见触发变化
A. external 规则变了(最常见)
- 旧规则:只 externalize 顶层包名(antd)
- antd/es/* 不外置,Rollup 会解析/打包,运行时不会再走 Node ESM 解析
- 新规则:外置子路径(antd/es/*)
- dist 里保留 require('antd/es/config-provider')
- SSR 运行时进入 Node ESM 解析 → 报错
B. 重新构建/发布
- 源码仍是 antd/es/*
- 以前构建/打包某种方式“隐式处理”掉了
- 现在产物原样输出导致 SSR 报错
C. Node 版本变化
- Node 20 的 ESM 子路径解析更严格
———
4. 为什么 exports / sideEffects 不是根因
exports 的作用
- 只决定 入口解析(require / import 走哪个入口)
- 不会改写 dist 内部的 import/require
sideEffects 的作用
- 只影响 tree-shaking
- 不会修改构建产物路径
✅ 结论: exports/sideEffects 不会让 dist/index.js 里“出现” antd/es 它们最多改变“入口文件选哪个”,但不会改变 dist 内部 import 语句。
———
5. 快速定位与确认(推荐命令)
用来确认是否仍在 dist 里保留了 antd/es
1) 验证 Node 直接 require antd/es 是否报错
node -e "require('antd/es/config-provider')"
2) 检查 dist 内是否还包含 antd/es
rg -n "antd/es" node_modules/.pnpm/**/node_modules/<你的包名>/dist
如果还能搜到 antd/es,就说明 dist 产物是根因。
———
6. 解决思路(推荐优先级)
✅ 方案 A(推荐,保留 ESM 优势)
保留源码 antd/es,但在 CJS 构建时重写为 antd/lib
优点:
- 浏览器构建仍能走 ESM tree-shaking
- SSR/CJS 不会触发 Node ESM 解析
核心做法: 在 Rollup CJS 构建中加一个 rewrite:
const rewriteAntdEsToLib = () => ({ name: 'rewrite-antd-es-to-lib', resolveId(source) { if (source === 'antd/es') { return { id: 'antd/lib', external: true }; } if (source.startsWith('antd/es/')) { return { id: source.replace('antd/es/', 'antd/lib/'), external: true }; } return null; }, });
然后确保 CJS 构建产物中不再出现 antd/es/*。
———
✅ 方案 B(简单稳定)
直接改源码,把 antd/es/* 改成 antd/lib/*
优点:
- 实施最快
- CJS/SSR 一定稳定
缺点:
- ESM tree-shaking 变差
- 前端打包可能稍微变大
———
✅ 方案 C(临时绕过,不推荐长期使用)
绕过 Node ESM 解析
- 启动时加 --experimental-specifier-resolution=node
- 或 patch antd ESM 文件补 .js 后缀
缺点:
- 有兼容性风险
- 不适合长期维护
———
7. 结合 exports 的推荐配置(可选)
确保入口解析更清晰,避免入口被误走:
"exports": { ".": { "types": "./dist/index.d.ts", "require": "./dist/index.js", "import": "./es/index.js", "default": "./dist/index.js" }, "./es/": "./es/", "./dist/": "./dist/" }
注意:这是入口配置,不是根因修复。 真正要修的是 dist 中 antd/es/* 的出现。
———
8. 常见误区 FAQ
Q1:我只改了 exports,为什么还是报错?
A:exports 只决定入口,不会改写 dist 内部引用。 只要 dist 里仍是 antd/es/*,SSR 就会报错。
Q2:我是 CJS,为什么会触发 ESM 错误?
A:因为 require('antd/es/...') 会进入 ESM 模块解析流程。
Q3:外部依赖 external 会导致这个问题吗?
A:会。 如果 external 规则包含了 antd/es/*,dist 会保留原样引用,SSR 就会触发 Node ESM 解析。
Q4:我以前没问题,现在才出?
A:通常是 external 规则变化、Node 版本变化、或重新构建导致 dist 暴露了 antd/es/*。
———
9. 建议的排查流程(通用版)
- 确认报错路径是否来自 antd/es
- 检查 dist 产物里是否出现 antd/es
- 如果有:
- 修 dist(方案 A / B)
- 如果没有:
- 检查 SSR 入口是否走错(exports / alias / bundle)
———
10. 一句话总结
真正的问题不是 exports,而是 CJS 产物里仍然引用了 antd/es/,触发 Node ESM 解析的无后缀路径错误。 修复目标:让 SSR/CJS 产物不再引用 antd/es/。