博客
博客
文章目录
  1. 背景
  2. Element 的目录结构
  3. 有哪些构建命令
  4. 开发模式与构建入口文件
  5. 新建组件
  6. 打包流程
    1. 清理文件
    2. 入口文件生成
    3. 代码检查
    4. 文件打包
    5. 转译工具方法
    6. 生成样式文件
  • 发布流程
    1. git 冲突检测
    2. git 发布;npm 发布
    3. 官网更新
  • 总结
  • ElementUI 的构建流程

    背景

    最近一直在着手做一个与业务强相关的组件库,一直在思考要从哪里下手,怎么来设计这个组件库,因为业务上一直在使用 ElementUI(以下简称 Element),于是想参考了一下 Element 组件库的设计,看看 Element 构建方式,并且总结成了这篇文章。

    logo

    Element 的目录结构

    废话不多说,先看看目录结构,从目录结构入手,一步步进行分解。

    ├─build // 构建相关的脚本和配置 
    ├─examples // 用于展示 Element 组件的 demo
    ├─lib // 构建后生成的文件,发布到 npm 包
    ├─packages // 组件代码
    ├─src // 引入组件的入口文件
    ├─test // 测试代码
    ├─Makefile // 构建文件
    ├─components.json// 组件列表
    └─package.json

    有哪些构建命令

    刚打开的时候看到了一个 Makefile 文件,如果学过 c/c++ 的同学对这个东西应该不陌生,当时看到后台同学发布版本时,写下了一句 make love,把我和我的小伙伴们都惊呆了。说正紧的,makefile 可以说是比较早出现在 UNIX 系统中的工程化工具,通过一个简单的 make XXX 来执行一系列的编译和链接操作。不懂 makefile 文件的可以看这篇文章了解下:前端入门 ->makefile

    当我们打开 Element 的 Makefile 时,发现里面的操作都是 npm script 的命令,我不知道为什么还要引入 Makefile,直接使用 npm run xxx 就好了呀。

    default: help

    install:
    npm install

    new:
    node build/bin/new.js $(filter-out $@,$(MAKECMDGOALS))

    dev:
    npm run dev

    deploy:
    @npm run deploy

    dist: install
    npm run dist

    pub:
    npm run pub

    help:
    @echo "make 命令使用说明 & quot;
    @echo "make install --- 安装依赖 & quot;
    @echo "make new <component-name> [中文名] --- 创建新组件 package. 例如 'make new button 按钮 & apos;"
    @echo "make dev --- 开发模式 & quot;
    @echo "make dist --- 编译项目,生成目标文件 & quot;
    @echo "make deploy --- 部署 demo"
    @echo "make pub --- 发布到 npm 上 & quot;
    @echo "make new-lang <lang> --- 为网站添加新语言。例如 'make new-lang fr'"

    开发模式与构建入口文件

    这里我们只挑选几个重要的看看。首先看到 make install,使用的是 npm 进行依赖安装,但是 Element 实际上是使用 yarn 进行依赖管理,所以如果你要在本地进行 Element 开发的话,最好使用 yarn 进行依赖安装。在官方的 贡献指南 也有提到。

    贡献指南

    同时在 package.json 文件中有个 bootstrap 命令就是使用 yarn 来安装依赖。

    "bootstrap": "yarn || npm i",

    安装完依赖之后,就可以进行开发了,运行 npm run dev,可以通过 webpack-dev-sever 在本地运行 Element 官网的 demo。

    "dev": "
    npm run bootstrap && // 依赖安装
    npm run build:file && // 目标文件生成
    cross-env NODE_ENV=development webpack-dev-server --config build/webpack.demo.js &
    node build/bin/template.js
    "

    "build:file": "
    node build/bin/iconInit.js & // 解析 icon.scss,将所有小图标的 name 存入 examples/icon.json
    node build/bin/build-entry.js & // 根据 components.json,生成入口文件
    node build/bin/i18n.js & // 根据 examples/i18n/page.json 和模板,生成不同语言的 demo
    node build/bin/version.js// 生成 examples/versions.json,键值对,各个大版本号对应的最新版本
    "

    在通过 webpack-dev-server 运行 demo 时,有个前置条件,就是通过 npm run build:file 生成目标文件。这里主要看下 node build/bin/build-entry.js,这个脚本用于生成 Element 的入口 js。先是读取根目录的 components.json,这个 json 文件维护着 Element 的所有的组件名,键为组件名,值为组件源码的入口文件;然后遍历键值,将所有组件进行 import,对外暴露 install 方法,把所有 import 的组件通过 Vue.component (name, component) 方式注册为全局组件,并且把一些弹窗类的组件挂载到 Vue 的原型链上。具体代码如下(ps:对代码进行一些精简,具体逻辑不变):

    var Components = require('../../components.json');
    var fs = require('fs');
    var render = require('json-templater/string');
    var uppercamelcase = require('uppercamelcase');
    var path = require('path');
    var endOfLine = require('os').EOL; // 换行符

    var includeComponentTemplate = [];
    var installTemplate = [];
    var listTemplate = [];

    Object.keys (Components).forEach (name => {
    var componentName = uppercamelcase (name); // 将组件名转为驼峰
    var componetPath = Components [name]
    includeComponentTemplate.push (`import ${componentName} from '.${componetPath}';`);

    // 这几个特殊组件不能直接注册成全局组件,需要挂载到 Vue 的原型链上
    if (['Loading', 'MessageBox', 'Notification', 'Message'].indexOf (componentName) === -1) {
    installTemplate.push (`${componentName}`);
    }

    if (componentName !== 'Loading') listTemplate.push (`${componentName}`);
    });

    var template = `/* Automatically generated by './build/bin/build-entry.js' */

    ${includeComponentTemplate.join (endOfLine)}
    import locale from 'element-ui/src/locale';
    import CollapseTransition from 'element-ui/src/transitions/collapse-transition';

    const components = [
    ${installTemplate.join (',' + endOfLine)},
    CollapseTransition
    ];

    const install = function (Vue, opts = {}) {
    locale.use (opts.locale);
    locale.i18n (opts.i18n);

    components.forEach (component => {
    Vue.component (component.name, component);
    });

    Vue.use (Loading.directive);

    Vue.prototype.$ELEMENT = {
    size: opts.size || '',
    zIndex: opts.zIndex || 2000
    };

    Vue.prototype.$loading = Loading.service;
    Vue.prototype.$msgbox = MessageBox;
    Vue.prototype.$alert = MessageBox.alert;
    Vue.prototype.$confirm = MessageBox.confirm;
    Vue.prototype.$prompt = MessageBox.prompt;
    Vue.prototype.$notify = Notification;
    Vue.prototype.$message = Message;

    };

    /* istanbul ignore if */
    if (typeof window !== 'undefined' && window.Vue) {
    install (window.Vue);
    }

    module.exports = {
    version: '${process.env.VERSION || require('../../package.json').version}',
    locale: locale.use,
    i18n: locale.i18n,
    install,
    CollapseTransition,
    Loading,
    ${listTemplate.join (',' + endOfLine)}
    };

    module.exports.default = module.exports;
    `;

    // 写文件
    fs.writeFileSync (OUTPUT_PATH, template);
    console.log ('[build entry] DONE:', OUTPUT_PATH);

    最后生成的代码如下:

    /* Automatically generated by './build/bin/build-entry.js' */
    import Button from '../packages/button/index.js';
    import Table from '../packages/table/index.js';
    import Form from '../packages/form/index.js';
    import Row from '../packages/row/index.js';
    import Col from '../packages/col/index.js';
    //some others Component
    import locale from 'element-ui/src/locale';
    import CollapseTransition from 'element-ui/src/transitions/collapse-transition';

    const components = [
    Button,
    Table,
    Form,
    Row,
    Menu,
    Col,
    //some others Component
    ];

    const install = function(Vue, opts = {}) {
    locale.use (opts.locale);
    locale.i18n (opts.i18n);

    components.forEach (component => {
    Vue.component (component.name, component);
    });

    Vue.use (Loading.directive);

    Vue.prototype.$ELEMENT = {
    size: opts.size || '',
    zIndex: opts.zIndex || 2000
    };

    Vue.prototype.$loading = Loading.service;
    Vue.prototype.$msgbox = MessageBox;
    Vue.prototype.$alert = MessageBox.alert;
    Vue.prototype.$confirm = MessageBox.confirm;
    Vue.prototype.$prompt = MessageBox.prompt;
    Vue.prototype.$notify = Notification;
    Vue.prototype.$message = Message;

    };

    /* istanbul ignore if */
    if (typeof window !== 'undefined' && window.Vue) {
    install (window.Vue);
    }

    module.exports = {
    version: '2.4.6',
    locale: locale.use,
    i18n: locale.i18n,
    install,
    Button,
    Table,
    Form,
    Row,
    Menu,
    Col,
    //some others Component
    };

    module.exports.default = module.exports;

    最后有个写法需要注意:module.exports.default = module.exports;,这里是为了兼容 ESmodule,因为 es6 的模块 export default xxx,在 webpack 中最后会变成类似于 exports.default = xxx 的形式,而 import ElementUI from 'element-ui'; 会变成 ElementUI = require ('element-ui').default 的形式,为了让 ESmodule 识别这种 commonjs 的写法,就需要加上 default。

    exports 对外暴露的 install 方法就是把 Element 组件注册会全局组件的方法。当我们使用 Vue.use 时,就会调用对外暴露的 install 方法。如果我们直接通过 script 的方式引入 vue 和 Element,检测到 Vue 为全局变量时,也会调用 install 方法。

    // 使用方式 1
    <!-- import Vue before Element -->
    <script src="https://unpkg.com/vue/dist/vue.js"></script>
    <!-- import JavaScript -->
    <script src="https://unpkg.com/element-ui/lib/index.js"></script>

    // 使用方式 2
    import Vue from 'vue';
    import ElementUI from 'element-ui';
    import 'element-ui/lib/theme-chalk/index.css';

    Vue.use (ElementUI); // 此时会调用 ElementUI.install ()

    在 module.exports 对象中,除了暴露 install 方法外,还把所有组件进行了对外的暴露,方便引入单个组件。

    import { Button } from 'element-ui';
    Vue.use (Button);

    但是如果你有进行按需加载,使用 Element 官方的 babel-plugin-component 插件,上面代码会转换成如下形式:

    var _button = require('element-ui/lib/button')
    require('element-ui/lib/theme-chalk/button.css')

    Vue.use (_button)

    那么前面 module.exports 对外暴露的单组件好像也没什么用。
    不过这里使用 npm run build:file 生成文件的方式是可取的,因为在实际项目中,我们每新增一个组件,只需要修改 components.json 文件,然后使用 npm run build:file 重新生成代码就可以了,不需要手动去修改多个文件。

    在生成了入口文件的 index.js 之后就会运行 webpack-dev-server。

    webpack-dev-server --config build/webpack.demo.js

    接下来看下 webpack.demo.js 的入口文件:

    //webpack.demo.js
    const webpackConfig = {
    entry: './examples/entry.js',
    output: {
    path: path.resolve (process.cwd (), './examples/element-ui/'),
    publicPath: process.env.CI_ENV || '',
    filename: '[name].[hash:7].js',
    chunkFilename: isProd ? '[name].[hash:7].js' : '[name].js'
    },
    resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: {
    main: path.resolve (__dirname, '../src'),
    packages: path.resolve (__dirname, '../packages'),
    examples: path.resolve (__dirname, '../examples'),
    'element-ui': path.resolve (__dirname, '../')
    },
    modules: ['node_modules']
    }
    //... some other config
    }

    //examples/entry.js
    import Vue from 'vue';
    import Element from 'main/index.js';

    Vue.use (Element);

    新建组件

    entry.js 就是直接引入的之前 build:file 中生成的 index.js 的 Element 的入口文件。因为这篇文章主要讲构建流程,所以不会仔细看 demo 的源码。下面看看 Element 如何新建一个组件,在 Makefile 可以看到使用 make new xxx 新建一个组件。。

    new:
    node build/bin/new.js $(filter-out $@,$(MAKECMDGOALS))

    这后面的 $(filter-out $@,$(MAKECMDGOALS)) 就是把命令行输入的参数直接传输给 node build/bin/new.js,具体细节这里不展开,还是直接看看 build/bin/new.js 的具体细节。

    // 参数校验 
    if (!process.argv [2]) {
    console.error ('[组件名] 必填 - Please enter new component name');
    process.exit (1);
    }

    const path = require('path');
    const fileSave = require('file-save');
    const uppercamelcase = require('uppercamelcase');
    // 获取命令行的参数
    //e.g. node new.js input 输入框
    //process.argv 表示命令行的参数数组
    // 0 是 node,1 是 new.js,2 和 3 就是后面两个参数
    const componentname = process.argv [2]; // 组件名
    const chineseName = process.argv [3] || componentname;
    const ComponentName = uppercamelcase (componentname); // 转成驼峰表示
    // 组件所在的目录文件
    const PackagePath = path.resolve (__dirname, '../../packages', componentname);

    // 检查 components.json 中是否已经存在同名组件
    const componentsFile = require('../../components.json');
    if (componentsFile [componentname]) {
    console.error (`${componentname} 已存在.`);
    process.exit (1);
    }
    //componentsFile 中写入新的组件键值对
    componentsFile [componentname] = `./packages/${componentname}/index.js`;
    fileSave (path.join (__dirname, '../../components.json'))
    .write (JSON.stringify (componentsFile, null, ' '), 'utf8')
    .end ('\n');

    const Files = [
    {
    filename: 'index.js',
    content: `index.js 相关模板`
    },
    {
    filename: 'src/main.vue',
    content: `组件相关的模板`
    },
    // 下面三个文件是的对应的中英文 api 文档
    {
    filename: path.join ('../../examples/docs/zh-CN', `${componentname}.md`),
    content: `## ${ComponentName} ${chineseName}`
    },
    {
    filename: path.join ('../../examples/docs/en-US', `${componentname}.md`),
    content: `## ${ComponentName}`
    },
    {
    filename: path.join ('../../examples/docs/es', `${componentname}.md`),
    content: `## ${ComponentName}`
    },

    {
    filename: path.join ('../../test/unit/specs', `${componentname}.spec.js`),
    content: `组件相关测试用例的模板`
    },
    {
    filename: path.join ('../../packages/theme-chalk/src', `${componentname}.scss`),
    content: `组件的样式文件`
    },
    {
    filename: path.join ('../../types', `${componentname}.d.ts`),
    content: `组件的 types 文件,用于语法提示`
    }
    ];

    // 生成组件必要的文件
    Files.forEach (file => {
    fileSave (path.join (PackagePath, file.filename))
    .write (file.content, 'utf8')
    .end ('\n');
    });

    这个脚本最终会在 components.json 写入组件相关的键值对,同时在 packages 目录创建对应的组件文件,并在 packages/theme-chalk/src 目录下创建一个样式文件,Element 的样式是使用 sass 进行预编译的,所以生成是 .scss 文件。大致看下 packages 目录下生成的文件的模板:

    {
    filename: 'index.js',
    content: `
    import ${ComponentName} from './src/main';

    /* istanbul ignore next */
    ${ComponentName}.install = function (Vue) {
    Vue.component (${ComponentName}.name, ${ComponentName});
    };

    export default ${ComponentName};
    `
    },
    {
    filename: 'src/main.vue',
    content: `
    <template>
    <div class="el-${componentname}"></div>
    </template>

    <script>
    export default {
    name: 'El${ComponentName}'
    };
    </script>
    `
    }

    每个组件都会对外单独暴露一个 install 方法,因为 Element 支持按需加载。同时,每个组件名都会加上 El 前缀。,所以我们使用 Element 组件时,经常是这样的 el-xxx,这符合 W3C 的自定义 HTML 标签的 规范(小写,并且包含一个短杠)。

    打包流程

    由于现代前端的复杂环境,代码写好之后并不能直接使用,被拆成模块的代码,需要通过打包工具进行打包成一个单独的 js 文件。并且由于各种浏览器的兼容性问题,还需要把 ES6 语法转译为 ES5,sass、less 等 css 预编译语言需要经过编译生成浏览器真正能够运行的 css 文件。所以,当我们通过 npm run new component 新建一个组件,并通过 npm run dev 在本地调试好代码后,需要把进行打包操作,才能真正发布到 npm 上。

    这里运行 npm run dist 进行 Element 的打包操作,具体命令如下。

    "dist": "
    npm run clean &&
    npm run build:file &&
    npm run lint &&
    webpack --config build/webpack.conf.js &&
    webpack --config build/webpack.common.js &&
    webpack --config build/webpack.component.js &&
    npm run build:utils &&
    npm run build:umd &&
    npm run build:theme
    "

    流程图

    下面一步步拆解上述流程。

    清理文件

    "clean": "rimraf lib && rimraf packages/*/lib && rimraf test/**/coverage"

    使用 npm run clean 会删除之前打包生成的文件,这里直接使用了一个 node 包:rimraf,类似于 linux 下的 rm -rf

    入口文件生成

    npm run build:file 在前面已经介绍过了,通过 components.json 生成入口文件。

    代码检查

    "lint": "eslint src/**/* test/**/* packages/**/* build/**/* --quiet"

    使用 ESLint 对多个目录下的文件进行 lint 操作。

    文件打包

    webpack --config build/webpack.conf.js && 
    webpack --config build/webpack.common.js &&
    webpack --config build/webpack.component.js &&

    这里直接使用原生 webpack 进行打包操作,webpack 版本为:3.7.1。在 Element@2.4.0 之前,使用的打包工具为 cooking,但是这个工具是基于 webpack2,很久没有更新(ps. 项目中能使用 webpack 最好使用 webpack,多阅读官网的文档,虽然文档很烂,其他第三方对 webpack 进行包装的构建工具,很容易突然就不更新了,到时候要迁移会很麻烦)。

    这三个配置文件的配置基本类似,区别在 entry 和 output。

    //webpack.conf.js
    module.exports = {
    entry: {
    app: ['./src/index.js']
    },
    output: {
    path: path.resolve (process.cwd (), './lib'),
    publicPath: '/dist/',
    filename: 'index.js',
    chunkFilename: '[id].js',
    libraryTarget: 'umd',
    library: 'ELEMENT',
    umdNamedDefine: true
    }
    }

    //webpack.common.js
    module.exports = {
    entry: {
    app: ['./src/index.js']
    },
    output: {
    path: path.resolve (process.cwd (), './lib'),
    publicPath: '/dist/',
    filename: 'element-ui.common.js',
    chunkFilename: '[id].js',
    libraryTarget: 'commonjs2'
    }
    }
    //webpack.component.js
    const Components = require('../components.json');
    module.exports = {
    entry: Components,
    output: {
    path: path.resolve (process.cwd (), './lib'),
    publicPath: '/dist/',
    filename: '[name].js',
    chunkFilename: '[id].js',
    libraryTarget: 'commonjs2'
    }
    }

    webpack.conf.js 与 webpack.common.js 打包的入口文件都是 src/index.js,该文件通过 npm run build:file 生成。不同之处在于输出文件,两个配置生成的 js 都在 lib 目录,重点在于 libraryTarget,一个是 umd,一个是 commonjs2。还一个 webpack.component.js 的入口文件为 components.json 中的所有组件,表示 packages 目录下的所有组件都会在 lib 文件夹下生成也单独的 js 文件,这些组件单独的 js 文件就是用来做按需加载的,如果需要哪个组件,就会单独 import 这个组件 js。

    当我们直接在代码中引入整个 Element 的时候,加载的是 webpack.common.js 打包生成的 element-ui.common.js 文件。因为我们引入 npm 包的时候,会根据 package.json 中的 main 字段来查找入口文件。

    //package.json
    "main": "lib/element-ui.common.js"

    转译工具方法

    "build:utils": "cross-env BABEL_ENV=utils babel src --out-dir lib --ignore src/index.js",

    这一部分是吧 src 目录下的除了 index.js 入口文件外的其他文件通过 babel 转译,然后移动到 lib 文件夹下。

    └─src
    ├─directives
    ├─locale
    ├─mixins
    ├─transitions
    ├─popup
    └─index.js

    在 src 目录下,除了 index.js 外,还有一些其他文件夹,这些是 Element 组件中经常使用的工具方法。如果你对 Element 的源码足够熟悉,可以直接把 Element 中一些工具方法拿来使用,不再需要安装其他的包。

    const date = require('element-ui/lib/utils/date')

    date.format (new Date, 'HH:mm:ss')

    生成样式文件

    "build:theme": "
    node build/bin/gen-cssfile &&
    gulp build --gulpfile packages/theme-chalk/gulpfile.js &&
    cp-cli packages/theme-chalk/lib lib/theme-chalk
    "

    这里直接使用 gulp 将 scss 文件转为 css 文件。

    gulp.src ('./src/*.scss')
    .pipe (sass.sync ())
    .pipe (autoprefixer ({
    browsers: ['ie > 9', 'last 2 versions'],
    cascade: false
    }))
    .pipe (cssmin ())
    .pipe (gulp.dest ('./lib'));

    最终我们引入的 element-ui/lib/theme-chalk/index.css,其源文件只不过是把所有组件的 scss 文件进行 import。这个 index.scss 是在运行 gulp 之前,通过 node build/bin/gen-cssfile 命令生成的,逻辑与生成 js 的入口文件类似,同样是遍历 components.json。

    index.scss

    发布流程

    代码经过之前的编译,就到了发布流程,在 Element 中发布主要是用 shell 脚本实现的。Element 发布一共涉及三个部分。

    1. git 发布
    2. npm 发布
    3. 官网发布
    // 新版本发布 
    "pub": "
    npm run bootstrap &&
    sh build/git-release.sh &&
    sh build/release.sh &&
    node build/bin/gen-indices.js &&
    sh build/deploy-faas.sh
    "

    git 冲突检测

    运行 git-release.sh 进行 git 冲突的检测,这里主要是检测 dev 分支是否冲突,因为 Element 是在 dev 分支进行开发的(这个才 Element 官方的开发指南也有提到),只有在最后发布时,才 merge 到 master。

    开发指南

    #!/usr/bin/env sh
    # 切换至 dev 分支
    git checkout dev

    # 检测本地和暂存区是否还有未提交的文件
    if test -n "$(git status --porcelain)"; then
    echo 'Unclean working tree. Commit or stash changes first.' >&2;
    exit 128;
    fi
    # 检测本地分支是否有误
    if ! git fetch --quiet 2>/dev/null; then
    echo 'There was a problem fetching your branch. Run `git fetch` to see more...' >&2;
    exit 128;
    fi
    # 检测本地分支是否落后远程分支
    if test "0" != "$(git rev-list --count --left-only @'{u}'...HEAD)"; then
    echo 'Remote history differ. Please pull changes.' >&2;
    exit 128;
    fi

    echo 'No conflicts.' >&2;

    git 发布;npm 发布

    检测到 git 在 dev 分支上没有冲突后,立即执行 release.sh。

    发布

    这一部分代码比较简单,可以直接在 github 上查看。上述发布流程,省略了一个部分,就是 Element 会将其样式也发布到 npm 上。

    # publish theme
    echo "Releasing theme-chalk $VERSION ..."
    cd packages/theme-chalk
    npm version $VERSION --message "[release] $VERSION"
    if [[ $VERSION =~ "beta" ]]
    then
    npm publish --tag beta
    else
    npm publish
    fi

    如果你只想使用 Element 的样式,不使用它的 Vue 组件,你也可以直接在 npm 上下载他们的样式,不过一般也没人这么做吧。

    npm install -S element-theme-chalk

    官网更新

    这一步就不详细说了,因为不在文章想说的构建流程之列。

    大致就是将静态资源生成到 examples/element-ui 目录下,然后放到 gh-pages 分支,这样就能通过 github pages 的方式访问。不信,你访问试试。

    http://elemefe.github.io/element

    同时在该分支下,写入了 CNAME 文件,这样访问 element.eleme.io 也能定向到 element 的 github pages 了。

    echo element.eleme.io>>examples/element-ui/CNAME

    域名重定向

    总结

    Element 的代码总体看下来,还是十分流畅的,对自己做组件化帮助很大。刚开始写这篇文章的时候,标题写着 主流组件库的构建流程,想把 Element 和 antd 的构建流程都写出来,写完 Element 才发现这个坑开得好大,于是麻溜的把标题改成 Element 的构建流程。当然 Element 除了其构建流程,本身很多组件的实现思路也很优雅,大家感兴趣可以去看一看。

    支持一下
    扫一扫,支持一下
    • 微信扫一扫
    • 支付宝扫一扫