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!

参考链接

相关推荐
anyup_前端梦工厂2 小时前
了解几个 HTML 标签属性,实现优化页面加载性能
前端·html
前端御书房2 小时前
前端PDF转图片技术调研实战指南:从踩坑到高可用方案的深度解析
前端·javascript
2301_789169542 小时前
angular中使用animation.css实现翻转展示卡片正反两面效果
前端·css·angular.js
风口上的猪20153 小时前
thingboard告警信息格式美化
java·服务器·前端
程序员黄同学3 小时前
请谈谈 Vue 中的响应式原理,如何实现?
前端·javascript·vue.js
爱编程的小庄4 小时前
web网络安全:SQL 注入攻击
前端·sql·web安全
爱学习的小王!4 小时前
nvm安装、管理node多版本以及配置环境变量【保姆级教程】
经验分享·笔记·node.js·vue
宁波阿成4 小时前
vue3里组件的v-model:value与v-model的区别
前端·javascript·vue.js
柯腾啊5 小时前
VSCode 中使用 Snippets 设置常用代码块
开发语言·前端·javascript·ide·vscode·编辑器·代码片段
Jay丶萧邦5 小时前
el-select:有关多选,options选项值不包含绑定值的回显问题
javascript·vue.js·elementui