Node.js 包编写指南#2:互操作性、双模块包以及运行时字段

本篇指南并不是"从 0 到 1 教你如何创建并发布一个 npm 包"的教程,也没有介绍任何三方打包工具的使用,而是仅仅从 Node.js 包编写者的角度,学习和理解 Node.js 运行时行为。

这个指南一共 2 篇,这是第 2 篇。

回顾

上一篇文章中,我们介绍了包的基本概念,又介绍了 Node.js 支持两种模块系统(CommonJS 和 ES 模块)以及 "exports"------这一替代 "main" 字段------的使用。本文,我们将继续深入了解,两种模块系统之间的互操作性、双模块包如何编写并介绍 Node.js 所能识别的为数不多的 package.json 文件中的几个字段。

互操作性

从 Node.js 开始支持 ES 模块开始,为了达到从 CommonJS 生态到 ES 模块生态的过渡, Node.js 支持在一定限制上的 CommonJS 和 ES 模块之间的互操作。

两个模块系统之间的互操作性的规则挺复杂的,我们也不打算在这里详细阐述。只介绍了 2 个简单、常见的互操作规则:

首先,CommonJS 中是无法引入 ES 模块的。

javascript 复制代码
// in CJS
const pkg = require('esm-only-package')
// Error [ERR_REQUIRE_ESM]: require() of ES 模块esm-only-package not supported.

另一方面,在 ES 模块文件中,引入 CommonJS 则是可以的,不过不支持命名导入。

javascript 复制代码
// in ESM
import { named } from 'esm-package'
import cjs from 'cjs-package'

双模块包

所谓的"双模块包(Dual module packages)",就是指一个包同时提供了支持 CommonJS 和 ES 模块版本,它们的功能相同,但是为不同环境准备的,这在 Node.js 中引入对 ES 模块的支持之前,是一种常见模式。

在 Node.js 中引入对 ES 模块的支持之前,双模块包通常这样提供:通过 package.json 文件的 "main" 字段指定 CommonJS 版本入口,通过 "module" 字段指定 ES 模块版本入口。如此一来,Node.js 使用包的 CommonJS 版本,而像 webpack、Rollup 这类打包工具则会使用 ES 模块版本。

需要注意的是,Node.js 并不识别 package.json 文件 "module" 字段,这是打包工具引入并使用的字段。

这是以前的做法。现在 Node.js 已经全面支持 ES 模块了。那么就可以通过条件导出或分别指定入口(比如:'pkg''pkg/es-module')这种原生方式,为你的包同时指定 CommonJS 和 ES 模块版本支持。

双包风险风险

Node.js 对 CommonJS 和 ES 模块语法混用的支持,会导致潜在的风险出现,这种风险来源于你的使用方同时使用了你一个包的两个版本。

举 2 个例子。

例子 1:const pkgInstance = require('pkg') 创建的 pkgInstanceimport pkgInstance from 'pkg' 创建的 pkgInstance 其实是不一样的。

例子 2:如果包导出是一个构造函数,那么使用 instanceof 来比较两个版本创建的实例时,返回的是 false(而非 true);如果导出是对象,那么添加到其中一个对象上的属性(例如:pkgInstance.foo = 3) 在另一个对象并不可见,因为是不同的对象。

以上的状况,就是所谓的"双包风险(dual package hazard)",即同一包的两个版本在同一运行时环境中被加载了。

虽然你的项目或包不太可能有故意加载同一个包的两个版本,但你项目中的一个依赖可能会加载同一个包的另一个版本。这是 Node.js 混合 CommonJS 和 ES 模块后的一个代价。这与 importrequire() 语句分别在纯 CommonJS 或全 ES 模块环境中的工作方式是不同的,这一点要注意。

当然,我们也是有办法去尽量避免和减少这部分的危险的。下面就来介绍。

如何安全地编写

目前,我们有 2 种方案来尽可能避免双包风险。当然在避免问题的同时,也会带来一定程度的编写和优化限制。

  • 方案一:采用 ES 模块包装文件(wrapper.mjs
  • 方案二:对包进行状态隔离

方案一:使用 ES 模块包装文件

这种方法适应于现有基于 CommonJS 格式编写的包,同时又希望给外部介入方提供 ES 模块导入支持。

我们通过一个 ES Module 包装文件,导入 CommonJS 入口并重新导出,为我们的包提供 ES 模块导出支持。这里会用到条件导出(conditional exports)技术,"import" 字段指向 ES 模块入口文件,"require" 字段指向 CommonJS 入口文件。

json 复制代码
// ./node_modules/pkg/package.json
{
  type": "module",
  "exports": {
    "import": "./wrapper.mjs",
    "require": "./index.cjs"
  }
}

注意,package.json 文件中,我们显式声明了 "type": "module",表示项目中所有的 .js 文件都会被视为 ES 模块文件,这是现在创建 npm 包的推荐做法。不过为了便于看清,我们使用了 .cjs.mjs 这些后缀做突出说明。

javascript 复制代码
// ./node_modules/pkg/index.cjs
exports.name = 'value';
javascript 复制代码
// ./node_modules/pkg/wrapper.mjs
import cjsModule from './index.cjs';
export const name = cjsModule.name;

这样做的结果是,import { name } from 'pkg' 中的 nameconst { name } = require('pkg') 中的 name 是同一个单例。当对两个 name 比较时, === 返回的是 true,这样就避免了双包危险。

当然,如果 cjsModule 本身作为默认导出(比如:一个函数,类似 module.exports = function () { ... }),那么 ES 包装文件也要提供类似的默认导出。

javascript 复制代码
import cjsModule from './index.cjs';
export const name = cjsModule.name;
export default cjsModule; 

这种方法的一种变体,是通过添加子路径增加 ES 模块支持。

json 复制代码
// ./node_modules/pkg/package.json
{
  "type": "module",
  "exports": {
    ".": "./index.cjs",
    "./module": "./wrapper.mjs"
  }
}

如此,用户通过 import 'pkg/module'引入的始终都是 ES 包装文件。

ES 模块包装文件的方式可以解放你的上层依赖(应用程序或是另一个 npm 包)所使用的模块语法,确保你的上层依赖既可以使用 CommonJS,也可以使用 ES 模块组织代码。你也不需要把你的 CommJS 包完全重写,由于 ES 包装文件和 CommonJS 入口文件底层都是运行的同一份 CommonJS 代码,也不会出现双包问题了。

方案二:对包进行状态隔离

这种方法适应于基于 ES 模块格式编写的包,同时又希望给外部介入方提供 CommonJS 导入支持。

与"使用 ES 模块包装文件"所使用的方法一样,通过在 package.json 文件,分别为 CommonJS 和 ES 模块单独定义入口实现。不同的是,"import" 字段不再是对应 CommonJS 的包装文件,而是我们的源文件。

json 复制代码
// ./node_modules/pkg/package.json
{
  "type": "module",
  "exports": {
    "import": "./index.mjs",
    "require": "./index.cjs"
  }
}

当然,这仍会导致双包问题出现,那么如何避免呢?

在谈到避免出现双包问题之前,我们先讨论代码功能一致性问题。index.cjs 并不需要再单独编写了,我们可以直接通过转译工具(例如:esbuild、rollup、tsup、tsc、babel、swc 等)从 index.mjs 转换得到。这也是目前的流行做法。

那么双包问题如何避免呢?

双包问题本质上是指包在运行的过程当中内部存在一些内部状态,由于包的两个副本被意外加载到内存中,因此出现两个单独且隔离的状态,最终导致难以排除的 BUG。

那么一个解决方案就是编写无状态包

所谓无状态包,就是不存在内部状态的包。比如 JavaScript 的 Math API 就是一个无状态包,因为它的所有方法都是静态的。

另一个方法,是将原本包内的状态暴露到外部,举一个例子。

javascript 复制代码
import Date from 'date';
const someDate = new Date();
// someDate contains state; Date does not

date 包没有暴露日期实例,而是暴露了构建函数。实例化发生在接入方一侧,就规避了在包内保存状态的问题。

当然你会说,那我的包里就是会有一些状态怎么办?这个时候还有一个办法就是将共享的内部状态,统一封装在一个 CommonJS 的文件当中

javascript 复制代码
// ./node_modules/pkg/index.cjs
const state = require('./state.cjs');
module.exports.state = state;
javascript 复制代码
// ./node_modules/pkg/index.mjs
import state from './state.cjs';
export {
  state,
};

这样不管你的 pkg 在应用程序中是通过通过 require() 或是 import 使用的,引入的都是同一份状态实例。

这种方法的一种变体,是通过添加子路径增加 ES 模块支持。

javascript 复制代码
// ./node_modules/pkg/package.json
{
  "type": "module",
  "exports": {
    ".": "./index.cjs",
    "./module": "./index.mjs"
  }
}

如此,接入方通过 import 'pkg/module'引入的始终都是 ES 包装文件。

ES 模块包装文件的方式可以解放你的上层依赖(应用程序或是另一个 npm 包)所使用的模块语法,确保你的上层依赖既可以使用 CommonJS,也可以使用 ES 模块组织代码。

虽然可以通过 CommonJS 文件共享内部状态,但也无可避免增加了运行开销(两个版本)。

运行时字段

平时我们的项目的 package.json 文件会特别长,但其实 Node.js 运行时使用的字段非常少。究其原因,就是很多前端开发阶段的工具(比如:bundler、linter 等)、发布工具(npm、github cli、CDN 服务)等都对 package.json 文件的字段做了扩展。

Node.js 运行时使用的字段有多少呢?一共就 6 个,少到我们可以一个个介绍,分别是:

  • "name"
  • "main"
  • "packageManager"
  • "type"
  • "exports"
  • "imports"

"name"

用于定义包名。另外通过 "exports" 暴露的出口时,在包内文件中还可以通过这个 name 进行自引用(Self-referencing)。

json 复制代码
// package.json
{
  "name": "a-package",
  "exports": {
    ".": "./index.mjs",
    "./foo.js": "./foo.js"
  }
} 
javascript 复制代码
// SUCCESS! ./a-package/a-module.mjs
import { something } from 'a-package'; // Imports "something" from ./index.mjs. 
javascript 复制代码
// ERROR! ./a-packageanother-module.mjs

// Imports "another" from ./m.mjs. Fails because
// the "package.json" "exports" field
// does not provide an export named "./m.mjs".
import { another } from 'a-package/m.mjs'; 

"main"

指定包被加载时默认使用的入口文件。是 "exports" 字段引入之前使用的字段。当一个包中同时定义 "exports""main" 字段时,"exports" 优先级更高。

javascript 复制代码
{
  "main": "./index.js"
}

"packageManager"

开发包时所使用的包管理器,被 Corepack shims 使用。目前还处在 Experimental 阶段,v16.9.0、v14.19.0 引入。

json 复制代码
{
  "packageManager": "<package manager name>@<version>"
} 

type

决定当前包内,所有 .js 文件默认是 ES Modlule 文件还是 CommonJS 文件。

目前有两个可能取值,"comonjs""module"

如果 package.json 缺少 "type" 字段,或包含 "type": "commonjs",那么 .js 文件将被视为 CommonJS。如果 package.json 包含 "type": "module",则 .js 文件的 import 语句将被视为 ES 模块。

由于后续 Node.js 默认模块逐渐迁移至 ES 模块,因此官方团队推荐始终在你的 **package.json** 文件中声明 **"type"** 字段。

json 复制代码
// package.json
{
  "type": "module"
}
javascript 复制代码
# In same folder as preceding package.json
node my-app.js # Runs as ES module

"exports"

"exports" 是 Node.js 12 引入用来替代 "main" 字段的一个方案,支持定义子路径导出和条件导出,同时避免未显式导出的内部模块被外部意外导入。

javascript 复制代码
{
  "exports": "./index.js"
}

还可以在 "exports" 中使用条件导出来定义每个环境的不同包入口点,包括是否通过 require() 还是通过 import 引用包。

javascript 复制代码
// package.json
{
  "exports": {
    "import": "./index-module.js",
    "require": "./index-require.cjs"
  },
  "type": "module"
}

需要注意的是,"exports" 中定义的所有路径必须是以 ./ 开头的相对文件 URL。

"imports"

"exports" 对应,负责指定包内部文件使用的导出别名,仅限包内文件互相引用(以 # 号开头)。

如此定义:

json 复制代码
// ./node_modules/es-module-package/package.json
{
  "exports": {
    "./features/*.js": "./src/features/*.js"
  },
  "imports": {
    "#internal/*.js": "./src/internal/*.js"
  }
}

这样使用:

javascript 复制代码
import featureX from 'es-module-package/features/x.js';
// Loads ./node_modules/es-module-package/src/features/x.js

import featureY from 'es-module-package/features/y/y.js';
// Loads ./node_modules/es-module-package/src/features/y/y.js

import internalZ from '#internal/z.js';
// Loads ./node_modules/es-module-package/src/internal/z.js

需要注意的是,"imports" 字段中的条目必须是以 # 开头的字符串。

我们可以把 "imports" 字段看作是一种引用别名,有点类似于 tsconfig.json 中定义的 path,不过这是 Node.js 原生支持的。

另外,与 "exports" 不同的是,"imports" 允许映射到外部包。

json 复制代码
// package.json
{
  "imports": {
    "#dep": {
      "node": "dep-node-native",
      "default": "./dep-polyfill.js"
    }
  },
  "dependencies": {
    "dep-node-native": "^1.0.0"
  }
}

总结

本文我们先简单介绍了 Node.js 中两种模块系统之间的互操作性,并深入了解了双模块包的概念、所面临的双包问题。

然后,就安全编写双模快包提供了两个方案,其中"使用 ES 模块包装文件"是适应于旧 CommonJS 包的方案,而"对包进行状态隔离"则是适应于现代 ES 模块包的方案------在尽可能减少包状态的前提下,从 ES 模块源码使用转译工具导出 CommonJS 副本。

最后,介绍 Node.js 所能识别的为数不多的 package.json 文件中的几个字段。

希望本文所介绍的内容对你有所帮助,感谢阅读,Happy Coding!

参考链接

相关推荐
AlienZHOU7 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc10 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师12 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen12 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒12 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
65岁退休Coder13 小时前
PI Agent 开发一个生产级 Harness
后端·node.js·agent
前端snow13 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81813 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007813 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明13 小时前
第 6 章 组件进阶
前端·vue.js