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

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

默认导出

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

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

main

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

module

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

指定导出

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

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

// 例如编译时入口存在以下编译时副作用
// 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.json exports 字段进行导出,当存在该字段时会忽略 main module 字段。该字段在 Node.js 12 版本中引入,可用来大幅简化模块的导出方式,支持同时支持多个环境下的导出方式,提供了更好的可读性和可维护性

支持以下用法

  1. 多文件导出
  "name": "pkg",
  "exports": {
    ".": "./dist/index.js",
    "./runtime": "./dist/runtime.js",
    "./buildtime": "./dist/buildtime.js"

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

  1. 多条件导出
{
  "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 支持 import require node node-addons default 。同时社区对于其它环境也定义了如 types deno browser 等供不同环境使用。具体规范 可见

  1. 目录导出 支持目录的整体导出
{
  "exports": {
    "./lib/*": "./lib/*.js"

类型

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

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

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

  "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 实现

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

// 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, (