Skip to content

如何打包一个现代化的npm包 #127

@peng-yin

Description

@peng-yin

简介

这份指南旨在提供一些大多数库都应该遵循的一目了然的建议。以及一些额外的信息,用来帮助你了解这些建议被提出的原因,或帮助你判断是否不需要遵循某些建议。这个指南仅适用于 库(libraries),不适用于应用(app)。
要强调的是,这只是一些建议,并不是所有库都必须要遵循的。每个库都是独特的,它们可能有充足的理由不采用本文中的任何建议。 
太长不看 👀
请产出Bundless,格式为ESM 和 CJS 的产物,并在 package.json 中通过 main 和 module配置不同格式产物的入口。同时配置 sideEffects 字段来辅助减包。

请同时输出 ESM & CJS | UMD 格式的产物
●ESM 是 「EcmaScript module」的缩写。
●CJS 是「CommonJS module」的缩写。
●UMD 是「Universal Module Definition」的缩写,它可以在 <script> 标签中执行、被 CommonJS 模块加载器加载、被 AMD 模块加载器加载,可谓全能。

各个模块之前的定义和详细区别可以看 https://dev.to/iggredible/what-the-heck-are-cjs-amd-umd-and-esm-ikm ,这里就不赘述了。简而言之,早期各个端使用不同的模块规范,而 CJS 是 Node 原有使用的规范。ESM 是为了统一各端模块标准而推出的新标准,但是在各端的支持度不太一样。现代浏览器中基本可以无脑使用,不过为了兼容老旧的浏览器,webpack 等构建工具一般都会将其转化为 CJS 再供浏览器使用,不过 vite 在开发模式下就是直接输出 ESM 文件给浏览器使用。重要的是 https://webpack.js.org/guides/tree-shaking  只会对 ESM 格式产物生效。不过 Node 环境下的 ESM 支持 https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c ,所以为了通用性考虑,目前仍然建议大家库产出 CJS 产物。

值得注意的是,即使是运行在浏览器的库,在进行单元测试的时候也会跑在Node环境下,需要额外进行babel & jest 配置,所以对于文档内部开发的npm包,建议同时构建 ESM 与CJS格式的产物,兼容性和减包我都要👊🏻

而像DC这种需要直接通过script标签使用的库,则需要使用 UMD 格式的产物。你可能已经注意到,UMD 已经与 CommonJS 模块加载器兼容 —— 所以为什么还要同时具备 CJS 和 UMD 输出呢?一个原因是,与 UMD 文件相比,CommonJS 文件在对依赖进行条件导入时通常表现更好;例如:
if (process.env.Node_ENV === "production") {
  module.exports = require("my-lib.production.js");
} else {
  module.exports = require("my-lib.development.js");
}

上面的例子,当使用 CommonJS 模块时,只会引入 production 或 development 包中的一个。但是,对于 UMD 模块,最终可能会将两个包全部引入。有关更多信息,请参阅 https://github.com/frehner/modern-guide-to-packaging-js-library/issues/9 。最后还需要注意是,开发者可能会在其应用中同时使用 CJS 和 ESM,发生双包危险。https://nodejs.org/api/packages.html#dual-package-hazard  一文介绍了一些缓解该问题的方法,利用 package.json#exports 进行 package exports 也可以帮助防止这种情况的发生。

Bundless 还是 Bundle
Bundless 指的就是不将文件打包成单文件产物,而是保留文件结构仅进行文件级别的翻译(ts→js)
.
└── src
    ├── index.less
    ├── index.tsx
    └── util.js

编译后
.
└── dist
    ├── index.d.ts
    ├── index.js
    ├── index.less
    └── util.js

目前社区里的 https://www.typescriptlang.org/docs/handbook/compiler-options.html 、https://github.com/unjs/unbuild  及 https://github.com/umijs/father  都是对源码做 Bundless 构建的构建工具。

而 Bundle 方式就是以入口文件为起点,递归处理所有依赖,然后将他们合并输出成单文件产物,目前社区中的https://webpack.js.org/ 、https://rollupjs.org/guide/en/  就是Bundle 类型的构建工具 
()Rollup 可以通过配置切换 Bundless 和 Bundle 的多种构建方式)
.
└── src
    ├── index.less
    └── index.tsx # 源码中引入 index.less

c
编译后
.
└── dist
    ├── index.min.js
    └── index.min.css

该如何选择
Bundless 模式下的产物可以被项目选择性引入,同时也具备更好的可调试性。保留文件结构更容易地对特定文件进行 https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free  标记, 让宿主的构建工具进行 [tree shaking](https://webpack.js.org/guides/tree-shaking)降低最终产物体积(ESM 中),所以对于大部分项目而言,Bundless 应该都是最好的选择,这也是社区大部分项目的选择。
但是如果你的库不是作为 npm 包发布,而是像DC这样(没错DC就是比较特殊)需要直接通过CDN使用,那就需要使用 Bundle 构建工具将所有源码打包成单文件直接提供给浏览器使用。

我需要压缩代码吗
保留正常文件内容有利于开发者进行调试,而宿主方的构建工具也会将依赖的代码进行简单的压缩处理,但是值得注意的是,不同的构建工具对于依赖库压缩的实践并没有那么完美。
举个例子,在 webpack 中仅会进行空格的删除和其他简单优化,而不会处理 constant inlining 这一对性能和体积都很有效果的优化项,目前只有在 rollup 中通过特定配置才能够开启这一行为。
所以作为库作者来说,可以跳过压缩空白格和变量命名压缩这两个宿主构建会处理的优化项,从而保留相对可读的代码内容,同时通过配置 Terser 来进行额外的优化项(需要深入了解 minifer 的设置和副作用),从而获取体积最小的产物。

创建 sourcemap文件
在所有情况下你都应该创建sourcemap,以供调试使用。

创建类型文件
所有使用 ts 编写的文件都应该产出 dts 的类型文件,它能够提供更好的开发体验。
一个小 tips 是,通过配置 tsconfig 中的 declarationMap 选项,可以产出 d.ts.map 文件,可以让你在编辑器里面直接跳转到源码中而不是 d.ts 文件中,这在 monorepo  仓库中十分有用~

External
不要在包中包含 react / vue 的代码,应该使用 external 选项将其从产物中剔除,保持引用。同时应该配置上 peer dependencies(同行依赖) 选项,告诉调用方本库依赖这个框架。
值得注意的是,external 原理就是在 Bundle 产物中去除直接打包进来的依赖代码,保留导入语句。所以在Bundless 产物中,天然就是 externals 所有依赖的,会在宿主构建的时候从自己 node_modules 中寻找依赖,如果这个依赖写在 peer dependencies中,则会去库同级目录的node_modules中查找(因为peer dependencies不会被安装到自己的node_modules中,所以会向上找到宿主的node_modules中)
External 的两种使用方式
●与peer dependencies 配合使用,库最终会在宿主的 node_modules 中找到依赖
externals: ["react"],

●通过 script 标签加载UMD格式的依赖产物,它会把自己挂到Window上,库最终会从window中找到依赖。
externals: {
	react: 'React',
	'react-dom': 'ReactDOM',
},

polyfill 配置
这里建议直接参考 antd的打包配置
https://github.com/ant-design/antd-tools/blob/master/lib/getBabelCommonConfig.js 
// 简化后的兼容性相关配置如下
{
  presets: [
    [
      '@babel/preset-env',
      {
        modules: false,
        loose: true,
        targets: ['last 2 versions', 'Firefox ESR', '> 1%', 'ie >= 11'],
      },
    ],
  ],
  plugins: [
    [
      '@babel/plugin-transform-runtime',
      {
        version: `^${require('@babel/runtime/package.json').version}`,
      },
    ],
  ],
};

配置 package.json 文件
设置 browser 字段
browser 字段应该指向 UMD 格式的产物
设置 module 字段
module 字段应该指向  ESM 格式的产物
设置 main 字段
main 字段应该指向 CJS / UMD 格式的产物

在 webpack 中,默认会按照 browser,module, main 的顺序去寻找入口文件

设置 sideEffects字段
通过配置 https://webpack.js.org/guides/tree-shaking/#clarifying-tree-shaking-and-sideeffects,sideEffects  字段,告诉webpack 那些文件内容是有副作用的,从而实现更好的 tree shaking,比如
"sideEffects": [
  "**/*.css",
  "**/*.scss",
]

什么样的文件叫做「没有副作用」呢,webpack体系中的定义可能与你的理解有些不一样,详看https://zhuanlan.zhihu.com/p/40052192 文章

设置 types 字段
如果你的类型文件单独产出在一个文件夹下时(dist/type),需要配置 types字段指向 index.d.ts,如果源码和dts 文件在同一目录下则不需要

列出 peerDependencies依赖项
peerDepemdencies 的设计初衷可以看https://nodejs.org/zh-cn/blog/npm/peer-dependencies/ ,简单来说就是告诉使用方「我这个包需要和某个包的某个版本一起使用」,比如可以告诉外部,我需要和react16.8以上一起工作,react 会被安装到和库同级的node_modules中。
"peerDependencies": {
    "react": ">=16.8",
    "react-dom": ">=16.8"
},

可以看到这里使用的是一个版本范围,请不要在peerDependencies中写具体的版本,这会导致使用方一旦升级了 peerDependencies 中的依赖项,就会频繁的出现 peerDependencies 缺失的警告⚠️,或者自动安装上两个版本造成重复打包(取决的宿主的包管理器如何处理peerDependencies),请使用类似 "1.X" 这种确保兼容性不会出现问题的写法即可。

在 npm7以上(包括)的版本中 peerDependencies 如果缺失会自动安装,而pnpm只会打印缺失信息,可以通过https://pnpm.io/zh/npmrc#auto-install-peers 开启自动安装。

请不要将一份依赖同时写在dependencies 和peerDependencies中,这会导致一些预期之外的行为,比如在https://github.com/pnpm/pnpm/releases/tag/v7.9.2  以下的版本中,会被作为dependencies  安装从而失去peerDependencies的意义。

一些使用peerDependencies的场景帮助理解它
●某些di框架需要全局唯一实例,所以写在peerDependencies中确保和宿主使用同一份di框架
●编写的babel插件只能在babel7中运行,所以需要在peerDependencies写上  "babel": "7.x" 确保宿主方使用了正确版本的babel。 
●我的ui组件库使用了 hooks语法,只能和"react": ">=16.8"一起运行

peerDependencies 的https://pnpm.io/zh/how-peers-are-resolved ,不太建议在没完全了解它的情况下滥用。

按需使用依赖库
深入到代码中一个比较明显的问题就是全量引入了一些依赖,比如 lodash,应该直接使用 lodash-es,它是 esm 格式的 lodash 包,在构建中会自动去除掉没有使用的代码。
像 dui 这种暂时没有产出 esm 格式产物的,请添加 babel-plugin-import  插件,实现按需引入。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions