Vue组件库工程探索与实践之构建工具

我们团队近期发布了移动端 Vue 组件库 NutUI 的 2.0 版[1],2.0 不是 1.0 的升级,而是一个全新的组件库。从 1.0 到 2.0 一路走来,我们积累了一些 Vue 组件库的开发经验,接下来的一段时间,我们将以系列文章的形式与大家进行分享,欢迎大家关注。

作为《Vue组件库工程探索与实践》系列文章开篇之作,我们从“盘古开天地”说起吧。

从当年的静态页面到如今的 Web App,前端工程越来越复杂,对于一个稍大些的前端项目来说,代码都写在一起难以维护,团队分工协作也成问题。根据软件工程领域的经验,解决这些问题的一个可行思路就是代码的模块化,即对代码按功能模块进行分拆,封装成组件,而反过来讲,组件就是指能完成某个特定功能的独立的、可重用的代码块。

把一个大的应用分解成若干小的组件,而每个组件只需要关注于某个小范围的特定功能,但是把组件组合起来,就能构成一个功能庞大的应用。组件化的网页开发也是如此,就像搭积木,各个组件拼接在一起就组成了一个完整的页面。

组件化开发可大大降低代码耦合度、提高代码的可维护性和开发效率,同时有利于团队分工协作和降低开发成本。这种开发模式已日渐流行起来。

当前,前端开发领域最流行的三大框架 Vue、React、Angular 都推崇组件化开发,组件是这些框架中极为重要的概念和功能。

以 Vue.js 来说,组件 (Component) 可以说是其最强大的功能,它可以扩展 HTML 元素,封装可重用的代码。Vue.js 的组件系统让我们可以用这些独立可复用的小组件来构建大型 Vue 应用,几乎任意类型的 Vue 应用的界面都可以抽象为一个组件树。

如果我们把日常应用开发中常用的组件累积起来,后续的项目就可以复用这些组件,这对提高开发效率、降低开发成本有重要意义。

因此,一个前端团队拥有一个常用框架的组件库是十分必要的。

模块化与构建工具

组件库自身就是一个大的工程,需要按照模块化开发思想进行模块划分。通常,在一个组件库里,组件、组件的样式文件、配置文件…都是模块,而最终我们需要把这些模块组合成一个完整的组件库文件,承担这种组装工作的就是打包构建工具。当下主流的库构建工具主要有 Rollup 和 Webpack 等。在说这些模块打包构建工具之前,我们先来了解一下目前主流的 JavaScript 模块化方案。

JavaScript 语言一直以来饱受诟病的一个地方就是它的语言标准里没有模块(module)体系,这对开发大型的、复杂的项目形成了巨大障碍。直到 ES6 时期,才在语言标准层面实现模块功能(ES6 Module)。在 ES6 之前,业界流行的是社区制定的一些模块加载方案,如 CommonJS 和 AMD 。而 ES6 Module 作为官方规范,且浏览器端和服务器端通用,未来一定会一统天下,但由于 ES6 Module 来的太晚,受限于兼容性等因素,可以预见的是今后一段时期内,多种模块化方案仍会共存。

  • ES6 Modue 规范:JavaScript 语言标准模块化方案,浏览器和服务器通用,模块功能主要由 export 和 import 两个命令构成。export 用于定义模块的对外接口,import 用于输入其他模块提供的功能。
  • CommonJS 规范:主要用于服务端的 JavaScript 模块化方案,Node.js 采用的就是这种方案,所以各种 Node.js 环境的前端构建工具都支持该规范。CommonJS 规范规定通过 require 命令加载其他模块,通过 module.exports 或者 exports 对外暴露接口。
  • AMD 规范:全称是 Asynchronous Modules Definition,异步模块定义规范,一种更主要用于浏览器端的 JavaScript 模块化方案,该方案的代表实现者是 RequireJS,通过 define 方法定义模块,通过 require 方法加载模块。

一些“上年纪”的国内前端老艺人们可能还会提到 CMD 规范,它是 SeaJS 在推广过程中对模块定义的规范化产出,只是 SeaJS 并未实现国际化,且项目在2015年就已宣布停止维护了,算不上当前主流模块化方案。

介绍完主流模块化规范,我们再回过头来看 Rollup 和 Webpack 这两个模块打包构建工具。

rollup

Rollup 是一个颇有名气的库打包工具,很多知名的库、框架都是使用它打的包,包括 Vue 和 React 自身。Rollup 可以直接对 ES6 模块进行打包,它率先提出并实现了 Tree-shaking 功能,即在打包时静态分析 ES6 模块代码中的 import,排除未实际使用的代码,这有助于减小构建包的体积。

rollup

另一个打包工具 Webpack 名气更大,不过我们通常用它来打包应用,而事实上它对库打包也能提供很好的支持。Webpack 支持代码分割、模块的热更新(HMR)等功能,这让它看起来非常适合打包应用。而 Webpack 2 及后续版本陆续增加了对 ES6 模块、Tree-shaking、Scope Hoisting 的支持,大大增强了其库打包能力。

如今,Rollup 在库打包方面的优势已不再那么明显,而在对应用打包的支持方面却明显落后于 Webpack 。所以打包应用推荐使用 Webpack ,而打包库的话, Rollup 和 Webpack 基本都能胜任。

那么我们在开发 NutUI 2 的时候为什么选择了 Webpack 而不是 Rollup 呢?其实主要还是上述这个原因,按照规划,NutUI 的官网(包含示例和文档)与库在同一个项目中,因此我们需要一个既能打包库,又能打包应用的工具,Webpack 显然更适合。

Webpack打包Library

使用 Webpack 来打包应用,相信大多前端小伙伴都不会感到陌生。可如何使用 Webpack 来打包一个组件库呢?各位细听我来言。

首先,虽然基于 ES6 模块规范开发,但考虑到浏览器兼容性,我们需要打包出来的组件库能兼容 AMD 等浏览器端模块规范。同时,为了使组件库能支持服务端渲染(SSR)等场景,它还需要支持 commonJS 规范。此外,还有一种常见的库使用场景,即在页面上直接通过 script 标签引入,也就是非模块化环境同样需要兼容。

Webpack 中,output.libraryTarget 选项用来配置如何暴露库,可配置以 commonJS 模块、AMD 模块,甚至全局变量形式暴露库。可是如何让这个库可以同时兼容 commonJS、AMD 和全局变量呢?

所幸,这个选项还支持一个可选值—— umd。UMD(Universal Module Definition,通用模块规范)可以同时支持 CommonJS 和 AMD 规范,以及非模块化引用。

综上,我们需要把 output.libraryTarget 的值设为“umd”。

另外两个与库打包关系密切的Webpack配置项如下:

  • output.library ,对外暴露的变量名或模块名,具体作用与 output.libraryTarget 选项的值有关。
  • output.umdNamedDefine ,当 output.libraryTarget 的值为“umd”时,设置该选项的值为 true 会对 UMD 的构建过程中的 AMD 模块进行命名,否则就使用匿名的 define,匿名的 AMD 模块。

这几个选项配置完,就可以打包出一个基于 umd 规范库了。

output: {
path: path.resolve(__dirname, '../dist/'),
filename: 'nutui.js',
library: 'nutui',
libraryTarget: 'umd',
umdNamedDefine: true
}

但是我们会发现构建出来的库在 Node.js 环境使用时会报错:

window is not defined

是不是感到莫名其妙?说好的 UMD 兼容 commonJS 呢?查看 Webpack 构建出的包代码,我们会发现,UMD 部分的代码里的全局对象竟然是 window !非浏览器环境哪有 window 对象,Node.js 中不报错才怪。

(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory(require("vue"));
else if(typeof define === 'function' && define.amd)
define("nutui", ["vue"], factory);
else if(typeof exports === 'object')
exports["nutui"] = factory(require("vue"));
else
root["nutui"] = factory(root["Vue"]);
})(window, function(__WEBPACK_EXTERNAL_MODULE__2__) {

查阅 Webpack 文档,可以发现 output 对象还有一个属性叫 globalObject ,用来指定挂载这个库的全局对象,默认值是 window 。而这部分文档明确指出,当构建 UMD 包需要兼容浏览器和 Node.js 环境时,值应该设为 this 。

output: {
path: path.resolve(__dirname, '../dist/'),
filename: 'nutui.js',
library: 'nutui',
libraryTarget: 'umd',
umdNamedDefine: true,
globalObject: 'this'
}

我们将 globalObject 设置为 ‘this’ 后,构建出来的包中 UMD 部分的 window 被替换为了 this ,这样在 Node.js 环境就不会再报上面那个错了,这对实现组件库兼容服务端渲染功能来说非常重要。

(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory(require("vue"));
else if(typeof define === 'function' && define.amd)
define("nutui", ["vue"], factory);
else if(typeof exports === 'object')
exports["nutui"] = factory(require("vue"));
else
root["nutui"] = factory(root["Vue"]);
})(this, function(__WEBPACK_EXTERNAL_MODULE__2__) {

这里吐个槽,个人感觉 Webpack 这部分设计欠妥,当 libraryTarget 值为 umd 时 globalObject 默认值应该为 this ,而不能是 window ,否则 umd 还有何意义?至少在文档中 libraryTarget: ‘umd’ 部分对此问题应该有所提及,不然还会有不少人踩此坑。

外部依赖Vue.js

Vue 组件库不需要把 Vue.js 也打包进去,可在运行时从外部获取。Webpack 中可以通过 externals 配置外部依赖。我们不妨以 jquery 为例看下 externals 的配置方法:

externals: {
jquery: 'jQuery'
}

这样 jquery 在构建时不会打到包内,而是在运行时需要 jquery 的时候去外部环境寻找 jQuery 这个模块(或属性)。照猫画虎,依葫芦画瓢,我们不需要打包 Vue.js ,那我们就这么写:

externals: {
vue: 'vue'
}

这时候构建出来的包在各种模块化场景使用都没毛病,可唯独在非模块化场景会报错:

vue is not defined

这是为什么呢?我们先来看下 Vue.js 的部分源码:

/*!
* Vue.js v2.6.10
* (c) 2014-2019 Evan You
* Released under the MIT License.
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Vue = factory());

从上面的 Vue.js 源码中,我们可以看到挂到全局对象上的 vue 属性名称是首字母大写的 Vue,而其 NPM 包名却是小写的 vue ,也就是说不同环境下 Vue 名称不尽一致,这可如何是好?

{
"name": "vue",
"version": "2.6.10",

还好,externals 中属性的值除了字符串,还支持传一个对象,可针对各种场景单独设置模块名(或属性名),这样一来,我们就可以为非模块化环境配置 ‘Vue’,为模块化环境配置 ‘vue’。

externals: {
'vue': {
root: 'Vue',
commonjs: 'vue',
commonjs2: 'vue',
amd: 'vue'
}
}

Vue.js 就是这样被设置为组件库外部依赖的。

Tree-shaking(摇树)

如前文所述,Tree-shaking 功能最早由 Rollup 提出并实现,曾是 Rollup 的杀手锏,后来 Webpack 等工具把它“借鉴”走了。

摇树

Tree-shaking 的原理是在打包时通过对代码进行静态分析将未使用的代码排除,从而减小包体积。对 JavaScript 进行静态分析,这在之前是不可能的。直到 ES6 模块化方案的提出,才使得 JavaScript 静态分析成为可能,因为 ES6 模块是编译时加载,不用等到代码运行时就可以知道加载了哪些模块。因此要使用 Tree-shaking 功能,就需要在代码中使用 ES6 模块方案,不管是用 Rollup 还是 Webpack 打包。

还有一个影响 Tree-shaking 施展的可能,那就是 Babel 在 Webpack 开始“摇”之前把你的 ES6 模块转成了 commonJS 模块,那就“摇”不了了。这种情况并不罕见,大部分前端开发者都乐于使用新语法,所以不止模块化方案要用 ES6 Module ,甚至整个项目的 JavaScript 代码都用 ES6+ 语法来写,为了兼容低版本环境,通常会使用 Babel 等工具把 ES6+ 语法转成 低版本语法。这当然没问题,只是如果想让 Tree-shaking 发挥作用,让我们构建出来的包体积更小,一定要注意,不要让 Babel 把ES6模块语法转成 commonJS ,Rollup 和较新版本的 Webpack 都支持直接处理 ES6 模块,可以也应该把 ES6 模块部分直接交给它们来处理。不使用 Babel 处理ES6模块,并不意味着最终打出来的包就是 ES6 模块,如前文所述,构建出来的包如何暴露,要兼容哪些模块规范打包工具就能搞定。

{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
]
]
}

我们测试了一下,Tree-shaking 让 NutUI 2.0 的完整版的构建文件体积明显减小。

好了,关于构建工具我们先说到这里,具体实现细节可以参考 NutUI 2.0 的源码[2]。后续的文章我们还会谈组件库的按需加载、主题定制、国际化、单元测试、持续集成、基于Markdown文件生成静态文档网站、Vue公共组件开发等方面的探索实践经验,敬请关注。

链接

[1] NutUI 2.0 官网 https://nutui.jd.com
[2] NutUI 2.0 代码仓库 https://github.com/jdf2e/nutui