虽然我有维护 dva、umi 等仓库的经验,但说到组件如何打包,一直是抱着够用就行的态度,所以始终是一知半解,之前都是用 babel 把 src 转为 lib 就完事,最多加上一个 umd 格式。
所以,关于组件打包,其实我有很多问题需要被解答。比如:
- 组件为什么要打包?可不可以不打包?
- package.json 里的 main、module 和 unpkg 等是什么意思?
- cjs, esm, umd, amd 是什么意思?他们是怎么来的?
- es 2015 和 es6 是什么关系?
- tree-shaking 怎么实现的?
- 有哪些组件打包方式?最佳实践是什么?
- webpack、rollup、babel、browserify、microbundle、pikapkg 等等,该怎么选?
- 流行的社区库是怎么处理的?
- 等等
然后,趁着让 umi 支持组件打包的机会,我对组件打包做了详细调研。
为什么要打包?
要打包的原因
我整理了几个组件为啥要打包的原因,
- 可以使用新的语法特性,甚至非 JavaScript 语言,比如 TypeScript
- 一份源码,多个输出
按现在的约定 node_modules 通常是不走 babel 编译的,所以使用新的语法特性要走打包,否则要么压缩代码时会报错,要么上到生产环境后某些浏览器会报语法错误。
多个输出指组件会有多种用法,以下是我能想到的,并在后面标注了相关的输出格式:
- 作为 npm 包使用,走 webpack 构建(cjs、esm)
- 浏览器通过
<script>
标签引入- type 为
module
(esm with dependency) - type 为空或
text/javascript
(umd)
- type 为
- 浏览器通过
requirejs
或seajs
等加载器引入(umd)
其中 requirejs
和 seajs
现在已经很少用到了,下图是支付宝登录页面的源码,
什么时候可以不打包?
那什么时候可以不打包?我觉得还是有一些场景的。
比如,你只需要一种输出方式,然后你按照那种输出方式来写,并且用 es5 语法,或者不需要考虑低版本浏览器然后用 es6 语法。
再比如,你 nb 到一定程度,像 query-string 一样,社区来适配你。
什么是打包?
打包简单理解,就是 src
到 dist
,然后 dist
下包含多种格式,满足各种使用场景,比如 umd
、cjs
、esm
、amd
、esm with dependency
等。
至于要搞懂这些名词是什么意思?以及他们怎么来的?我们需要先看了模块系统的历史。
模块系统历史
ES5
ES5 阶段有两种模块系统类型,cjs 和 amd。
简单来说,cjs 是给 node 用的,适合服务端,同步加载,通过 require
引入模块,通过 module.exports
导出;amd 是给浏览器用的,异步加载,通过 define
定义模块,通过 require
使用模块,实现层有 requirejs 和 seajs 等。
这两种的模块结构是动态的,在运行时解析。
ES6/ES2015
ES6 阶段统一了模块类型,一种类型同时满足同步和异步两种需求。通过 export
定义模块,通过 import
引用模块。
应用方面,在浏览器端现在通常是打包成 es5 的代码使用,或通过 <script type="module">
引入 mjs 文件;node 端需要通过 --experimental-modules
flag 开启才能用。
然后他的模块结构是静态,静态意味着我们可以在编译时分析出模块结构和引用关系等,也正因为这个,才有了 tree-shaking,让我们可以去除冗余代码。
ES 名词解释
前面有看到 ES6 和 ES2015,为啥有一位数和年份两种版本号?在做调研时我也顺便了解了下 JavaScript 和 EcmaScript 的历史,
- 1995
– LiveScript,作为 Netscape Navigator 的一部分
– 一年半后,更名为 JavaScript - 1996
– Netscape 向 ECMA International 提交标准化 JavaScript
– 即 ECMAScript - 1999
– ECMAScript 3 - 2009
– ECMAScript 5 - 2015
– ECMAScript 6 - 2016
– ECMAScript 7
从 2015 年开始,ECMAScript 推荐用年份来做版本号,所以就有了 ECMAScript 2015 = ES6。还有 ES Next
的说法,我理解应该是指 babel 等编译器吧,可以让我们提前用上所有提案中的语言特性。
package.json
然后还有个知识点是 package.json
中的各种 key 是什么意思,
- main,指向 cjs
- module,指向 esm
- unpkg,指向 umd
- umd:main,同 unpkg,用一个就好了
- typings,TypeScript 定义
- sideEffects,可以是 false,也可以是数组,拿来做 tree-shaking
然后 webpack
里 target 为 web 时会依次找 browser
、module
和 main
,其他 target 会依次找 module
和 main
。所以,只要我们定义了 module
,webpack 就会优先用 esm 格式的文件,毕竟 tree-shaking 能带来不少好处。
如何打包?
先从使用的角度来看看每种模块系统类型的产物,
按模块系统类型来看
cjs/commonjs
如果不用考虑 node 端的使用,cjs 格式可以不用生成。
esm
现在的主流格式。
umd
umd 看需求,现在引用 umd 通常是和 external 配合使用来提升构建性能,所以尺寸较小的类库可以不生成。
社区例子
调研过程中有看不少开源类库的打包方式,
redux
redux 最为典型,如果要学组件打包,看这个就够了。
注意几点:
- cjs 和 esm 打包时不会包含依赖,umd 和给浏览器用的 esm 会包含依赖
- umd 和给浏览器用的 esm 需要有打包后的版本,因为是直接给生产环境用的
- 打包依赖时,并不包含 peerDependency,比如 react、react-dom 可以放这里
developit/htm
基于 developit 自己写的 microbundle,microbundle 是 rollup 的封装,从打包脚本就能看出产物和格式了,
microbundle src/index.mjs -f es,umd –no-sourcemap –target web
mweststrate/immer
react-router
cjs proxy
值得一提的是,redux-saga 和 react 在打包时会额外生成一个 cjs proxy,先生成不同环境的 cjs 文件,然后根据 process.env.NODE_ENV
自动引入不同环境版本的 cjs 文件。
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}
我们的方案
注:
- cjs 和 esm 同时支持 babel 和 rollup 两种打包格式,因为 babel 整个目录输出的方式会适用于某些场景,比如 antd 会针对每个组件输出样式,然后我们通过 babel-plugin-import 做样式的按需打包,由于 esm 不能覆盖 css,所以 rollup 这种把所有文件打包到一起的方式不能适用于这种场景
参考
- umi library 实现讨论,包含方案,调研笔记,以往的分享等,https://github.com/umijs/umi/issues/1550
- 关于模块系统将地最好的一篇文章,http://exploringjs.com/es6/ch_modules.html
- node 关于 esm 的文档,https://nodejs.org/api/esm.html
- node 的模块系统,https://nodejs.org/api/modules.html
- webpack 关于 tree-shaking 的文档,https://webpack.js.org/guides/tree-shaking/
- webpack 的 resolve 规则,和 package.json 里的值有关,https://webpack.js.org/configuration/resolve/#resolve-mainfields
- node 的 esm loader,https://github.com/standard-things/esm
- 关于 cjs、amd、requirejs 和 esm,https://medium.com/computed-comparisons/commonjs-vs-amd-vs-requirejs-vs-es6-modules-2e814b114a0b