最近一直在着手作一個與業務強相關的組件庫,一直在思考要從哪裏下手,怎麼來設計這個組件庫,由於業務上一直在使用ElementUI(如下簡稱Element),因而想參考了一下Element組件庫的設計,看看Element構建方式,而且總結成了這篇文章。javascript
廢話很少說,先看看目錄結構,從目錄結構入手,一步步進行分解。css
├─build // 構建相關的腳本和配置 ├─examples // 用於展現Element組件的demo ├─lib // 構建後生成的文件,發佈到npm包 ├─packages // 組件代碼 ├─src // 引入組件的入口文件 ├─test // 測試代碼 ├─Makefile // 構建文件 ├─components.json // 組件列表 └─package.json
剛打開的時候看到了一個Makefile文件,若是學過c/c++的同窗對這個東西應該不陌生,當時看到後臺同窗發佈版本時,寫下了一句make love
,把我和個人小夥伴們都驚呆了。說正經的,makefile能夠說是比較早出如今UNIX 系統中的工程化工具,經過一個簡單的make XXX
來執行一系列的編譯和連接操做。不懂makefile文件的能夠看這篇文章瞭解下:前端入門->makefilehtml
當咱們打開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進行依賴安裝。在官方的貢獻指南也有提到。vue
同時在package.json文件中有個bootstrap命令就是使用yarn來安裝依賴。java
"bootstrap": "yarn || npm i",
安裝完依賴以後,就能夠進行開發了,運行npm run dev
,能夠經過webpack-dev-sever在本地運行Element官網的demo。node
"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:對代碼進行一些精簡,具體邏輯不變):linux
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);
最後生成的代碼以下:webpack
/* 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。c++
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發佈一共涉及三個部分。
// 新版本發佈 "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-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在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除了其構建流程,自己不少組件的實現思路也很優雅,你們感興趣能夠去看一看。