解读ElementUI源码及其构建流程

2,091 阅读6分钟

引言

平时在业务中,我们一直都会选择一些开源的通用组件库,通过简单的引用方式嵌入到业务系统中,以提高开发效率,统一整体样式。我们的关注点往往在它怎么用,一个组件暴露出了哪些属性和方法,在什么场景下使用某种属性和方法。

但其实了解一个通用组件的构建流程和组成结构,有助于我们在业务中更灵活的使用组件,也为公司开发自己的通用组件库提供理论基础。

今天我想讲一讲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 bootstrapnpm 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的引用路径
      }
    ]
  ]
}