前言
当我们在打包 webApp 的时候,各式各样的脚手架提供了丰富的全家桶服务,无需过多的配置,甚至不需要关心内部的逻辑,一行命令搞定一切,这无疑降低了 web 工程的门槛,让开发者更聚焦于业务/功能实现上。像早些年开发一个项目,30% 的时间都在捣鼓 webpack/babel 的情景已不复存在。正如 create-react-app 的介绍:
You don’t need to install or configure tools like webpack or Babel. They are preconfigured and hidden so that you can focus on the code.
而当我们想发布一个第三方库或组件的时候,一些事情开始变的没那么清晰。
应该输出哪些目录或文件类型?esm/lib/umd/cjs ?
要合并 bundle 还是直接用 babel 转义输出?
怎样才能达到最好的 tree-shaking 效果?
Babel 要使用 preset-env 还是 transform-runtime?
如果只是想简单地生成一个 npm 包,达到快速复用的目的,那么像 father、microbundle 之类的零配置 bundle 工具就能满足大部分的诉求。但如果想更精细地控制每个环节,达到最优的输出体积和使用效果,那么上边的问题就需要搞清楚。
本文并非 npm 打包的 A-Z 教程,仅会针对容易困惑的点进行展开,所以需要对 babel、rollup、webpack 有一定的使用和配置经验。
为了更好地分析说明,我们将目光聚焦在 lib (纯功能)的打包上,暂不涉及 UI 组件打包。所有使用的工具、库均为当前最新的 release 版本(下文会标注),不再关心旧版本中的差异。
为配合代码演示,这里准备了一个示例项目 - 简易计算器(地址见文末原文链接),下边的代码示例以及接下来的姊妹篇《如何打好一个NPM包-UI组件篇》均会基于该项目展开。有兴趣的同学可以拉取到本地手动调试各个配置项,文中限于篇幅只能着重讲一些核心,让大家脑海中有个大致的轮廓,手动调整每个配置项以及组合,才能更好的了然于胸。
项目文件结构如下,暂时无需关心文件中的内容。
|─ src
└─ features
└─ Displayer.js // 展示功能类
└─ Processor.js // 计算功能类
└─ utils
└─ platform.js // 判断当前的平台
└─index.js // 集中导出该 lib 的功能
|─ .babelrc
|─ .browserslistrc
|─ package.json
|─ rollup.config.js
OK,正文开始,嘿喂Go!
2
应该输出什么
输出的文件类型通常取决于 npm 包的使用场景,也连带决定了是输出 bundle 还是直接转义。
CommonJS / cjs
由 Node.js 实现,通常用于服务端。无需处理依赖包,无需合并 bundle,直接使用 babel 转义指定 cjs 格式即可。
UMD
CommonJS + AMD + 全局变量的组合方式,通常用于 web 端通过 script 引入,需要处理依赖包,合并 bundle,并发布线上地址。
ES Module / es
ECMAScript 标准的 module 规范,会有两种使用场景:
1. 作为其它项目的本地依赖
无需处理依赖,是否合并 bundle 通常不影响功能,会影响使用者的最终代码体积。合并意味着在 lib 打包时就做了作用域提升和 tree-shaking,单个 bundle 体积更小。不合并就是交由使用方的 bundler 进行处理,作用域提升和 tree-shaking 也取决于使用方的 bundler,但针对多出口的 lib,能提升最终的打包体积,比如 lodash-es、swiper 。
2. 通过浏览器的 script type="module" 引入
除非确定所有依赖都符合 esm 规范并且有线上发布地址,否则还是需要处理依赖,进行合并,并发布线上地址。
小结
目前主流的 bundler 均已支持 esm 的解析,而且 esm 是 tree-shaking 必要条件,所以 esm 应该作为主要的输出形式,其它形式可根据目标场景做取舍。而其它比如 AMD/CMD 由于使用场景已经越来越少,这里就不再赘述。
3
Tree-Shaking
随着近几年的发展,tree-shaking 已被各个主流 bundler 支持,那我们输出的 npm 包要如何配合才能在 webApp 中使用时达到最理想的状态呢?
Tree-shaking 简单地讲就是删除无用代码。在纯粹的 ES module 世界里,如果每个模块只有 import 和 export,那么这件事可能没那么复杂,只需确定每个模块的 exports 被使用的情况,就可以移除未被使用的模块或文件。然而现实还是残酷的,非 esm 类型的依赖、polyfills、不友好的代码书写方式都增加了判断无用代码的难度。
鉴于目前打包 webApp 主要还是使用 webpack,那么就基于 webpack( v5.3) 来分析。Webpack 删除无用代码大致是这么一个流程:
确定各个模块的 exports,by providedExports;
确定各个模块被使用的 exports,by usedExports、sideEffects、terser;
将用到的 modules 以及 exports 进行串联,合并到一个作用域下(scope hoisting), by concatenateModules;
对于无法进行作用域提升的模块,被使用的 exports 会被注册到__webpack_exports__ 中,没被注册的 exports 由于没有任何引用,最终会被压缩工具删除。
其中,我们主要关心的有两点:
1. ConcatenateModules
ConcatenateModules 是 Webpack 对作用域提升(scope hoisting)的一种实现,利用静态分析将依赖树中所有被使用的模块(usedExports)串联到一个闭包中。这样做的好处是:
模块的声明函数以及 exports 的注册函数没有了,带来 bundle 体积的减小。
函数作用域减少了,代码运行的耗时和内存消耗也随之降低。
2. SideEffects
SideEffects 是指当前模块不是纯粹的只暴露 exports,被引入时会产生其它的副作用。比如 polyfill、css 引入、造成了全局或原型链变化的代码等等。配置该参数就是告知 webpack,哪些文件会有副作用(或者都没有)。被标记为 sideEffects: false 的模块,如果所有 exports 都没被使用,则不做内部的副作用评估,直接跳过该模块;标记为sideEffects: true 的模块,则会经过评估后保留可能有副作用的代码。
ConcatenateModules 通常不用关心(其实也没什么可操作的),下面通过示例我们来看下 sideEffects 发挥的作用,以及一些特殊情况。示例项目的代码:
/** utils/platform.js **/
const platform = navigator.platform
export const UA = /*#__PURE__*/ navigator.userAgent;
export const isMac = /*#__PURE__*/ /mac/i.test(platform);
export const isWindows = /*#__PURE__*/ /win32/i.test(platform);
/** features/Processor.js **/
export class Processor {
// 异步计算参数之和
compute(a, b) {
return Promise.resolve(a + b);
}
}
/** features/Displayer.js **/
export class Displayer {
constructor(opts) {
this.options = Object.assign({ color: '#333' }, opts);
}
// 输出展示内容
flush(msg) {
console.log(msg);
}
}
/** index.js **/
// 导出以上功能
export * from './utils/platform';
export * from './features/Processor';
export * from './features/Displayer';
在我们的库中导出了多个功能:Processor 负责计算,Displayer 负责展示,platform 提供一些辅助函数/变量(为了更好的演示 webpack 的 tree-shaking,库本身暂不做打包)。那么在使用时,如果只引入其中一部分功能,最终打包后会不会掺杂其它功能的代码?
纯模块引入
新增目录 example/src,增加 main.js 文件,这里我们暂时只引入 Displayer:
/** example/main.js **/
import { Displayer } from '../src';
const displayer = new Displayer();
displayer.flush('Displayed here');
在 example 目录下安装 webapck,由于 production 模式下 webpack 会默认开启 tree-shaking 和压缩,这里先关闭 sideEffects。配置如下:
/** example/webapck.config.js **/
const path = require('path');
module.exports = {
entry: './main.js',
output: {
filename: 'app.js',
path: path.resolve(__dirname, 'dist'),
},
optimization: {
sideEffects: false,
}
};
执行 cd example && npm run build,打包后 app.js 代码如下:
!function () {
const s = navigator.platform
navigator.userAgent, /mac/i.test(s), /win32/i.test(s), (new class {flush(s) {console.log(s)}}).flush('Displayed here')
}()
从打包结果可见:
Processor.js 是纯 exports,未被引入,所以整个被跳过了。
Platform.js 不是纯 exports,虽然未被引入,但一部分执行代码被保留了。
因为 webpack 无法判断 navigator.userAgent(全局属性)、regexp.test(原型方法)之类的调用是否会产生其它影响,所以安全起见只能保留下来。
同时也可以看出,模块声明和 exports 已经被消除了,这就是作用域提升带来的效果,清爽~
开启 sideEffects
为根目录 package.json 中增加 sideEffects: false(声明包代码无副作用),修改 example/webpack.config.js 中的 sideEffects 为 true(开启识别包中的 sideEffects),重新打包:
!function () {
(new class {flush(s) {console.log(s)}}).flush('Displayed here')
}()
跟预期一致,未引入的模块代码均被删除了。
模块内部分功能引入
修改 main.js,引入 platform.js 中的部分功能 - isMac:
/** example/main.js **/
import { Displayer, isMac } from '../src';
const displayer = new Displayer();
const platform = isMac ? 'mac' : 'other'
displayer.flush('Displayed in ' + platform);
打包输出:
!function () {
const t = navigator.platform, s = (navigator.userAgent, /mac/i.test(t)), n = (/win32/i.test(t), s ? 'mac' : 'other');
(new class {flush(t) {console.log(t)}}).flush('Displayed in ' + n)
}()
可见,虽然我们只使用了 isMac 函数,但是 UA、isWindows 的代码还是保留了。这是因为 sideEffects 只作用于模块级别,在模块所有 exports 都没被使用时跳过该模块。而在模块被引入后,函数级别的无副作用声明需要借助 /* #__PURE__ */ 注释,当函数返回值没有被使用时,函数也可以安全的删除。PS:函数声明不会产生副作用(export const foo = function() {...}),函数执行才会(export const foo = getFoo())。
函数增加 PURE 声明
修改 platform.js:
/** utils/platform.js **/
const platform = /*#__PURE__*/ navigator.platform;
export const UA = /*#__PURE__*/ navigator.userAgent;
export const isMac = /*#__PURE__*/ /mac/i.test(platform);
export const isWindows = /*#__PURE__*/ /win32/i.test(platform);
打包输出:
!function () {
const s = navigator.platform, t = (navigator.userAgent, /mac/i.test(s) ? 'mac' : 'other');
(new class {flush(s) {console.log(s)}}).flush('Displayed in ' + t)
}()
这里 isWindows 的代码没了,但 UA 的执行代码为什么没删掉,明明加了 #__PURE__。看来 #__PURE__ 只能作用于函数级别,代码块并不生效。
属性调用转自执行函数
继续改,将属性调用改为自执行函数:
/** utils/platform.js **/
const platform = /*#__PURE__*/ (() => navigator.platform)()
export const UA = /*#__PURE__*/ (() => navigator.userAgent)();
export const isMac = /*#__PURE__*/ /mac/i.test(platform);
export const isWindows = /*#__PURE__*/ /win32/i.test(platform);
打包输出:
!function () {
const s = /mac/i.test((() => navigator.platform)()) ? 'mac' : 'other';
(new class {flush(s) {console.log(s)}}).flush('Displayed in ' + s)
}()
哈利路亚~ 代码终于“干净”了,流下激动的泪水!
小结
为了更好的配合 tree-shaking,应该注意以下几点:
输出 ESModule 标准的文件类型。
每个模块都尽可能的只有纯粹的 import 和 exports。
packgage.json 中声明 sideEffects 范围。
使用 /*#__PURE__*/ 为执行函数声明无副作用。
适当对全局变量的使用增加自执行函数包装。
4
Babel And Polyfills
如果没有兼容问题,文章到此就该收尾了,但语法转义和 polyfills 仍然是生产环境不可或缺的一部分。想把 babel 配细致的话,还是得捋清其中各个配置项的功能。如果网上搜,各式文章铺天盖地,不少都已经过时,对于新人会产生很多干扰。这里推荐关注 babel 7.4+,围绕 preset-env 和 transform-runtime展开的文章,配合官方文档一起“食用”。搞清楚这两个,思路就可以清晰一些,其它的配置也就可以自行摸索了。这里不再过多深入,大致概括下:
Babel
简单的讲,babel 主要负责两件事:
将 es5+ 语法转义(transpile)为目标环境支持的语法,会保留很多 helper 的通用函数,比如 _extend、_createClass 等。依托各种 helper 插件。
借助 core-js 为目标环境不支持的特性注入 polyfills。
@babel/preset-env
Presets 就是各种插件的集合,env 是官方推出的其中之一。一些主要配置项:
useBuiltIns- 控制如何处理 polyfills。
usage - 检测代码中的新特性,结合 browserslist 确定注入项。简单,但是不准(准确地讲是会过度注入)。
entry - 根据 core-js 的显式引入(import),结合 browserslist 确定注入项,不管代码中是否用到。比较准确,但要求使用者清楚自己需要什么。
corejs- 当 useBuiltIns 被启用时,使用什么版本的 core-js 进行注入,需自行安装对应的 core-js 到 dependencies 下。
modules- 语法转换后的模块类型。如果要发挥 bundler 的 tree-shaking 功能,需要设为 false,保持 esm 类型。
Preset-env 会以 inline 的形式为每个文件注入 inline 形式的 helper 通用函数;以全局形式注入 polyfills。
@babel/plugin-transform-runtime
该插件主要有两个功能:
将 helpers 转换为 @babel/runtime 的引用,避免每个文件都有 inline 形式的 helper 通用函数,减少冗余代码。
将 polyfills 以沙盒的形式引入,避免污染全局。
该插件依赖 @babel/runtime 包,如果需要 polyfill 则安装 @babel/runtime-corejs3 或者 @babel/runtime-corejs2,否则安装 @babel/runtime。一些主要配置项:
useESModules- 控制 helpers 是否以 ES Module 形式注入,为配合 tree-shaking,应该开启。
corejs- 类似于 preset-env 中的 corejs 配置。
version - 使用的 core-js 版本,需自行安装。
proposals - 在使用 core-js@3 时,可以设为 true,开启对提案的 polyfill。
version- 告诉 transform-runtime 当前安装的 @babel-runtime 版本,默认为 7.0.0 ,更高的版本可能会获得更好的 bundle 体积。
搞清楚了两种转义方式的特征,我们取其中一个文件对比来看下。在 Processor.js 中我们使用了 class 和 Promise 两个特性:
/** features/Processor.js **/
export class Processor {
// 异步计算参数之和
compute(a, b) {
return Promise.resolve(a + b);
}
}
在 package.json 中配置一行命令:
{
"scripts": {
...
"transpile": "babel src --out-dir lib",
}
}
配置 babel ,暂时仅使用 preset-env 进行转义。
// .babelrc
{
"presets": [
[
"@babel/env",
{
"modules": false,
"useBuiltIns": "usage",
"corejs": 3
}
]
]
}
执行 npm run transpile。
import "core-js/modules/es.object.to-string";
import "core-js/modules/es.promise";
function _classCallCheck(instance, Constructor) { ... }
function _defineProperties(target, props) { ... }
function _createClass(Constructor, protoProps, staticProps) { ... }
export var Processor = /*#__PURE__*/function () {
function Processor() {
_classCallCheck(this, Processor);
}
_createClass(Processor, [{
key: "compute",
value: function compute(a, b) {
return Promise.resolve(a + b);
}
}]);
return Processor;
}();
可以发现,基于 preset-env 转义,保留了 class 相关的 helper 函数;同时全局注入了 Promise 相关的 polyfill。
下面禁用 preset-env 的 polyfill,启用 transfrom-runtime 插件。
{
"presets": [
[
"@babel/env",
{
"modules": false,
"useBuiltIns": false
}
]
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"corejs": {
"version": 3,
"proposals": true
},
"useESModules": true,
"version": "^7.12.5"
}
]
]
}
转义后:
import _Promise from "@babel/runtime-corejs3/core-js/promise";
import _classCallCheck from "@babel/runtime-corejs3/helpers/esm/classCallCheck";
import _createClass from "@babel/runtime-corejs3/helpers/esm/createClass";
export var Processor = /*#__PURE__*/function () {
function Processor() {
_classCallCheck(this, Processor);
}
_createClass(Processor, [{
key: "compute",
value: function compute(a, b) {
return _Promise.resolve(a + b);
}
}]);
return Processor;
}();
基于 transform-runtime 转义,helper 函数全变成了 @babel/runtime-corejs3 的引用,这样重复的引用就会被提取复用;同时 Promise 也被替换为函数引用,不再影响全局。
小结
看到这里应该可以理解,为什么常说 preset-env 适用于 app,而 transfrom-runtime 适合 lib 了。但并不是独立分开使用的,preset-env 组合了各种基础的转义能力,transfrom-runtime 帮助提取通用功能,转为模块引用,同时创建沙盒模式的 polyfills。
5
Tree-Shaking VS Babel
可能你和我一样会有一个疑惑,tree-shaking 帮我们移除代码,babel 为我们添加代码,那一块使用会是什么结果呢?
我们先对 lib 示例项目进行正式打包(配置 babel,输出 esm),calculator.esm.js 如下:
import _Promise from '@babel/runtime-corejs3/core-js/promise';
import _classCallCheck from '@babel/runtime-corejs3/helpers/esm/classCallCheck';
import _createClass from '@babel/runtime-corejs3/helpers/esm/createClass';
import _Object$assign from '@babel/runtime-corejs3/core-js/object/assign';
var platform = /*#__PURE__*/function () {...}();
var UA = /*#__PURE__*/function () {...}();
var isMac = /*#__PURE__*/ /mac/i.test(platform);
var isWindows = /*#__PURE__*/ /win32/i.test(platform);
var Processor = /*#__PURE__*/function () {
function Processor() {
_classCallCheck(this, Processor);
}
_createClass(Processor, [{
key: "compute",
value: function compute(a, b) {
return _Promise.resolve(a + b);
}
}]);
return Processor;
}();
var Displayer = /*#__PURE__*/function () {
function Displayer(opts) {
_classCallCheck(this, Displayer);
this.options = _Object$assign({
color: '#333'
}, opts);
}
...
return Displayer;
}();
export { Displayer, Processor, UA, isMac, isWindows };
可以看出,经过打包后,所有模块依赖的 helpers 和 polyfills 都被合并提升到了文件头部,而且是以沙盒形式引入。那么我们只引入一个最简单的功能看看情况:
/** example/main.js **/
import { UA } from '../dist/calculator.esm';
console.log(UA);
实际打包后会生成一个很大的 app.js,lib 中暴露的所有 helpers 和 polyfills 均被保留在了最终文件中。这里省略调试过程,在 webpack 解析依赖执行合并压缩之前,得到的代码是这样的:
import '@babel/runtime-corejs3/core-js/promise';
import '@babel/runtime-corejs3/helpers/esm/classCallCheck';
import '@babel/runtime-corejs3/helpers/esm/createClass';
import '@babel/runtime-corejs3/core-js/object/assign';
var UA = /*#__PURE__*/function () {
return navigator.userAgent;
}();
console.log(UA);
相关逻辑代码已经被移除,但是依赖的 core-js 模块都保留了,变成了直接引入。回忆 tree-shaking 小节中讲到的,这些依赖可能是因为副作用无法安全的移除,查看 node_modules/@babel/runtime-corejs3/package.json 也确实没有 sideEffects: false 声明。为了测试,强行加上 sideEffects: false,再次打包:
(()=>{var n=function(){return navigator.userAgent}();console.log(n)})();
虽然确实得到了我们想要的结果,但这并不是一个符合规范的做法。仔细分析可以发现,这个问题的根本来自于我们在一个包里导出了多个模块,而希望使用方在引入其中一个模块的时候,其它模块的代码及依赖都能被移除。这个作为问题一先搁置,再考虑另一个问题二:
从 calculator.esm.js 输出代码中可以看到,polyfills 都转换为了沙盒模式,但在业务开发的项目中,我们通常使用的是全局形式的注入,比如 @vue/cli 默认就是全局注入。如果业务中使用了同样的特性(如 Promise),最终的打包代码中就会存在两份功能几乎一样的代码。另外,core-js 大版本不同也会导致冗余出现。
针对这两个问题,有几个思路:
Polyfills 依赖显式说明
带来 bundle 体积增长的主要来自 polyfill,那么在 lib 打包时我们不处理 polyfill,而是告知用户,使用哪些功能需要手动引入哪些 polyfills。这样基于用户自身的环境来引入就能避免上述问题,但会增加使用门槛,同时也需要 lib 的开发者清楚所依赖的 polyfills。适合 polyfill 比较少的情况,像 zent 就采用的该方式。
独立插件处理
在 vue 框架中,库本身没有引入任何 polyfill,而是通过 @vue/cli 中的@vue/cli-plugin-babel 插件来处理这些问题,用户可以通过 @vue/babel-preset-app 暴露的配置项对注入内容进行更细的控制。这种方案成本相对较高,适合较大型且比较稳定的库。
拆分多个文件/模块
针对问题一,如果我们在打包输出时就拆分为多个文件,用户可以更精准的引入指定的功能模块,那么就不用担心其它模块冗余代码的问题了。参考 lodash-es的包,输出了300多个文件,颗粒度很小,但为了使用方便,也在 lodash-es/lodash.js 中合并导出了所有模块。这种方案实现起来也比较简单,只需要将拆分好的功能模块均增加一个入口文件,读取到这些文件,以数组形式交由 rollup 打包即可;原来的 index 入口导出所有模块依旧保留。
源码引入
很多 NPM 包最初都来自项目源码,有时不禁会想,这个功能在我项目源码里直接用的时候压根不用考虑这么多,一个 import 搞定一切,想输出成 NPM 包时才出这么多“幺蛾子”,那么我们以源码形式引入 NPM 包又何尝不可?所有的环境配置全都以主项目为准,包本身只提供源码,polyfill 也交由主项目的 babel 进行注入。似乎挺完美,唯一要做的是需要告知主项目的 babel 不要过滤 node_modules 中的某个包。这种方案不够标准,比较适合内部团队。下边是基于 webpack-chain的示例写法:
webpackConfig.module.rule('js').exclude.add(filepath => {
if (/your_package_name/.test(filepath)) return false;
return /node_modules/.test(filepath);
} )
如果是使用 @vue/cli,那更简单,在 vue.config.js 中添加:
transpileDependencies: [/your_package_name/]
如果包名含有 scope,注意兼容斜杆('/')的环境差异,[/@myScope[\/|\\]package-name/]。
小结
目前还难以给出一个“万金油”方案,只能综合 NPM 包的规模和使用场景、团队状况等因素考虑取舍。
6
结语
该文讨论的问题均是基于笔者在开发过程中比较“头疼”的点,进行汇总收敛后与具体的技术点结合做了稍微深入的分析实验。希望大家阅读之后,能在开发 NPM 包时思路更清晰一些。能力所限,如有更好的方案欢迎一起讨论。
热门跟贴