乐趣区

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

我们团队近期发布了移动端 Vue 组件库 NutUI 的 2.0 版,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 是一个颇有名气的库打包工具,很多知名的库、框架都是使用它打的包,包括 Vue 和 React 自身。Rollup 可以直接对 ES6 模块进行打包,它率先提出并实现了 Tree-shaking 功能,即在打包时静态分析 ES6 模块代码中的 import,排除未实际使用的代码,这有助于减小构建包的体积。

另一个打包工具 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

退出移动版