前言
随着 npm 社区的发展,其发包规范与包的解析机制也在不断完善,最直观的体现就是package.json
支持的字段增多,以及各种打包工具的特性支持变多,发包能力也在不断完善。
那么,在如今的社区中,如何正确地打包、发包,又需要遵循哪些规范做出哪些取舍,才能对开发者和使用者都相对友好呢?
本篇文章记录了我在日常开发与发包过程中遇到的一些坑和解决方案,并附带总结了一套发包的最佳实践。
普遍的发包方式
一般来说,我们都是用rollup
、webpack
、tsc
、father
等构建工具打包,然后再按照通用的发包流程发包即可,这也是最简单的方式,网上也有很多文章了,这里不再赘述。
而在通用的发包流程中,我们一般会关心pakcage.json
的四个字段:main
、module
、browser
和types(或typings)
,补全这四个字段后,一个 npm 包就能比较好的被开发者使用了。
main、module
通常来说,最简单的发包方式就是全包只有一个导出入口,用户通过import { useState } from 'react'
这样的形式即可使用,这是所有包都通用的导出形式。
在 package.json 中的配置示例如下:
json
{
"name": "foo",
"main": "./dist/index.js"
}
当同时有cjs
与esm
模块时,可以再添加一个module
字段用于优先指定esm
模块的解析入口。
这不代表
main
字段是cjs
模块独有,只是esm
会优先解析module
字段,从而允许开发者做兼容处理。
json
{
"name": "foo",
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js"
}
browser
除此之外,有些 npm 包还会提供browser
字段,该字段对应模块在浏览器端运行时会首先被查找并加载,取代module
和main
字段。
标准的打包工具一般都会识别该字段,以webpack
为例,当指定构建环境target: web
时,其默认模块查找方式为browser
、module
、main
。
types
如果 npm 包是由 typescript 书写的,一般会添加额外的types
或typings
字段,该字段的类型文件会在用户导入该包时自动加载,为用户提供类型提示。
json
{
"name": "foo",
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js",
"types": "./dist/index.d.ts"
}
思考与优化
上面的发包方式其实已经能适配大多数使用场景了,但还有更进一步优化的空间,在某些场景下并不能完全适用,下面会依次分析,同时也会列举出我个人关于发包的一些思考。
如何减少用户引入体积
目前最常见的做法就是导出 esm 模块,然后在使用时依赖打包工具的 tree shaking 能力来实现按需引入,不过这也是有局限性的。
tree shaking 的局限性
首先,我们要理解什么是 tree shaking,下面是 webpack 中对其的定义:
tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如
import
和export
。
总的来说,就是找到 esm 模块中哪些导出值未曾其它模块使用,将其删除,以此实现打包产物的优化。
但这些删除的模块还需要符合一个前提,它们都是无副作用的,如果模块含有副作用,则不会被 tree shaking。
至于什么是副作用,下面是一个简单示例:
js
// module.js
let a = 1
export function foo() {
return a++
}
export function bar() {
return "bar"
}
bar()
// test.js
import { foo } from './module'
console.log(foo)
此时bar
就不含副作用,因为它的执行对外部没有任何影响,在打包时会被 tree shaking 删除。
js
let a = 1
function foo() {
return a++
}
console.log(foo)
但如果是这样:
js
// module.js
let a = 1
export function foo() {
return a++
}
export function bar() {
return a++
}
bar()
此时由于bar
会对外界产生影响,也就是带来副作用,所以无法 tree shaking。
js
let a = 1
function foo() {
return a++
}
function bar() {
return a++
}
bar()
console.log(foo)
因为构建工具在做 tree shaking 时必须要明确没有副作用的影响,所以产生了一个问题:由于cjs
模块和import()
等动态导入语法无法确定副作用(打包工具无法通过静态分析获取用户的使用情况),默认都会认为含有副作用,不会进行 tree shaking,此时对一些需要懒加载能力(lazy load)的需求来说会变得非常棘手。
更详细的解释可以看 这里
当然,由于 tree shaking 的概念本质是由社区输出的(rollup 首先提出),不同打包工具的实现方式也有着不同,一些问题可以通过打包工具本身的支持来解决。比如 webpack 支持我们通过在 package.json 中写入sideEffects
字段来标记模块是否有副作用,支持用户添加webpackExports
注释来手动声明懒加载的模块等,不过这些同样也有其适配环境与局限性,这里就不再延伸了。
很多时候,与其去研究如何更适配打包工具,不如从源头出发解决问题,上面的问题总结下来就是只有一个统一的入口,无法适配多样化的需求场景。那针对这类场景下是否可以适当地拆包呢?
在此之前,我们先了解一下目前社区的两种打包方案:Bundle
与 Bundless
。
Bundless 与 Bundle
以下为 father 对其的解释。
- Bundless 即文件到文件的构建模式,它不会处理依赖,只对源码做平行编译输出。目前社区里的 tsc、unbuild 就是这样做的。Bundless 模式下的产物可以被项目选择性引入,同时也具备更好的可调试性,用户可以比较方便地了解项目结构。
- Bundle 即将源码打包的构建模式,它以入口文件作为起点、递归处理全部的依赖,然后将它们合并输出成统一的构建产物。目前社区里的 Webpack、Rollup就是这样做的。Bundle 模式下的产物具备更好的一体性与稳定性,通常比较适合打包 umd 模块。
具体举例来说,假设我们的源码目录是这样的:
txt
── src
├── foo.ts
└── index.ts
在 Bundless 构建模式下,会输出如下结构:
txt
├── dist
│ ├── cjs # 同步输出,不做依赖处理
│ │ ├── foo.js
│ │ ├── foo.d.js
│ │ └── index.js
│ │ └── index.d.ts
│ └── esm #esm 模块
│ ├── foo.js
│ ├── foo.d.js
│ └── index.js
│ └── index.d.ts
在 Bundle 模式下,会输出如下结构:
txt
├── dist
│ ├── cjs # 全部打包为一个文件,会做依赖处理
│ │ └── index.js
│ │ └── index.d.ts
│ └── esm
│ └── index.js
│ └── index.d.ts
需要注意的是,这些只是简单基于默认的构建模式的构建工具分类,比如 rollup 可以通过配置多入口或开启preserveModules
加上externals
依赖手动实现 Bundless 打包。
两者的取舍
至于 Bundless 与 Bundle 这两者如何选用,一般建议是大部分 esm 和 cjs 模块都可以遵循 Bundless 模式的打包方式,当需要 umd 模块时,使用 Bundle 模式。
在这两者的最极端场景中,Bundless 会 external 掉所有的三方包,Bundle 则是将三方包全部打进依赖中。
一个比较综合的做法是在打包时以 Bundless 为主,将大部分包都 external 掉,只有某些特殊的三方包,可以基于 tree shaking 将其打包在 npm 包中,以此保证稳定性,具体情况也可以根据包的大小与稳定性需求程度来做出相应的调整。
Bundless 与 Bundle 对于库和应用的影响是不同的,不要一概而论,应用主要考虑的是资源加载性能与兼容性问题,而库大部分需要考虑代码可读性与依赖处理问题。
Bundless 下的 tree shaking 问题
在 Bundless 模式下,如果你的代码中有 esm 之外的模块的导入(如 css 的模块导入),并且没有被单独处理过(比如使用 father 4.x 打包时并不会处理 css 文件,只是原样拷贝),比如这样:
js
import "./index.css"
需要手动在package.json
中指定sideEffects: \["\*.css"\]
,否则由于 import "./index.css"是全量导入语法,打包工具默认会认为它含有副作用, tree shaking 能力会完全失效。
package.json:
json
{
"sideEffects": ["*.css", "*.less"]
}
子路径导出
了解完 Bundless 与 Bundle 两种构建模式后,我们再回到在 tree shaking 那里提出的问题,是否可以适当地拆包?
答: 可以。 让用户可以通过子路径导入对应的模块即可,比如import Button from 'my-component/button'
。
很显然,在这种情况下,我们就不能使用只有单入口 Bundle 构建模式了(多入口的 Bundle 模式仍然适用),因为这样整个包都只有一个地方导出,也就谈不上子路径了。
当构建产物有多个文件时,我们要做的就是如何将这些文件分别暴露给用户。通常包含有构建产物的目录结构是这样的:
txt
├── dist
│ ├── cjs # cjs 模块
│ │ ├── foo.js
│ │ ├── foo.d.js
│ │ └── index.js
│ │ └── index.d.ts
│ └── esm #esm 模块
│ ├── foo.js
│ ├── foo.d.js
│ └── index.js
│ └── index.d.ts
├── package.json
└── src
├── foo.ts
└── index.ts
如果不做任何处理发布到 npm,用户只能通过import foo from 'pkg/dist/esm/foo'
这样的形式引入指定路径的模块,这样做有两点坏处:
- 引入路径过长,且有一部分无用路径,比如/dist/esm 这串路径其实跟功能本身并无关联。
- 无法自动适配不同标准的模块,此时有 esm 与 cjs 模块,需要用户在使用时自行选择对应模块。
下面我们来说说如何解决这两个问题。
exports 字段
上面的两个问题其实都可以使用exports
字段来解决,node 在 12.7.0 版本开始逐步支持了在 package.json 中添加exports
字段来指定子路径,也被称为 export map
,Webpack 则是在 v5 版本默认支持了该字段的解析(webpack 4 可以通过插件支持)
关于 exports 字段的更多细节,详见 node.js 文档
还是以上面的目录结构为例,我们使用exports
来定义导出:
json
"exports": {
".": {
"types": "./dist/ems/index.d.ts",
"require": "./dist/cjs/index.js",
"import": "./dist/esm/index.js"
},
"./foo": {
"types": "./dist/ems/foo.d.ts",
"require": "./dist/cjs/foo.js",
"import": "./dist/esm/foo.js"
}
}
其中:
-
exports
中所有的路径都必须以.
开头,可以理解为.
即是根目录 -
.
对应用户引入为import pkg from 'pkg'
;./foo
对应用户引入为import foo from 'pkg/foo'
,即子路径引入。 -
.
和./foo
下面的types
、require
、import
等字段代表条件导出,根据模块解析策略不同会选用不同的字段解析。- 在 esm 情况下,会使用
import
字段。 - 在 cjs 情况下,会使用
require
字段。 - 当使用 typescript 时,会使用
types
字段读取类型。
除此之外,还可以根据环境变量、运行环境 (
nodejs
/browser
/electron
) 等选择不同的入口,这里就不再延伸了。 - 在 esm 情况下,会使用
-
如果使用了
exports
来声明子路径,没有被写在exports
中的路径是不能使用的:ts// 正常加载 import foo from 'pkg/foo'; // 报错 import bar from 'pkg/bar';
导出多个子路径
导出包内所有的子路径有两种方式,一种是依次将所有导出写在exports
中,一种则是使用通配符*
,相信大部分情况下应该都会选用的第二种。
通过在子路径中使用通配符可以处理任意的嵌套子路径,这里通配符*
的用法与在填写tsconfig.json/jsconfig.json
的paths
字段时完全一致:
上面的子路径导出可以写成这样:
json
{
"name": "pkg",
"exports": {
".": {
"types": "./dist/ems/index.d.ts",
"require": "./dist/cjs/index.js",
"import": "./dist/esm/index.js"
},
"./*": {
"types": "./dist/ems/*.d.ts",
"require": "./dist/cjs/*.js",
"import": "./dist/esm/*.js"
}
}
}
那么在使用时,*
所匹配到的字符串都会被依次匹配解析:
ts
// foo 匹配到 ./dist/esm/foo.js,正常加载
import foo from 'pkg/foo';
// bar 匹配到 ./dist/esm/bar.js,正常加载
import bar from 'pkg/bar';
注意我们这里的*
用的不是 glob 语法,而是模式匹配,在 glob 语法里面 *
表示任意的一层目录,但是在这里是表示无限层的任意路径。
扩展名与文件夹模块
通过exports
解析模块时不会自动添加扩展名,比如这样:
json
{
"name": "pkg",
"exports": {
"./*": {
"types": "./dist/ems/*",
"require": "./dist/cjs/*",
"import": "./dist/esm/*"
}
}
}
按照刚刚*
的解析:会变成这样:
ts
// 报错,foo 匹配到 ./dist/esm/foo,没有找到文件
import foo from 'pkg/foo';
esm 和 cjs 模块的模块解析策略都不会再继续延伸,会直接把./dist/esm/foo
当作文件来看,不会自动添加后缀,也就是说此时./dist/esm/foo
本身就是一个不带后缀名的js
文件反而能正确被解析。
正确引入的方式应该是这样:
ts
import foo from 'pkg/foo.js'
不过,大多数时候我们应该都不想用户在导入子路径时指定文件扩展名,这样也不好读取ts
的类型文件,所以通常的做法都是需要我们按照某一种规则打包,并手动添加导入文件后缀,正如一开始那样:
json
{
"exports": {
"./*": {
"types": "./dist/ems/*.d.ts",
"require": "./dist/cjs/*.js",
"import": "./dist/esm/*.js"
}
}
}
如何在 ts 中识别 exports 字段
说到exports
的功能,我们需要再说一下如何在项目中使用 ts 识别 npm 包的exports
字段,这涉及到 moduleResolution(模块解析策略)。
简单来说就是按照怎样的方式来查找到模块的,这里只是提一下概念,不做深入研究。我们单独拿 node 的模块解析策略说一下,因为目前大部分前端构建工具比如 webpack、vite 等都是采用的这种策略,假设我们有如下引用:
js
const pkg = require('pkg')
- 找到当前目录下的
node_modules
目录。- 找到
pkg.js
文件,/root/src/node_modules/pkg.js
- 找到
pkg
文件夹,读取package.json
,/root/src/node_modules/pkg/package.json
- 找到
pkg
文件夹,读取index.js
,/root/src/node_modules/pkg/index.js
- 找到
- 当前
node_modules
下没有找到模块,递归向上一层级目录寻找,/root/node_modules
,重复 1 的步骤/root/node_modules/pkg.js
/root/node_modules/pkg/package.json
/root/node_modules/pkg/index.js
现在我们回到 typescript,在 tsconfig.json 中有一个 moduleResolution 字段,该字段可以指定 ts 解析类型的模块策略,早期该字段只支持两个值:classic 与 node,这里的 node 又叫做 node10。
由于exports
字段是在 node v12.7.0 后才慢慢开始支持的,上面的两种策略都无法识别这个字段,所以在使用 ts 时不会给出模块提示。
下面是 rollup 的 exports 配置:
json
"exports": {
".": {
"types": "./dist/rollup.d.ts",
"require": "./dist/rollup.js",
"import": "./dist/es/rollup.js"
},
"./loadConfigFile": {
"types": "./dist/loadConfigFile.d.ts",
"require": "./dist/loadConfigFile.js",
"default": "./dist/loadConfigFile.js"
},
"./getLogFilter": {
"types": "./dist/getLogFilter.d.ts",
"require": "./dist/getLogFilter.js",
"import": "./dist/es/getLogFilter.js"
},
"./dist/*": "./dist/*"
}
没有exports
提示:
有exports
提示:
直到 typescript 5.1 版本,这个字段的可接收值新增到了5个,剩下三个:
node16
:node 16 版本的解析策略,支持exports
字段,但是同时要求用户必须严格遵循 esm 模块规范,这个规范有一条要求,就是导入文件必须带扩展名。
ts
import add from './add';
add(1, 2);
如果你这样导入文件,ts 会抛出错误,因为你没有遵循 esm 模块的规范,正确做法为:
ts
import add from './add.ts'; // 必须带扩展名
add(1, 2);
nodenext
:表示最新的 nodejs 模块解析策略,兼容node16
,但是依旧会有 node16 的问题,强制要求使用相对路径模块时必须写扩展名。bundler
:ts 5.0 推出该策略,它是为了解决上面两种策略的痛点而产生的策略,可以让你使用 exports 声明类型的同时,使用相对路径模块可以不写扩展名。
所以,如果你想要在 ts 中使用三方模块exports
定义的子路径,最好将 moduleResolution 设置为 bundler。
兼容处理
上面提到了,exports
字段对使用者的 node 版本与 webpack 等构建工具的版本都有要求,如果使用了 typescript 也需要 module 字段正确。
如果想要适配所有场景,需要手动在打包时做一些兼容处理,我们再来看看之前使用 import foo from 'pkg/dist/esm/foo'
遇到的问题:
- 引入路径过长,且有一部分无用路径,比如/dist/esm 这串路径其实跟功能本身并无关联。
- 无法自动适配不同标准的模块,此时有 esm 与 cjs 模块,需要用户在使用时自行选择对应模块。
我们先解决第二个问题,如何让用户在引入文件时可以自动适配不同标准的模块?
答::使用 package.json 替换文件导出。
还记得刚刚说的 moduleResolution 吗,最通用的 node 策略其实是会找到 package.json 的,那我们把每个子路径模块都当作是一个新的包,用户在引入子路径时默认去找 package.json 即可。
至于第一个问题,反而可以让我们上面的做法变得更轻松,因为无法识别exports
字段的项目默认是按照包的文件路径查找的,那么直接在包的最外层目录新建一个与exports
内部同步的目录即可,在这个目录下就可以放入子路径的 package.json 了。
最后完整目录结构如下:
txt
├── dist
│ ├── cjs # cjs 模块
│ │ ├── foo.js
│ │ └── index.js
│ └── esm #esm 模块
│ ├── foo.js
│ └── index.js
├── foo # 新增 foo 目录,下面的 package.json 指向 ./dist/esm/foo.js 与 ./dist/cjs/foo.js
│ └── package.json
├── package.json
└── src
├── foo.ts
└── index.ts
babel-plugin-import
相信大家多少都有了解过 babel-plugin-import 这个 babel 插件,它可以在编译时改变 import 的导入指向,它所适配的场景就是已导出子路径的 npm 包,多用于组件库:
ts
import { Button } from 'antd';
// babel-plugin-import 可以进行如下转换
import Button from 'antd/es/button';
虽然伴随着 esm 模块的普及与 tree shaking 的出现,其使用场景已经很少了,但在一些使用 cjs 这类无法直接 tree shaking 的场景下,也许依旧用得上它。
细致化功能划分
有了子路径导出的能力,其实我们可以更加细致化地做功能划分,比如某些功能是否应该不需要在统一的入口暴露,由用户通过其他方式引入。
目前这类划分功能的行为主流有两种做法:一种是使用 monorepo,将不同的功能拆包发布;另一种就是子路径导出。
-
monorepo:
tsimport { foo } from '@foo/core' import { useFoo } from '@foo/react'
-
子路径导出:
tsimport { foo } from 'foo' import { useFoo } from 'foo/react'
至于两者如何取舍,一般是没有太过明显的界限。前者拆包发布,可以进一步减少包下载体积,用户只用下载需要的包即可;而后者则将所有功能聚合在一个包中,如果用户要用到所有模块,也可以节省用户找包的时间。
一个较为通用的解法: 根据包的模块体积划分,如果包划分出的功能较少,体积较小,可以更多地考虑子路径导出,反之则是使用 monorepo。
如何优化开发体验
monorepo 下的开发体验
在 monorepo 的环境下,不同子包间的依赖通常是通过包管理器的软连接指向的目标目录,在具体使用时同第三方包并无区别。那么此时会出现一个问题,当我们更新依赖包后,必须重新执行一次打包,使用该包的模块才能正常使用新的更新,这明显会极大降低开发效率与开发体验。
如何解决?其实很简单,我们来思考一个问题,package.json 下的 main
、module
、exports
等字段应该指向 src 还是 dist?
答: 应该指向 src,也就是源代码文件。
也许你会解决这样会直接影响用户的使用,明显很不合理,但是别忘了 package.json 中还有一个publishConfig
的字段,在这个字段下可以重新指定 npm 发布包时的配置信息,包括之前提到的main
、module
、exports 等字段,在发包时会用publishConfig
下的字段信息替换外层字段。利用这个特性,我们就能在不影响用户使用的同时提升开发体验了。
综上所述,完整的 package.json 如下:
json
{
"name": "pkg",
"exports": {
".": "./src/index.ts",
"./submodule": "./src/submodule.ts"
},
"main": "./src/index.ts",
"publishConfig": {
"types": "./dist/esm/index.d.ts",
"module": "./dist/esm/index.js",
"main": "./dist/cjs/index.js",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/esm/index.d.ts"
},
"./submodule": {
"import": "./dist/esm/submodule.js",
"require": "./dist/cjs/submodule.js",
"types": "./dist/esm/submodule.d.ts"
}
}
}
}
如何优化使用体验
构建产物是否 minify
库打包与应用打包不同,库的开发者应该更多地考虑代码的可读性,是否 minify 可以直接交给具体的应用决定。所以除非对包体积有很高的追求或者并不想暴露可读的代码给用户,否则构建产物最好都不应该被 minify。
是否提供 sourcemap
sourcemap 可以帮助开发者更快速和方便地调试代码,一般都建议在打包时同时产出对应的 sourcemap 文件。
不过具体情况也以库的使用场景和开发者的需求为准,一般在库的源码意义不大,或者代码模块少,复杂度低,直接通过编译后的代码就能理解大致逻辑时,可以将其省去,毕竟增加 sourcemap 也会增大包的体积。
是否提供 polyfill
先说结论,我个人的结论是:不需要。
在 github 上的一些讨论:issues
要理清这个问题,我们首先要了解打包时编译工具做了哪些事件,以常用的编译工具babel
为例,babel
把代码转换拆分为了 syntax(语法) 和 api。
- 语法转换主要是将新的语法转换为旧的语法,以确保代码可以在更旧的浏览器或环境中运行。例如,将
let
和const
关键字转换为var
,将箭头函数转换为普通函数等等。这些语法转换通常不需要对代码进行过多额外的依赖(会引入一些 helpers 函数,但是通常都很小),在保证兼容性的同时不会过多影响代码大小。 - API 转换主要是将新的 API 转换为旧的 API,以确保代码在旧的浏览器或环境中具有相同的行为。例如,将
Promise API
转换为回调函数,将Object.assign
转换为手动复制对象属性等等。这些 API 转换可能需要对代码进行额外的依赖,因此在保证兼容性的同时,会影响代码大小和性能。
那 babel 为什么要这样拆分呢?因为可以实现最佳的兼容性和最小的代码大小,做到只转换语法,而不转换 API,也就是不添加 polyfill。具体原因如下:
- 在目前的浏览器环境中,其实大部分都是支持最新的 es 特性的,此时引入 polyfill 无疑是没有任何用处的,反而还会增大项目体积,得不偿失。而由于语法转换并不会带来太大的代码体积改动,通常情况下是可以被接受的。
- 而在低版本浏览器环境中,由于库内部已经做了一次语法转换,用户无需再次对 node_modules 做处理,从而减少构建时间(一般应用构建时默认都是忽略 node_modules 的,如果库本身没做语法转换就需要在打包的时候手动指定),只需要在全局添加 polyfill 即可完成兼容。
不过凡事都没有绝对,如果你的库只用了很少的 polyfill,或者有明确的兼容低版本浏览器的需求,将其注入到代码中也未尝不可。不过需要注意,不要使用@babel/preset-env
提供的全局的 polyfill 导入,应该使用@babel/plugin-transform-runtime
提供的模块化 polyfill。
@babel/preset-env 和 @babel/plugin-transform-runtime 提供 polyfill 的区别
简单来说,就是下面的代码:
ts
const str = 'xx'
str.replaceAll('xx','')
通过@babel/preset-env
添加 polyfill 会变成这样:
js
import 'core-js/modules/es.regexp.exec.js';
import 'core-js/modules/es.string.replace.js';
import 'core-js/modules/esnext.string.replace-all.js';
var str = 'xx'
str.replaceAll('xx','')
而通过@babel/plugin-transform-runtime
则会变成这样:
js
import _replaceAllInstanceProperty from '@babel/runtime-corejs3/core-js/instance/replace-all';
var str = 'xx'
_replaceAllInstanceProperty(str).call(str, "xx", '')
一种是全局导入,一种是模块化导入,很明显第一种实际是给应用添加全局 polyfill 使用的,而第二种才是针对库开发使用的,这样做不会侵入应用代码。
如何指定依赖
简单理解,当三方包被直接打包进最终产物(被 Bundle 进产物中)时,将其设置为 devDependencies,否则根据使用需求将其设置为 dependencies 或 peerDependencies。
版本号与 dist-tag
普通的项目简单遵循semver
规范即可,即<major>.<minor>.<patch>
,例如1.2.3
。
其中,major 表示主版本号,minor 表示次版本号,patch 表示修订号。每个版本号必须是非负整数。当发布新版本时,按照以下规则进行更新:
- 如果对现有 API 进行了不兼容的更改,增加主版本号(major),重置次版本号(minor)和修订号(patch)为 0。
- 如果增加了新的功能,但是保证了向后兼容性,则增加次版本号(minor),重置修订号(patch)为 0。
- 如果只是进行了错误修复或优化,增加修订号(patch),不改变其他版本号。
除了版本号外,semver 规范还定义了一些特殊的版本号标识符,通常较大型的开源项目都会使用,版本号标识符也不固定,一般用的较多的标识符包括:
- alpha:内部测试版本
- beta:公开测试版本
- rc:发行候选版本
这些标识符可以添加到版本号中,同时也可以额外添加版本,例如1.0.0-alpha.0
、1.0.0-beta.1
等。
与版本号标识符对应,一个 npm 包也可以有多个dist-tag
,如latest
、beta
、alpha
等,用于标识不同的发布状态。默认使用npm install xxx
下载标记为latest
的dist-tag
,这也是默认发包时指定的dist-tag
。也可以通过npm install xxx@dist-tag
手动指定,如 npm install lodash@beta
。
通常情况下,我们会将版本号标识符与对应 npm 的dist-tag
对应,以此来让版本管理更加有序。
使用云构建发包
通常,一个流程较为规范的 npm 包都不会在本地直接发包,因为无法排除本地的环境影响,而是采用 ci/cd 平台代替发包,也就是云构建发包。比如目前很多开源项目都是依赖于 github 提供的 github action 实现发包的。
为什么使用云构建发包
- 保证构建环境统一纯净,这点在多人协作时尤为重要,降低不同环境因素产生的影响,并且通常都会带有环境隔离机制,可以保证每次构建不受缓存等因素的影响。
- 发布源码可追溯,每一次构建结果都可以找到对应的源码,本地构建无法保证发布的代码是哪次 commit 的结果。
- 可以在构建过程中保留构建日志,帮助后续查找问题原因。
- 一键部署,通常在 ci/cd 平台中都会提供一站式的发布机制,帮助快速集成上线。
总结
最后,对上面的内容做一个总结:
- 发包时不能完全依赖打包工具的 tree shaking 能力,因为它有着"副作用"与一些使用局限性。
- 目前社区内主要有两种打包方案:
Bundle
与Bundless
,我们可以根据打包需求做灵活变动,一般建议 esm 和 cjs 模块遵循 Bundless 模式,当需要 umd 模块时,使用 Bundle 模式。 - package.json 中除了 main、module 等字段外,可以添加 exports 字段实现子路径导出。
- package.json 中的 main、module 等字段应该指向开发目录,同时在 publishConfig 中对应字段用构建目录代替,在发包时自动替换。
- 构建产物最好不要 minify 和提供 polyfill,并根据实际情况来决定是否提供 sourcemap。
- 当三方包被直接打包进最终产物时,将依赖设置为 devDependencies,否则根据使用需求将其设置为 dependencies 或 peerDependencies。
- 发布项目尽量遵循 semver 规范,同时可以添加版本号标识符与 npm 的 dist-tag 让版本管理更加有序。
- 尽量使用云构建发包来代替本地发包,减少环境因素对发包的影响。