Node导入不了命名函数?记一次Bug的探索

290 阅读6分钟

问题背景

在给公司写项目的时候引入了一个只导出cjs格式的包(bloom-filters)。不过奇怪的是,这个包明明在 d.ts 声明中写明了其提供的各个类可以被具名导入,但是在实际使用中却直接报错了

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-filtersbuild 脚本非常简单,就是使用4.5.5版本(实际 yarn 安装版本为 4.9.2) 的 typescripttsc 直接编译打包。

再查看一下 tsconfig.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,有重大嫌疑。

  1. 再来按照 bloom-filters 的打包配置做一个简易的 demo 项目。

    • tsconfig.json 完全照抄

    • 观察导出文件的结构,主要存在两种结构

      // src/api.ts
      
      // ...
      export {MinHash} from './sketch/min-hash'
      export {default as MinHashFactory} from './sketch/min-hash-factory'
      // ...
      

      一种是直接对于具名导出函数再导出,一种是对于默认导出重新命名后的导出

    • 由此,我们可以做一个简易的项目结构

      // 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 打包一下看看

      // 得到的打包入口文件结构如下
      "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 文件模拟此时我的项目导入

      import * as ShouldImport from "./dist/index.js";
      
      console.log(ShouldImport);
      

      运行结果如下

      node index.mjs
      [Module: null prototype] {
        __esModule: true,
        default: { shouldImport: [Getter], util: [Getter] },
        util: [Function: util]
      }
      

      有点意思,原本具名导出的函数在可以在引入的第一层直接被找到,但是默认导出再被具名转发导出的找不到了

    • 排查一下刚才怀疑的对象 "esModuleInterop": true设置为false我们再次打包,得到如下结果

      "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 可得结果

      node index.mjs
      [Module: null prototype] {
        __esModule: true,
        default: { shouldImport: [Getter], util: [Getter] },
        shouldImport: [Function: shouldImport],
        util: [Function: util]
      }
      

      具名导出和默认导出的具名转发导出都在第一层可以被找到了

    此时的疑问:

    1. 难道 esModuleInterop 有问题?但是这是一个自 ts2.7就被使用的特性,很多高 star 高下载量的库都在使用,也没有人报告这个问题。

    2. __importDefault 有问题?但是从简单的代码逻辑看出来,这个包裹函数只是对于没有 default 且不是esm的导出 加了一层 default而已,应该不会有太大问题。不如再做个实验,将第一次的打包结果修改如下

      "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; } });
      

      此时,实际代码逻辑应该与第二次打包结果的输出,但神奇的一幕出现了

      node index.mjs
      [Module: null prototype] {
        __esModule: true,
        default: { shouldImport: [Getter], util: [Getter] },
        util: [Function: util]
      }
      

      此时并没有 shouldImport 函数导出。可是从代码逻辑上看,这里的修改和我第二次打包的结果应该是一致的仅仅只是多了一个括号的问题。

    3. 难道是 node 模块导入处理逻辑有问题?换bun 试试看,使用第一次的打包结果和只保留括号的打包结果。得到的输出与第二次打包结果输出一致,bun 的结果符合预期,看来问题出现 node 上面。

    真相

    把这些问题提交给 node后,被告知可能问题出现在 cjs-module-lexer 这个模块上面。仔细阅读了一下 cjs-module-lexerREADME,大概问题可能是这样的

    1. cjs-module-lexer 并不是一个完整的 js 词法分析器,只对cjs相关的语法进行了分析,存在很多限制

    2. 有原话如下

      To 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 中调用,被看做了一种副作用而不被支持。可以做一下试验,

      将第一次打包结果改造如下

      "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

    3. cjs-module-lexer 的项目状态是冻结的,也就是说诸如此类的错误可能无法修复了

总结

这个问题要找谁背锅,可能就是 cjs-module-lexer 来背,毕竟 esModuleInterop 更早嘛。不过争论谁的问题也于事无补了,因为可能已经有大量存在问题的包在 npm被大量使用了,且推动 typescript 或者 node来修改这个问题也是耗时漫长且艰难的。只能在此做一些呼吁:

  1. 已经快 2025 年了,各位库作者不要再只提供 cjs 的引入方式了以及各个大佬也不要在新项目中使用 cjs,尽快完成 esm 的新陈代谢。
  2. 各位库作者不要再库项目中开启 esModuleInterop 了,这个选项更多是为了 project 能够用import defaut引入 cjs的包,并不是唯一的选项(此处点名批评 antd )。
  3. 如果要引入 cjs,大家还是选择使用 import defaut 吧,具名导入虽可用,但不保证不出问题