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

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

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

回顾

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

互操作性

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

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

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

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

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

// 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)[3]技术,"import" 字段指向 ES 模块入口文件,"require" 字段指向 CommonJS 入口文件。

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

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

// ./node_modules/pkg/index.cjs
exports.name = 'value';
// ./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 包装文件也要提供类似的默认导出。

import cjsModule from './index.cjs';
export const name = cjsModule.name;
export default cjsModule; 

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

// ./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 的包装文件,而是我们的源文件。

// ./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。

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

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

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

import Date from 'date';
const someDate = new Date();
// someDate contains state; Date does not

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

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

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

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

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

// ./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)。

// package.json
{
  "name""a-package",
  "exports": {
    ".""./index.mjs",
    "./foo.js""./foo.js"
  }

// SUCCESS! ./a-package/a-module.mjs
import { something } from 'a-package'// Imports "something" from ./index.mjs. 
// 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" 优先级更高。

{
  "main""./index.js"
}

“packageManager”

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

{
  "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" 字段。

// package.json
{
  "type""module"
}
# In same folder as preceding package.json
node my-app.js # Runs as ES module

“exports”

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

{
  "exports""./index.js"
}

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

// package.json
{
  "exports": {
    "import""./index-module.js",
    "require""./index-require.cjs"
  },
  "type""module"
}

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

“imports”

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

如此定义:

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

这样使用:

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" 允许映射到外部包。

// 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!

参考资料

[1]

模块系统与入口文件: https://juejin.cn/post/7309655223831199798

[2]

上一篇文章: https://juejin.cn/post/7309655223831199798

[3]

条件导出(conditional exports): https://nodejs.org/dist/latest-v20.x/docs/api/packages.html#conditional-exports


原文始发于微信公众号(写代码的宝哥):Node.js 包编写指南#2:互操作性、双模块包以及运行时字段

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/243658.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!