Skip to content

打包 JavaScript 库的现代化指南

所以现在我们有两个模块语法略有不同的代码副本需要维护,复制它们当然不是一个理想的解决方案。此时,您可能需要考虑引入一些构建工具或打包过程,以便将代码构建为多种格式。这可能会让你想起配置复杂的webpack或rollup的噩梦,不过别担心,我今天的任务是向你介绍两个很棒的工具,让你的生活变得更容易。

本指南旨在提供一些大多数库都应该遵循的一目了然的建议。以及一些额外的信息,用来帮助你了解这些建议被提出的原因,或帮助你判断是否不需要遵循某些建议。这个指南仅适用于 库(libraries),不适用于应用(app)。

要强调的是,这只是一些建议,并不是所有库都必须要遵循的。每个库都是独特的,它们可能有充足的理由不采用本文中的任何建议。

最后,这个指南不针对某一个特定的打包工具 —— 已经有许多指南来说明如何在配置特定的打包工具。相反我们聚焦于每个库和打包工具(或不用打包工具)都适用的事项。

文件查找策略

模块路径是 Node 在定位文件模块的具体文件时制定的查找策略,具体表现为一个路径组成的数组。

js
const _ = require('lodash')

如当前的文件路径为 /home/jackson/research,在加载的过程中,Node 会逐个尝试模块路径中的路径,直到找到目标文件为止。

[
  '/home/jackson/research/node_modules',
  '/home/jackson/node_modules',
  '/home/node_modules',
  '/node_modules'
]

在分析标识符的过程中,require()通过分析文件扩展名之后,可能没有查找到对应文件,此时 Node 会将目录当做一个包来处理。

  1. 在当前目录下查找package.json
  2. 通过JSON.parse()解析出包描述对象
  3. 从中取出 main 属性指定的文件名进行定位
  4. 如果文件名缺少扩展名,将会进入扩展名分析的步骤

如果 main 属性指定的文件名错误,或者压根没有 package.json 文件,Node 会将 index 当做默认文件名,然后依次查找 index.jsindex.jsonindex.node

如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。

定义你的 exports

开发者可能会在其应用中同时使用 cjsesm,发生双包危险。dual package hazard 一文介绍了一些缓解该问题的方法,利用 package.json#exports 进行 package exports 也可以帮助防止这种情况的发生。

exports 为你的库定义公共 API

package.json 中的 exports 字段 - 有时被称为 package exports 是一个非常有用的补充,尽管它确实引入了一些复杂性。它做的最重要的两件事是:

  1. 定义哪些东西可以从你的库中导入,哪些则不可以,以及可导入的内容的名字。如果没有在 exports 中被列出,那么开发者就不可以 importrequire 它们。换句话说,exports 的表现像是给你的库用户查看的公共 API,帮助定义哪些是外部的哪些是内部的。

  2. 允许你根据不同的条件(你可以定义)去选择那个文件是被导入的,例如“文件是被 import 还是被 require?开发人员需要的是 development 版本的库还是 production 版本等等。

关于这部分的内容NodeJS 团队webpack 团队提供了一些很优秀的文档。在此我列出一个涵盖大部分常见场景的例子:

json
{
  "exports": {
    ".": {
      "types": "index.d.ts",
      "module": "index.js",
      "import": "index.js",
      "require": "index.cjs",
      "default": "index.js"
    },
    "./package.json": "./package.json"
  }
}

让我们深入了解这些字段的含义以及我选择这个例子的原因:

  • "." 表示你的库的默认入口
  • 解析过程是从上往下的,并在找到匹配的字段后立即停止;所以入口的顺序是非常重要的
  • types 字段应始终放在第一位,帮助 TypeScript 查找类型文件
  • module 是一个“非官方”字段,它被 webpack 和 Rollup 等打包工具所支持。它应该被放在 importrequire 之前,并且指向 esm 格式的产出 -- 如果你的源代码是纯 esm 的,它也可以指向你的源代码。正如在格式部分中指出的那样,它旨在帮助打包工具只包含你的库的一个副本,无论它是通过 import 还是 require 方式引入的。你可以从这里这里、还有 这里了解更多关于 module 的内容
  • import 用于当有人通过 import 使用你的库时
  • require 用于当有人通过 require 使用你的库时
  • default 字段用于兜底,在没有任何条件匹配时使用。虽然目前可能并不会匹配到它,但为了面对“未知的未来场景”,使用它是好的

当一个打包工具或者运行时支持 exports 字段的时候,那么 package.json 中的顶级字段 maintypesmodule 还有 browser 将被忽略,被 exports 取代。但是,对于尚不支持 exports 字段的工具或运行时来说,设置这些字段仍然很重要。

如果你有一个 "development" 和一个 "production" 的产出(例如,你有一些警告在 development 产出中有但在 production 产出中没有),那么你可以通过在 exports 字段中 "development""production" 来设置它们。注意一些打包工具例如 webpackvite 将会自动识别这些导出条件,而 Rollup 也可以通过配置来识别它们,你需要提醒开发者在他们自己打包工具的配置中去做这些事。

要不要压缩代码

确定你期望的代码压缩程度

你可以将一些层面的代码压缩应用到你的库中,这取决于你对你的代码最终通过开发者的打包工具后的大小的追求程度。

例如,大多数编译器已经配置了删除空白符等其他简单的优化,即使是来自 NPM 模块的代码(在这里指的是你的库)。使用 terser —— 一个流行的 JavaScript 代码压缩工具 —— 这类压缩工具可以将包的最终大小减少 95%。在某些情况下,你可能会对这些优化感到满意,且不需要你来付出任何努力。

但如果在发布前对你的库进行代码压缩,这可以得到一些额外的好处,但需要深入了解压缩工具的配置和副作用。压缩工具通常不会将这类压缩用于 NPM 模块,因此,如果你不自己来做的话,你会错过这些节省。请参阅这个 issue了解更多信息。

最后,如果你正创建一个不依赖任何打包工具可以直接在浏览器中使用的产出(通常是 umd 格式,但也可以是现代的 esm 格式)。在这种情况下,你应该对代码进行压缩,并创建 sourcemap,并输出到一个单文件

创建 sourcemap

当使用打包工具或编译器时,生成 sourcemap

对源代码进行任何形式的编译,都将导致未来某个异常的位置,无法与源码对应起来。为了帮助未来的自己,创建 sourcemap,即使只进行了很少的编译工作。

外置框架

不要将 React、Vue 等框架打包在你的库中

当构建的库依赖某个框架(例如 React、Vue 等),或是作为另一个库的插件,你可能需要将框架配置到“externals”中。这可以使你的库引用这个框架,但不会将其打包到最终的产出中。这会避免产生一些 bug,并减少库的体积。

你应该还需要将框架添加到库的 package.jsonpeer dependencies 中,这将帮助开发者发现你依赖于某个框架。

面向现代浏览器

使用现代的新特性,如果有需要,让开发者支持旧的浏览器

这篇 web.dev 上的文章提供了一个很好的案例,并提供了相关的指导原则:

  • 当使用你的库时,能够让开发者去支持老版本的浏览器。
  • 输出多个产出来支持不同版本的浏览器。

举个例子,如果你使用 TypeScript,你可以创建两个版本的包代码:

  1. 通过在 tsconfig.json 中设置 "target"="esnext",生成一个用现代 JavaScript 的 esm 版本
  2. 通过在 tsconfig.json 中设置 "target"="es5" 生成一个兼容低版本 JavaScript 的 umd 版本

有了这些设置,大多数用户将获得现代版本的代码,但那些使用老的打包工具配置或使用 <script> 加载代码的用户,将获得进行了额外编译来支持老版本浏览器的版本。

必要的编译

编译 TypeScript、将 JSX 转换为函数调用

如果库的源码是需要进行编译的形式,如 TypeScript、React 或 Vue 组件等,那么你库需要输出的是编译后的代码。

例如:

  • 你的 TypeScript 代码应该输出为 JavaScript。
  • 你的 React 组件,例如 <Example />,应该在输出中使用 jsx()createElement() 来替换 JSX 语法。

进行这样的编译时,请确保同时也创建 sourcemap

依赖预打包

流行的前端开发框架(比如 Umi、Next.js、Vite)都做了依赖预打包,father 4 基于 Vercel 的 ncc 和 Microsoft 的 api-extractor 带来了开箱即用的依赖预打包能力,它能给项目带来三大好处。

一是 NPM 包发布后安装体积更小、速度更快。 由于 NPM 包的依赖树往往十分复杂,我们依赖的只是 A 这棵树,安装的却可能是一片森林,但却不是每棵树都会被用到。而依赖预打包能将依赖打包成单个文件,让一片森林压缩成一棵大树,再跟随我们的项目一起发布 NPM,达到体积更小、速度更快的目的。

二是不担心三方依赖更新引起 Bug。 相信大家都碰到过『昨天还好好的,今天怎么就挂了』的情况,即便底层依赖的更新再小心翼翼,也无法验证上层项目的所有场景,更何况有些层依赖发版还不一定遵循 semver 的约定。而依赖预打包可以将『底层依赖更新』这件事由被动变为主动,需要更新的时候我们再重新预打包一份便是,从而大大提升项目稳定性。

三是 NPM 包发布后安装 0 warning。 由于 NPM 安装会进行 peerDependencies 的校验,可能会产生大量的警告,依赖预打包后不需要安装,用户在使用我们的 NPM 包时也不会再看到警告信息了。而我们作为 NPM 包开发者,在开发阶段安装的时候仍然是可以审查这些警告信息的。

不过,由于依赖中可能存在 dynamic require/import 等复杂的情况,现阶段不一定每个依赖都能顺利打包,father 4 会在 RC 阶段持续优化,将这项功能变得更加好用。

Reference