引言
平时在业务中,我们一直都会选择一些开源的通用组件库,通过简单的引用方式嵌入到业务系统中,以提高开发效率,统一整体样式。我们的关注点往往在它怎么用,一个组件暴露出了哪些属性和方法,在什么场景下使用某种属性和方法。
但其实了解一个通用组件的构建流程和组成结构,有助于我们在业务中更灵活的使用组件,也为公司开发自己的通用组件库提供理论基础。
今天我想讲一讲ElememtUI的源码,分析一下它的构建流程和组成结构。最后总结一下一个完备的组件库需要做的一些工作。
目录结构解析
目录结构对于大型项目是非常重要的,合理清晰的目录结构对于后期的开发和扩展都是非常有意义的。ElementUI的目录构成为:
- .github
存放Element UI贡献指南、issue和PR模板
- build
存放打包相关的配置文件
- examples
存放组件相关的示例demo
- packages
组件源码
- src
存放入口文件和一些工具辅助函数
- test
存放单元测试相关文件
- types
类型声明文件
剩下的都是一些文件,有两个比较重要的,涉及到构建的文件:
components.json:标明了组件的文件路径,方便webpack打包时获取组件的文件路径。
Makefile:Makefile 是一个适用于 C/C++ 的工具,在拥有 make 环境的目录下, 如果存在一个 Makefile 文件。 那么输入 make 命令将会执行 Makefile 文件中的某个目标命令。
package.json: 通常我们去配置一个大型项目都是从package.json文件开始的,这里面包含了项目的版本、入口、脚本、依赖等关键信息。
packege.json
有几个关键字段是我们构建组件库项目必须要设置的,下面将对它们的配置项一一进行分析和解释。
(1) scripts:scripts是package.json中最重要的一个对象,它包含开发、测试、生产构建,打包、部署,测试用例等相关脚本。脚本命令可以使用脚手架提供的,也可以根据项目的需求自定义内容。Element中使用的脚本命令将在构建流程中梳理。
(2) main:目标入口文件,外部库引入时的实际引用地址。比如Element ui,指令import Element from 'element-ui'引入的就是main中配置的文件地址。lib/element-ui.common.js是commonjs规范,而lib/index.js是umd规范。lib是运行打包命令后会生成的文件。
(3) version:项目的版本,发布项目时通过控制版本更新改动,相同的版本不能进行二次发布。
(4) files:指定发布包时需要包含的文件/目录,有些文件或目录是没有必要上传的,比如说示例文件目录examples。
(5) style:声明样式入口文件,因为Element ui的js和css文件是分开打包的。
packages
存放着组件库的源码和组件样式文件。
- alert组件
main.vue 是组件源码,index.js是入口文件
入口文件为组件提供install方法添加全局的组件。让Vue可以通过Vue.use(Alert)来使用。
- /theme-chalk
这里存放所有组件相关的样式,使用BEM方式命名,index.scss负责引用所有的组件样式用于全局引用,单个的组件scss用于按需引入是到处对应的组件样式。
其余的样式文件都是一些工具函数、公用样式和字体库等等。
src
上面的packages文件夹是分开去处理每个组件,而src的作用就是把所有的组件做一个统一处理,同时包含自定义指令、项目整体入口、组件国际化、组件mixins、动画的封装和公共方法。
我们主要分析一下入口文件index.js
/* Automatically generated by './build/bin/build-entry.js' */
提示index.js是由build-entry脚本自动生成的。
examples
可以将其看做是一个独立的Vues项目,主要用于官方文档的展示。Element ui支持四种语言,因此docs下有四个文件夹,每个文件夹对应一种语言相关的组件示例。
docs里边全部是md文档,而每一个md文档,分别对应着官网组件的展示页面。md文档在打包时需要使用loader转换成vue文件,然后嵌入到展示的位置。
构建流程
脚本指令
scripts属性中的脚本。开发、测试、生产构建,打包、部署,测试用例等相关脚本。Element UI的亮点就在于它的自动化生成脚本。
- "bootstrap": "yarn || npm i"
这个bootstrap是引导程序的意思,安装依赖时优先选用yarn。
- build:file 该指令主要用来自动化生成一些文件。
build/bin/iconInit.js
解析icon.scss,把所有的icon的名字放在icon.json里面 最后挂在Vue原型上的$icon上。
build/bin/build-entry.js
根据components.json自动生成src/index.js,也就是项目的入口文件。
build/bin/i18n.js
根据 examples/i18n/page.json 和模版,生成不同语言的首页,也就是官网首页展示国际化的处理。
ElementUI官网的国际化依据的模版是examples/pages/template,根据不同的语言,分别生成不同的文件。这里面都是.tpl文件,每个文件对应一个模版,而且每个tpl文件又都是符合SFC规范的Vue文件。里面都有数字标示了需要国际化处理的地方。首页所有国际化相关的字段对应关系存储在examples/i18n/page.json中。
i18n.js就是负责官网首页的国际化,遍历examples/i18n/page.json,根据不同的数据结构把tpl文件的标志位,通过正则匹配出来,并替换成自己预先设定好的字段。
build/bin/version.js
根据package.json中的version,生成examples/versions.json,对应就是完整的版本列表
- build:theme 处理样式相关
build/bin/gen-cssfile
这一步是根据components.json,生成package/theme-chalk/index.scss文件,把所有组件的样式都导入到index.scss。
gulp build --gulpfile packages/theme-chalk/gulpfile.js
将packages/theme-chalk下的所有scss文件编译为css,当需要全局引入时,就去引入index.css文件;当需要按需引入时,引入对应的组件css文件即可。
根据上述需求,Element认为果采用gulp基于工作流去打包样式文件会更加方便。gulp相关的处理就在packages/theme-chalk/gulpfile.js中。
cp-cli packages/theme-chalk/lib lib/theme-chalk
cp-cli是一个跨平台的copy工具,和CopyWebpackPlugin类似。将 packages/theme-chalk下的lib文件复制到lib打包文件下的theme-chalk文件夹里。
构建指令Makefile
平时我们都习惯将项目常用的脚本放在package.json中的scripts中。但ElementUI还使用了Makefile文件。执行 make 命令, 在该目录下找到 Makefile 文件。 找到 Makefile 文件中对应命令行参数目标。
- make new component-name [中文]
执行build/bin/new.js文件,该文件是生成一个组件相关的所有文件。
开发流程
比如现在我们开始开发一个名为button-test的测试按钮组件,我们首先要make new创建一个新组件。
它会执行:
1、新建的组件添加到components.json
2、在packages/theme-chalk/src下新建对应到组件scss文件,并添加到packages/theme-chalk/src/index.scss中
3、添加到 element-ui.d.ts,也就是对应的类型声明文件
4、创建package(我们上面有提到组件相关的源码都在package目录下存放)
5、添加到路由相关的文件nav.config.json(也就是官网组件左侧的菜单)
之后yarn run dev在开发环境上启动项目
npm run bootstrap和npm run build:file之前提到过,先安装依赖,然后自动化生成一些文件。之后运行webpack-dev-server,使用build/webpack.demo.js里面的配置启动运行环境。
我们可以注意一下,webpack.demo.js里面是怎么处理md文件的,element自己开发了一个md-loader。
打包流程
npm run clean
清理之前的打包文件。
npm run build:file
npm run lint
代码eslint检查,涉及到一系列的文件。
webpack --config build/webpack.conf.js && webpack --config build/webpack.common.js && webpack --config build/webpack.component.js
webpack打包时使用的配置参数,这三个文件配置基本类似,区别在于entry和output。
build/webpack.conf.js 生成umd格式的js文件(index.js)
build/webpack.common.js 生成commonjs格式的js文件(element-ui.common.js),require时默认加载的是这个文件。
build/webpack.component.js 以components.json为入口,多入口打包,将每一个组件打包生成一个文件,用于按需加载。
npm run build:utils
负责单独转译一份所有的工具方法,把src目录下的除了index.js入口文件外的其他文件通过babel转译,然后移动到lib文件夹下。
为什么要单独拎出来转译,Element考虑到开发人员如果对其源码足够熟悉,那么可以不必下载别的包,直接用这些简便的工具方法。
形如
const date = require('element-ui/lib/utils/date')
date.format(new Date, 'HH:mm:ss')
npm run build:umd
生成umd模块的语言包
npm run build:theme
生成样式文件
发布流程 pub
1、git 发布
2、npm 发布
3、官网发布
sh build/git-release.sh
代码冲突检测
运行 git-release.sh 进行git冲突的检测,这里主要是检测dev分支是否冲突,因为Element是在dev分支进行开发的。
发布 npm && 官网更新
dev分支代码检测没有冲突,接下来就会执行release.sh脚本,合并dev分支到master、更新版本号、推送代码到远程仓库并发布到npm(npm publish)。
官网更新大致就是:将静态资源生成到examples/element-ui目录下,然后放到gh-pages分支,这样就能通过github pages的方式访问。
一些补充
md-loader
loader用于对模块的源代码进行转换。md-loader将markdown文件转换成vue文件。
md-loader源码在build/md-loader中,五个文件的职责分别为:
-
index.js 入口文件
-
config.js markdown-it的配置文件
-
containers.js render添加自定义输出配置
-
fence.js 修改fence渲染策略
-
util.js 一些处理解析md数据的函数
可以先看一个md的demo组成部分,代码部分包裹在
:::demo descprition
code
:::
中,这一套code有两个作用,一个是作为示例展示,一个是作为示例代码。全部放进demo-block组件里面。
<div class="source">
<slot name="source"></slot>
</div>
<div class="meta" ref="meta">
<div class="description" v-if="$slots.default">
<slot></slot>
</div>
<div class="highlight">
<slot name="highlight"></slot>
</div>
</div>
config.js中,用markdown-it-anchor给页眉添加锚点,为标题生成超链接,可以快速跳转。
之后在index.js中先使用config.js暴露出的方法,生成新的md文件。
const md = require('./config');
module.exports = function(source) {
const content = md.render(source)
// ....
}
之后,为了将md文件转换成形如的vue文件
<template>
</template>
<script>
export default {
}
</script>
考虑到一个md里面有很多类似的demo,最终输出的肯定是一个vue对象,那么这些demo就必须包装成一个个组件。
使用渲染函数为每一个demo创建组件。
最终source输出的script形如:
<script>
export default {
name: 'component-doc',
components: {
component1:(function() {*render1* })(),
component2:(function() {*render2* })(),
component3:(function() {*render3* })(),
}
}
</script>
将demo渲染为函数形组件的方法在util.js的genInlineComponentText函数中,抽离demo中的template和script,通过插件编译成render Functioon。
先使用vue-template-compiler将template转为render函数(作为配置项),再利用@vue/component-compiler-utils插件编译这个单文件组件。
每个demo编译出来compiled.code的结果如下:
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h //可以把下面return的_c当做是$createElement
return _c(
"div", // 一个 HTML 标签名
[//.....] // 子节点数组 ,实际上也是由由$createElement构建而成
)
}
var staticRenderFns = []
render._withStripped = true
走到这步,就还需要考虑一个问题,就是所有的demo的组件,如何区分,如何给它们依次命名。就需要给每个demo打上标记,在containers.js文件中,使用markdown-it-container插件,为每个demo包裹demo-block标签,组成一个DemoBlock组件。
在这个demo-block里面就是三样东西:
${description} //(1) description即为demo的开头说明,请返回demo.md查看
<!--element-demo: ${content}:element-demo--> //(2) 展示组件 前面和后面的便是标记,在index.js会根据这个标记找到这段内容
${content} // (3) 展示的代码 这个东西会在fence.js文件里面做渲染覆盖,修改它的标签内容
最后就是在index.js中拼装所有demo组件,形如
let content = `
Description1 //description
Component1 // 展示组件
componentCode1//展示代码
Description2
Component2
componentCode2
`
output.push(content.slice(0))
script = `<script>
export default {
name: 'component-doc',
components: {
component1:(function() {*render1* })(),
component2:(function() {*render2* })(),
component3:(function() {*render3* })(),
}
}
</script>`;
return `
<template>
<section class="content element-doc">
${output.join('')}
</section>
</template>
${script}
`
按需加载
使用多入口打包和代码转换插件来实现对js逻辑文件和css样式文件的按需加载。
多入口打包在前面的打包流程已经说完了,之后就是在业务中使用代码转换工具进行简便的组件引入。要引入组件需要同时引入它的逻辑和样式代码。代码转换的工具是为了简化引用过程,该工具会在编译时完成自动转换。假设打包后某个组件xxx和其样式所在路径为componets/lib/xxx,代码转换工具的转换前后代码如下:
// 转换前
import { xxx } from 'componets
// 转换后
import xxx form 'componets/lib/xxx'
import 'componets/lib/xxx/xxx.css'
上述转化过程使用Babel自带的AST(抽象语法树)操作,首先@babel/parser模块将代码解析为抽象语法树,利用@babel/traverse模块遍历语法树找到转换前的相关语法节点,然后使用@babel/types模块将其转化为转换后单独引用组件的语法, 最后利用@babel/generator模块将修改的抽象语法树生成新的代码字符串,这样就完成了在进行ES语法的向前兼容的同时对组件按需加载的语法转化。
Element官方建议使用babel-plugin-component插件,在.babelrc 配置
{
"presets": [["es2015", { "modules": false }]],
"plugins": [
[
"component",
{
"libraryName": "element-ui", //js的引用路径
"styleLibraryName": "theme-chalk" //css的引用路径
}
]
]
}