背景
最近一直在著手做一個與業務強相關的元件庫,一直在思考要從哪裡下手,怎麼來設計這個元件庫,因為業務上一直在使用ElementUI(以下簡稱Element),於是想參考了一下Element元件庫的設計,看看Element構建方式,並且總結成了這篇文章。
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 命令使用說明"
@echo "make install --- 安裝依賴"
@echo "make new <component-name> [中文名] --- 建立新元件 package. 例如 'make new button 按鈕'"
@echo "make dev --- 開發模式"
@echo "make dist --- 編譯專案,生成目標檔案"
@echo "make deploy --- 部署 demo"
@echo "make pub --- 釋出到 npm 上"
@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。
釋出流程
程式碼經過之前的編譯,就到了釋出流程,在Element中釋出主要是用shell指令碼實現的。Element釋出一共涉及三個部分。
- git釋出
- npm釋出
- 官網釋出
// 新版本釋出
"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的方式訪問。不信,你訪問試試。
同時在該分支下,寫入了CNAME檔案,這樣訪問element.eleme.io也能定向到element的github pages了。
echo element.eleme.io>>examples/element-ui/CNAME
複製程式碼
總結
Element的程式碼總體看下來,還是十分流暢的,對自己做元件化幫助很大。剛開始寫這篇文章的時候,標題寫著主流元件庫的構建流程
,想把Element和antd的構建流程都寫出來,寫完Element才發現這個坑開得好大,於是麻溜的把標題改成Element的構建流程
。當然Element除了其構建流程,本身很多元件的實現思路也很優雅,大家感興趣可以去看一看。