组件打包那些事

题图:mildlee @ unsplash.com

虽然我有维护 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 支持组件打包的机会,我对组件打包做了详细调研。

为什么要打包?

要打包的原因

我整理了几个组件为啥要打包的原因,

  1. 可以使用新的语法特性,甚至非 JavaScript 语言,比如 TypeScript
  2. 一份源码,多个输出

按现在的约定 node_modules 通常是不走 babel 编译的,所以使用新的语法特性要走打包,否则要么压缩代码时会报错,要么上到生产环境后某些浏览器会报语法错误。

多个输出指组件会有多种用法,以下是我能想到的,并在后面标注了相关的输出格式:

  1. 作为 npm 包使用,走 webpack 构建(cjs、esm)
  2. 浏览器通过 <script> 标签引入
    1. type 为 module(esm with dependency)
    2. type 为空或 text/javascript(umd)
  3. 浏览器通过 requirejsseajs 等加载器引入(umd)

其中 requirejsseajs 现在已经很少用到了,下图是支付宝登录页面的源码,

什么时候可以不打包?

那什么时候可以不打包?我觉得还是有一些场景的。

比如,你只需要一种输出方式,然后你按照那种输出方式来写,并且用 es5 语法,或者不需要考虑低版本浏览器然后用 es6 语法。

再比如,你 nb 到一定程度,像 query-string 一样,社区来适配你。

什么是打包?

打包简单理解,就是 srcdist,然后 dist 下包含多种格式,满足各种使用场景,比如 umdcjsesmamdesm 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 时会依次找 browsermodulemain,其他 target 会依次找 modulemain。所以,只要我们定义了 module,webpack 就会优先用 esm 格式的文件,毕竟 tree-shaking 能带来不少好处。

如何打包?

先从使用的角度来看看每种模块系统类型的产物,

按模块系统类型来看

cjs/commonjs

如果不用考虑 node 端的使用,cjs 格式可以不用生成。

esm

现在的主流格式。

umd

umd 看需求,现在引用 umd 通常是和 external 配合使用来提升构建性能,所以尺寸较小的类库可以不生成。

社区例子

调研过程中有看不少开源类库的打包方式,

redux

redux 最为典型,如果要学组件打包,看这个就够了。

注意几点:

  1. cjs 和 esm 打包时不会包含依赖,umd 和给浏览器用的 esm 会包含依赖
  2. umd 和给浏览器用的 esm 需要有打包后的版本,因为是直接给生产环境用的
  3. 打包依赖时,并不包含 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-sagareact 在打包时会额外生成一个 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');  
}

我们的方案

注:

  1. cjs 和 esm 同时支持 babel 和 rollup 两种打包格式,因为 babel 整个目录输出的方式会适用于某些场景,比如 antd 会针对每个组件输出样式,然后我们通过 babel-plugin-import 做样式的按需打包,由于 esm 不能覆盖 css,所以 rollup 这种把所有文件打包到一起的方式不能适用于这种场景

参考