Vue组件库工程探索与实践之按需加载

avatar
UX @京东

《Vue组件库工程探索与实践》系列文章第二篇,聊一聊组件库按需加载功能。

一个组件库通常有数十个组件,随着版本迭代组件数量还可能进一步增加。组件库文件的体积也随之膨胀,动辄几百KB。而我们的业务项目中,有可能只用到了这个组件库的少数几个组件,这时把整个组件库打包进去,非但没有必要,还会徒增项目构建文件的体积,这与应用性能优化的方向是背道而驰的。因此,组件库有必要提供一种更灵活的组件引用方式,允许应用只引用指定的组件。事实上,主流的组件库基本都具备“按需加载组件”功能。

最简单的“按需加载组件”实现方式,就是在应用中直接引用所需组件的源文件,在应用的构建工具中跟应用一起构建。说它简单,是因为这种方式几乎不需要组件库做什么工作,应用直接引用组件源码,并不需要经过组件库的构建过程。

这种方式的局限性也大都与“组件未经组件库构建”有关。在应用中构建这些组件,就意味着应用的构建工具必须要具备构建这些组件的能力。比如需要有编译Vue模板、编译ES6+语法、编译Scss/Less语法、支持postcss等的能力,如果说上面这些功能很基础,大多数应用的构建工具都能支持,那么组件可能还有一些不太常见或者组件库特有的功能,比如处理SVG、定制主题、国际化等等,通常应用构建工具不具备或者依赖于组件库配置文件,这就给直接在用户的应用中编译组件源码带来了困难。另一方面,未经构建的组件模块化接口单一,无法直接在其他模块化场景和非模块化场景使用。还有,如果组件库支持直接引用组件源码,则需要把所有组件源码随NPM包一起发布,可能会导致npm包过大,看起来并不是一个好主意。

好吧,我们换个思路,不直接引用组件源码,而是让组件库对这些用户指定的组件(而非全部组件)进行构建,生成一个自定义版本的组件库给用户应用使用。这就需要组件库与用户进行交互,收集用户所需要的组件信息,然后将指定组件编译成一个自定义版本的库文件。这种自定义构建方案常见的情况有两种,一种是通过网页收集信息,在服务端进行构建。遥想当年jQuery时代,jQuery-UI库提供的自定义构建下载方式[1],让用户在线选择所需组件,然后在服务端进行编译,完成后提供给用户下载(当然,服务端也可能存在已经提前编译完的各种组合的构建包)。那个时代已然远去,如今下载安装组件库“政治正确”的姿势是通过npm/Yarn。

另一种方案是通过命令行界面(CLI)收集信息并在客户端构建。比如jQuery的“不同父异母”的小兄弟Zepto.js,官方标准包里只包含部分模块,如果需要增加或移除模块就需要进行自定义构建了:在Zepto.js项目目录下安装依赖,在MODULES中指定需要的模块,然后执行npm run-script dist进行构建,完事儿后dist目录下zepto.js和zepto.min.js就是自定义构建出来的包,拿到项目里使用即可。这种方式节约服务器资源,甚至不需要自己的服务器。

# do a custom build
$ MODULES="zepto event data" npm run-script dist

# on Windows
c:\zepto> SET MODULES=zepto event data
c:\zepto> npm run-script dist

NutUI 1.x 时期的按需加载方案,类似上述第二种方案,较之还有一些改进。用户在NutUI 1.x项目中安装依赖,然后执行npm run custom命令,这时命令行界面会列出所有组件名,用户选择需要的组件后回车,组件库的构建工具会将所选组件进行构建,得到与完整组件库文件同名的构建文件nutui.js,正常使用即可。

只看这种方案自身,似乎没什么问题,确实实现了按需构建,而且并不繁琐,只是几行命令而已,也不需要架设服务器。但是如果结合用户使用场景来看,问题还是不少:

  • 用户通常是通过npm/Yarn方式安装的组件库,需要进node_modules目录找到组件库项目目录安装依赖
  • 自定义构建之后的文件在组件库项目目录的dist目录下,因组件库目录位于node_modules目录中,而node_modules目录通常不被提交到代码仓库,因此在换电脑或多人合作的时候往往还需要再次构建才能在本地拿到自定义构建后的组件库文件,如果版本有差异,还可能会增加风险
  • 为了支持用户进行自定义构建,需要把几乎整个组件库的源码都发布到npm包中

于是NutUI 2.0时,我们决定对按需加载功能进行重新设计。我们参考了业界优秀组件库的实现方案。在组件库构建时,除了构建完整的组件库包以外,还把每个组件单独构建了一个包,这样就可以独立引用每一个组件了。

// 加载构建后的组件JS
import Button from '@nutui/nutui/dist/packages/button/button.js';

//加载构建后的组件CSS
import '@nutui/nutui/dist/packages/button/button.css';

webpack的中如何实现构建多个bundle呢?主要是entry选项的配置,entry的值通常是一个字符串,其实它还可以是一个对象。我们新增一个webpack配置文件,基于组件库的组件配置文件生成一个对象,key是组件名,value是组件的入口js文件,将此对象作为该配置文件的entry选项值即可,其他配置与完整版的组件库webpack配置文件一致(输出目录可根据需要自行配置)。构建时执行这两个配置文件,即可构建出一个完整版的组件库包和每个组件独立的包。

const cptConf = require('../src/config.json');
const entry = {};

cptConf.packages.map((item)=>{
    entry[cptName] = `./src/packages/${item.name.toLowerCase()}/index.js`;
});

module.exports = {
    entry
};

如果用户项目中使用了多个组件,这种分别引用每个组件及其样式文件的写法还是略显繁琐,URL拼写也容易出错。代码洁癖患者的感受也需要顾及啊~

抛开技术实现和兼容性不谈,比较理想的、面向未来的写法应该是ES6 modules风格的写法,因为一众的模块化方案中,这是亲儿子。

import { Button,Switch } from '@nutui/nutui';

我们考虑支持这种写法,并提供一个工具在用户应用编译阶段将代码自动转换为组件单独引用的写法:

import Button from '@nutui/nutui/dist/packages/button/button.js';
import Switch from '@nutui/nutui/dist/packages/switch/switch.js';  

import '@nutui/nutui/dist/packages/button/button.css'; 
import '@nutui/nutui/dist/packages/switch/switch.css';

承担这种转码工作最适合的人选非Babel莫属了。大多数用户的项目脚手架都会安装Babel,用来进行ES6+语法向低版本语法的转换,我们只需要提供一个Babel的插件,使其在转换的过程中捎带着把我们组件按需加载的语法也给转换了即可。我们先来了解一下Babel的工作原理。

Babel的转码工作大致分为三个阶段:

  • 解析(parse):将代码字符串解析成AST(抽象语法树)
  • 转换(transform):对抽象语法树进行转换操作
  • 生成(generate): 将变换后的抽象语法树再生成代码字符串

抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

我们的Babel插件@nutui/babel-plugin-separate-import[2]的大致工作原理是在代码被解析成AST抽象语法树之后,遍历语法树找到形如import { Button,Switch } from '@nutui/nutui';的语法相关节点,转换成单独引用组件的语法,最后再生成代码字符串。

这只是基本原理,实际情况比较复杂,因为还需要考虑样式文件类型、主题换肤、国际化等因素,这里就不展开了。下面说下这个插件的基本使用。

通过npm/yarn安装@nutui/babel-plugin-separate-import 在项目的Babel配置文件(如.babelrc)中配置插件

{
  "plugins": [
    ["@nutui/babel-plugin-separate-import", {
      "style": "css"
    }]
  ]
}

然后就可以使用ES6 modules风格的语法引用所需的组件了

import Vue from 'vue';
import { Button,Switch } from '@nutui/nutui';

Vue.use(Button);
Vue.use(Switch);

既然说到Babel与AST,我们不妨进行一些延展(这部分内容属赠送性质)。Babel自带的AST操作相关模块可以在需要AST的场景独立使用,无需再安装其他AST工具。

  • @babel/parser模块用来把代码解析成AST抽象语法树
  • @babel/traverse模块用来对AST节点进行递归遍历
  • @babel/types模块用来对具体的AST节点进行进行增、删、改、查
  • @babel/generator模块用来将修改后的AST生成新的代码字符串

比如在NutUI 2.x项目中,我们为新增组件提供了一个命令npm run add,可根据录入信息自动生成新组件的模板,并更新配置文件。其中一个需要更新的组件库配置文件是src目录下的nutui.js文件,这个文件非常重要,是整个项目的entry文件。添加新组件的时候,nutui.js文件有两处需要修改。

增加两个import,用于加载新组件的入口js文件和scss文件。如:

import Uploader from "./packages/uploader/index.js";
import "./packages/uploader/uploader.scss";

向packages对象添加新组件信息。如:

const packages = {
  Cell,
  Dialog,
  Icon,
  Toast,
  ...
  Uploader
}

第一处修改并不困难,可以通过Node.js将nutui.js文件内容读取,然后把两个新的import加在内容头部,再把新文本内容写入文件。然鹅,第二处修改就有些困难了,如何向文件中的一个js对象中追加内容呢?一个靠谱的办法就是AST,即把读取的文件内容解析成AST,然后遍历AST找到packages对象,向其中追加新组件信息,最后生成新的代码字符串,写入nutui.js文件。而这些操作可以通过Babel自带的相关模块来完成[3]。

const t = require('@babel/types');
const {parse} = require('@babel/parser');
const {default: traverse} = require('@babel/traverse');
const {default: generate} = require('@babel/generator');

好了,这篇文章先谈到这里。留一个思考题吧,我们知道,webpack 2+ 拥有了Tree-shaking(摇树)功能,能“摇”掉未用到的代码,那么如果我们不借助Babel插件处理,而直接使用下面这种ES6 modules语法来引入组件,未用到的组件会被“摇”掉吗?答案当然是否定的,否则何必去开发个Babel插件,所以我真正要问的是为什么不能呢?

import { Button,Switch } from '@nutui/nutui';

链接