问题背景
在给公司写项目的时候引入了一个只导出cjs
格式的包(bloom-filters)。不过奇怪的是,这个包明明在 d.ts
声明中写明了其提供的各个类可以被具名导入,但是在实际使用中却直接报错了
sh
node index.js
file:///Volumes/extend/practice/demo-test-import/demo_import/index.js:1
import { MinHashFactory } from "bloom-filters";
^^^^^^^^^^^^^^
SyntaxError: Named export 'MinHashFactory' not found. The requested module 'bloom-filters' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from 'bloom-filters';
const { MinHashFactory } = pkg;
at ModuleJob._instantiate (node:internal/modules/esm/module_job:171:21)
at async ModuleJob.run (node:internal/modules/esm/module_job:254:5)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:483:26)
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
Node.js v22.9.0
当然我知道在 esm
中导入 cjs
,最推荐的还是使用 default
导入比较稳妥。不过根据 commonjs-namespaces说明,命名导入是可以被支持。所以好奇地探索一下这个问题,到底是 node
有问题, ts
有问题还是这个包的打包有毛病。
探索过程
原始记录可以查看 nodejs/node#56304 这个 issue
1.首先排查bloom-filters这个包。
bloom-filters的 build
脚本非常简单,就是使用4.5.5
版本(实际 yarn
安装版本为 4.9.2
) 的 typescript
的 tsc
直接编译打包。
再查看一下 tsconfig.json
json
{
"compilerOptions": {
"rootDir": "./src",
"target": "es5",
"outDir": "./dist",
"module": "commonjs",
"lib": [ "ES2015" ],
"declaration": true,
"strict": true,
"allowJs": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"downlevelIteration": true
},
"include": [
"./src/**/*"
],
"exclude": [
"node_modules/",
"test/"
]
}
会影响到实际打包结果的字段有 target
, module
, esModuleInterop
"target": "es5"
- 改变的是打包后语法目标,有一定嫌疑
"module": "commonjs"
- 声明打包导出格式为 commonjs
,本来就是探索 cjs
导出问题,肯定设置为 commonjs
排除嫌疑
"esModuleInterop": true
- 主要用来辅助 esm
导入 cjs
,有重大嫌疑。
-
再来按照 bloom-filters 的打包配置做一个简易的 demo 项目。
-
tsconfig.json
完全照抄 -
观察导出文件的结构,主要存在两种结构
ts// src/api.ts // ... export {MinHash} from './sketch/min-hash' export {default as MinHashFactory} from './sketch/min-hash-factory' // ...
一种是直接对于具名导出函数再导出,一种是对于默认导出重新命名后的导出
-
由此,我们可以做一个简易的项目结构
ts// should_import/src/methods.ts 模拟默认导出的方法 const shouldImport = (a: number, b: number) => a + b; export default shouldImport; // should_import/src/util.ts 模拟具名导出的方法 export const util = () => { return "util"; }; // should_import/src/index.ts 模拟导出入口文件 export { default as shouldImport } from "./methods"; export { util } from "./util";
再使用
tsc
打包一下看看ts// 得到的打包入口文件结构如下 "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.util = exports.shouldImport = void 0; var methods_1 = require("./methods"); Object.defineProperty(exports, "shouldImport", { enumerable: true, get: function () { return __importDefault(methods_1).default; } }); var util_1 = require("./util"); Object.defineProperty(exports, "util", { enumerable: true, get: function () { return util_1.util; } });
此时在新建一个
index.mjs
文件模拟此时我的项目导入jsimport * as ShouldImport from "./dist/index.js"; console.log(ShouldImport);
运行结果如下
shnode index.mjs [Module: null prototype] { __esModule: true, default: { shouldImport: [Getter], util: [Getter] }, util: [Function: util] }
有点意思,原本具名导出的函数在可以在引入的第一层直接被找到,但是默认导出再被具名转发导出的找不到了
-
排查一下刚才怀疑的对象
"esModuleInterop": true
设置为false
我们再次打包,得到如下结果js"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.util = exports.shouldImport = void 0; var methods_1 = require("./methods"); Object.defineProperty(exports, "shouldImport", { enumerable: true, get: function () { return methods_1.default; } }); var util_1 = require("./util"); Object.defineProperty(exports, "util", { enumerable: true, get: function () { return util_1.util; } });
对比之前的结果,是缺少了
__importDefault
这个包装default
导入的方法,这符合esModuleInterop
的功能描述。 -
此时运行
index.mjs
可得结果shnode index.mjs [Module: null prototype] { __esModule: true, default: { shouldImport: [Getter], util: [Getter] }, shouldImport: [Function: shouldImport], util: [Function: util] }
具名导出和默认导出的具名转发导出都在第一层可以被找到了
此时的疑问:
-
难道
esModuleInterop
有问题?但是这是一个自ts2.7
就被使用的特性,很多高 star 高下载量的库都在使用,也没有人报告这个问题。 -
__importDefault
有问题?但是从简单的代码逻辑看出来,这个包裹函数只是对于没有default
且不是esm
的导出 加了一层default
而已,应该不会有太大问题。不如再做个实验,将第一次的打包结果修改如下js"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.util = exports.shouldImport = void 0; var methods_1 = require("./methods"); Object.defineProperty(exports, "shouldImport", { enumerable: true, get: function () { return (methods_1).default; } }); var util_1 = require("./util"); Object.defineProperty(exports, "util", { enumerable: true, get: function () { return util_1.util; } });
此时,实际代码逻辑应该与第二次打包结果的输出,但神奇的一幕出现了
shnode index.mjs [Module: null prototype] { __esModule: true, default: { shouldImport: [Getter], util: [Getter] }, util: [Function: util] }
此时并没有
shouldImport
函数导出。可是从代码逻辑上看,这里的修改和我第二次打包的结果应该是一致的仅仅只是多了一个括号的问题。 -
难道是 node 模块导入处理逻辑有问题?换
bun
试试看,使用第一次的打包结果和只保留括号的打包结果。得到的输出与第二次打包结果输出一致,bun
的结果符合预期,看来问题出现node
上面。
真相
把这些问题提交给
node
后,被告知可能问题出现在 cjs-module-lexer 这个模块上面。仔细阅读了一下 cjs-module-lexer 的README
,大概问题可能是这样的-
cjs-module-lexer 并不是一个完整的
js
词法分析器,只对cjs
相关的语法进行了分析,存在很多限制 -
有原话如下
markdownTo avoid matching getters that have side effects, any getter for an export name that does not support the forms above will opt-out of the getter matching: ```js // DETECTS: NO EXPORTS if (false) { Object.defineProperty(module.exports, 'a', { get () { return dynamic(); } }) } ``` Alternative object definition structures or getter function bodies are not detected: ```js // DETECTS: NO EXPORTS Object.defineProperty(exports, 'c', { get: () => p }); Object.defineProperty(exports, 'd', { enumerable: true, get: function () { return dynamic(); } }); ```
可知,在 cjs-module-lexer 处理下的
Object.defineProperty
的调用中并不支持有副作用的getter
函数。这样来说,__importDefault
被写入了getter
中调用,被看做了一种副作用而不被支持。可以做一下试验,将第一次打包结果改造如下
js"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.util = exports.shouldImport = void 0; var methods_1 = require("./methods"); // 把 __importDefault 的执行放到根作用域 var shouldImport = __importDefault(methods_1).default; } Object.defineProperty(exports, "shouldImport", { enumerable: true, get: function () { return shouldImport }); var util_1 = require("./util"); Object.defineProperty(exports, "util", { enumerable: true, get: function () { return util_1.util; } });
运行后,得到结果符合预期,可以直接获取到
shouldImport
-
cjs-module-lexer 的项目状态是冻结的,也就是说诸如此类的错误可能无法修复了
-
总结
这个问题要找谁背锅,可能就是 cjs-module-lexer 来背,毕竟 esModuleInterop
更早嘛。不过争论谁的问题也于事无补了,因为可能已经有大量存在问题的包在 npm
被大量使用了,且推动 typescript
或者 node
来修改这个问题也是耗时漫长且艰难的。只能在此做一些呼吁:
- 已经快 2025 年了,各位库作者不要再只提供
cjs
的引入方式了以及各个大佬也不要在新项目中使用cjs
,尽快完成esm
的新陈代谢。 - 各位库作者不要再库项目中开启
esModuleInterop
了,这个选项更多是为了project
能够用import defaut
引入cjs
的包,并不是唯一的选项(此处点名批评 antd )。 - 如果要引入
cjs
,大家还是选择使用import defaut
吧,具名导入虽可用,但不保证不出问题