巧用 exports 和 typeVersions 提升 npm 包用户使用体验
flytam Lv4

默认导出

对于开发一个 JavaScript 三方库供外部使用而言,package.json是其中不可缺少的一部分

一般而言,对于库开发者来说,我们会在package.json中指定我们的导出入口。一般而言会涉及两个字段mainexport,它们会涉及到当前模块在被导入的行为。通常我们会将main字段指向 cjs 产物,module字段指向 ES 产物

main

main字段指定了该模块的主入口文件,即 require 该模块时加载的文件。该字段的值应为相对于模块根目录的路径或者是一个模块名(如index.jslib/mymodule.js,如果是模块名,则需要保证在该模块根目录下存在该模块)。主入口文件可以是 JavaScript 代码、JSON 数据或者是 Node.js C++扩展

module

module字段是 ES 模块规范下的入口文件,它被用于支持 import 语法。当使用 esm 或 webpack 等工具打包时,会优先采用 module 字段指定的入口文件。如果没有指定 module 字段,则会使用 main 字段指定的入口文件作为默认的 ES 模块入口文件

指定导出

一般情况下,我们使用mainmodule在大部分场景下对于开发一个库来说已经足够。但是如果想实现更精细化的导出控制就无法满足

当我们一个库本身同时包含运行时和编译时的导出时,如果我们导出的模块在编译时(node 环境)包含副作用,如果运行时模块也从同一入口导出就会出现问题

1
2
3
4
5
6
7
8
9
// 例如编译时入口存在以下编译时副作用
// buildtime.ts
console.log(process.env.xxx)
export const buildLog = () => console.log("build time")
// runtime.ts
export const runLog = () => console.log("run time")
// index.ts
export * from "./buildtime.ts"
export * from "./runtime.ts"

当前,可以通过解决掉副作用规避这个问题,但是很可能我们依赖的第三方模块也是有复作用的这个时候就无解了。此时最好的办法是将这个库的运行时和编译时从两个入口进行导出,这样子就不存在某一方影响到另一方。库使用者也不需关心从统一入口导入的方法到底是编译时方法还是运行时方法

这个时候就可以利用package.jsonexports字段进行导出,当存在该字段时会忽略mainmodule字段。该字段在 Node.js 12 版本中引入,可用来大幅简化模块的导出方式,支持同时支持多个环境下的导出方式,提供了更好的可读性和可维护性

支持以下用法

  1. 多文件导出
1
2
3
4
5
6
"name": "pkg",
"exports": {
".": "./dist/index.js",
"./runtime": "./dist/runtime.js",
"./buildtime": "./dist/buildtime.js"
}

这样当运行require('pkg') 时会加载dist/index.js,而当运行 require('pkg/runtime')时会加载dist/runtime.jsrequire('pkg/buildtime') 则会加载 dist/buildtime.js

  1. 多条件导出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"name": "pkg",
"version": "1.0.0",
"main": "dist/index.js",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.mjs",
"node": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./runtime": {
"require": "./dist/runtime.cjs",
"import": "./dist/runtime.mjs",
"node": "./dist/runtime.cjs",
"default": "./dist/runtime.js"
},
"./buildtime": {
"require": "./dist/buildtime.cjs",
"import": "./dist/buildtime.mjs",
"node": "./dist/buildtime.cjs",
"default": "./dist/buildtime.js"
}
}
}

对于条件,目前 node 支持importrequirenodenode-addonsdefault。同时社区对于其它环境也定义了如typesdenobrowser等供不同环境使用。具体规范可见

  1. 目录导出
    支持目录的整体导出
1
2
3
4
5
{
"exports": {
"./lib/*": "./lib/*.js"
}
}

类型

按照上述操作完成后,打包就能符合相关预期,但是对于 typescript 文件的导入如果使用runtime路径是会找不到相应的类型文件,typescript 并不会去识别该字段,已有的讨论issues

注:对于配置 tsconfig.json "compilerOptions.moduleResolution": "Node16"的项目可以在 exports 命名导出配置相应的 types 字段指向 ts 声明文件

此时需要借助package.jsontypeVersions字段进行声明供 ts 识别

对于这个例子,我们在库的package.json中增加如下,表示各路径分别导出的类型文件路径

1
2
3
4
5
6
7
"typesVersions": {
"*": {
".": ["./dist/index.d.ts"],
"runtime": ["dist/runtime.d.ts"],
"buildtime": ["dist/dist/runtime.d.ts"]
}
},

此时我们就能看见能正确找到相应的类型提示

实现

目前 Node.js 12+和主流的打包工具都已经支持exports字段的解析,下面来简单看下 webpack 的实现

Webpack

webpack 已经内置支持对于exports的解析,它的解析由enhance-resolve实现

createResolverenhance-resolve导出的create函数,用法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// https://github.com/webpack/enhanced-resolve/blob/main/README.md
const fs = require("fs");
const { CachedInputFileSystem, ResolverFactory } = require("enhanced-resolve");

// create a resolver
const myResolver = ResolverFactory.createResolver({
// Typical usage will consume the `fs` + `CachedInputFileSystem`, which wraps Node.js `fs` to add caching.
fileSystem: new CachedInputFileSystem(fs, 4000),
extensions: [".js", ".json"]
/* any other resolver options here. Options/defaults can be seen below */
});

// resolve a file with the new resolver
const context = {};
const lookupStartPath = "/Users/webpack/some/root/dir";
const request = "./path/to-look-up.js";
const resolveContext = {};
myResolver.resolve(context, lookupStartPath, request, resolveContext, (
err /*Error*/,
filepath /*string*/
) => {
// Do something with the path
});

通过创建一个自定义 resolver 函数后可调用resolve函数根据当前的模块路径和一些配置查找一个模块的绝对路径

相关自定义 resolver 选项含义

  • extensions 查找的文件扩展名
  • conditionNames 对应package.json中的exports条件
  • exportsFields 指定从 package.json 哪个字段读取exports条件
  • fullySpecified 为 true 时,解析器会优先尝试使用完全指定的路径来解析模块请求,而忽略其他任何条件。如果找到了对应的模块文件,则直接返回该路径;否则抛出错误

通过相关上述代码我们可以知道

  • 对于解析es导入,webpack 会尝试读取exports字段的导出,依次读取importnode字段。并且这里也是直接配置了fullySpecified。即处理相对路径的导入如import foo from './foo';时,Webpack 在解析模块请求时会直接将 ./foo.js 当作完整路径来处理,而不进行路径的拼接和解析
  • 对于解析cjs导入,webpack 会尝试读取exports字段的导出,依次读取requirenode字段。并且会尝试使用各种解析策略来解析该路径

由于enhance-resolve是一个完全独立于 webpack 的模块,当我们自己实现一个三方打包器或者插件时,如果想实现类似的模块解析能力,也可以完全独立使用enhance-resolve来实现

总结

为了实现一个库更友好的导出,我们可以借助 package.json 的exports字段指定多条件的导出方式,主流打包工具以及 Node.js 都已经支持;对于 ts 类型,我们可以结合typeVersions进行配置

  • Post title:巧用 exports 和 typeVersions 提升 npm 包用户使用体验
  • Post author:flytam
  • Create time:2023-05-07 10:39:08
  • Post link:https://blog.flytam.vip/巧用 exports 和 typeVersions 提升 npm 包用户使用体验.html
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.