當咱們去實現一個組件庫的時候,並不會一上來就擼碼,而是把它當作產品同樣,思考一下咱們的組件庫的需求。那麼對於 element-ui
,除了基於 Vue.js 技術棧開發組件,它還有哪些方面的需求呢。javascript
需求有了,接下來就須要去思考如何去實現,本文會依據 element-ui
2.11.1 版本的源碼來分析這些需求是如何實現的。固然,element-ui
早期必定不是這樣子的,咱們分析的這個版本已是通過它屢次迭代優化後的,若是你想去了解它的發展歷程,能夠去 GitHub 搜索它的歷史版本。css
組件庫最核心的仍是組件,先來看一下 element-ui
組件的設計原則:一致、反饋、效率、可控。具體的解釋在官網有,我就很少貼了,在 element-ui
開發團隊背後,有一個強大的設計團隊,這也得益於 element-ui
的創始人 sofish
在公司的話語權和地位,爭取到這麼好的資源。因此 element-ui
組件的外型、配色、交互都作的很是不錯。html
做爲一個基礎組件庫,還有一個很重要的方面就是組件種類豐富。element-ui
官方目前有 55 個組件,分紅了 6 大類,分別是基礎組件、表單類組件、數據類組件、提示類組件、導航類組件和其它類型組件。這些豐富的基礎組件能很好地知足大部分 PC 端 to B 業務開發需求。前端
開發這麼多組件,須要大量的時間和精力,因此這裏要很是感謝
element-ui
團隊,爲咱們提供了這些基礎組件,咱們基於它們作二次開發,節約了很是多的時間。vue
element-ui
的組件源碼在 packages
目錄裏維護,而並不在 src
目錄中。這麼作並非爲了要採用 monorepo,我也並無找到 lerna 包管理工具,這麼作的目的我猜想是爲了讓每一個組件能夠單獨打包,支持按需引入。但實際上想達到這個目的也並不必定須要這麼去組織維護代碼,我更推薦把組件庫中的組件代碼放在 src/components
目錄中維護,而後經過修改 webpack 配置腳本也能夠作到每一個組件單獨打包以及支持按需引入,源碼放在 src
目錄老是更合理的。java
element-ui
的一大特點是支持自定義主題,你可使用在線主題編輯器,能夠修改定製 Element 全部全局和組件的 Design Tokens,並能夠方便地實時預覽樣式改變後的視覺。同時它還能夠基於新的定製樣式生成完整的樣式文件包,供直接下載使用,那麼它是如何作到這點的呢?node
element-ui
組件的樣式、公共樣式都在 packages/theme-chalk
文件中,而且它是能夠獨立發佈的。element-ui
組件樣式中的顏色、字體、線條等等樣式都是經過變量的方式引入的,在 packages/theme-chalk/src/common/var.scss
中咱們能夠看到這些變量的定義,這樣就給作多主題提供了方便,由於我只要修改這些變量,就能夠實現組件的主題改變。webpack
瞭解了基本原理,作在線替換主題也並非難事了,我並不會詳細去講在線定製主題前端交互部分,感興趣的同窗能夠本身去看源碼,都在 examples
目錄中,我這裏只說一下本質的原理。git
想要作到在線換膚,而且實時預覽,須要藉助 server 的幫助,好比主題能夠經過一個配置去維護,用戶作一系列操做後,會生成新的主題配置,把這個配置經過接口提交的方式告訴 server,而後 server 會根據這個配置作返回生成新的 CSS(具體的實施的方案未開源,大體會作一些變量替換,而後編譯),新的 CSS 的樣式就會覆蓋默認的樣式,達到了切換主題的目的。github
咱們能夠在主題編輯頁面打開網絡面板,能夠看到有 2 個 xhr 請求,如圖:
其中,updateVarible
是一個 POST 請求,他會把你修改的的主題配置提交到後端 server,提交的數據你能夠本身去查看它的 Request Payload,這個 POST 請求會返回一段 CSS 文本,而後會動態插入到 head 標籤的底部,來覆蓋默認樣式,你能夠經過審查元素看到 head 底部會動態插入一個 id 爲 chalk-style
的標籤。
下圖就是該請求返回的樣式文本 :
相關代碼在 examples/components/theme/loader/index.vue
中。
onAction() {
this.triggertProgressBar(true);
const time = +new Date();
updateVars(this.userConfig)
.then(res => {
this.applyStyle(res, time);
})
.catch(err => {
this.onError(err);
})
.then(() => {
this.triggertProgressBar(false);
});
},
applyStyle(res, time) {
if (time < this.lastApply) return;
this.updateDocs(() => {
updateDomHeadStyle('chalk-style', res);
});
this.lastApply = time;
}
複製代碼
onAction
函數中的 updateVars
就是去發送 POST 請求,而 applyStyle
函數就是去修改和覆蓋默認樣式,updateDocs
函數會去更新默認主題顏色,updateDomHeadStyle
樣式會添加或者修改 id 爲 chalk-style
的 style 標籤,目的就是覆蓋默認樣式,應用新主題樣式。
updateVars
請求在頁面加載的時候會發起,在你修改完主題配置後也會發起。
再來看一下 getVarible
請求,它是一個 GET 請求,返回的內容是主題配置頁面右側配置面板的數據源,以下圖所示:
主題配置面板根據該數據源生成,而且當你去編輯其中一項的時候,又會發起 updateVars
POST 請求,把更新的配置提交,而後後端會返回新的 CSS 並在前端生效。
另外,用戶修改的配置還利用了 localStorage 在本地保存了一份,這樣用戶每次編輯均可以保存一份主題,下次也能夠繼續基於某個主題繼續編輯。
不過,這麼實現多主題也並不是完美,爲了編譯加速,element-ui
把樣式部分單獨抽離出單獨的文件,這樣給開發組件的同窗帶來很大的不便,當你去編寫組件的樣式的時候,須要在多個文件中來回切換,並且這樣也不符合組件就近管理的原則。可是若是把樣式寫在組件中,server 端去編譯生成單獨樣式文件的時間就會增加(須要從組件中提取 CSS),因此這是一個須要權衡的問題。
說到 Vue 的國際化方案,你們很容易會聯想到 vue-i18n
方案,element-ui
並未引入 vue-i18n
,不過它是能夠很好地與 vue-i18n
兼容的。
全部的國際化方案都會用到語言包,語言包一般會返回一個 JSON 格式的數據,element-ui
組件庫的語言包在 src/locale/lang
目錄下,以英語語言包爲例:
export default {
el: {
colorpicker: {
confirm: 'OK',
clear: 'Clear'
}
// ...
}
}
複製代碼
在 packages/color-picker/src/components/picker-dropdown.vue
中,咱們在模板部分能夠看到這個語言包的使用:
<el-button size="mini" type="text" class="el-color-dropdown__link-btn" @click="$emit('clear')">
{{ t('el.colorpicker.clear') }}
</el-button>
<el-button plain size="mini" class="el-color-dropdown__btn" @click="confirmValue">
{{ t('el.colorpicker.confirm') }}
</el-button>
複製代碼
模板中用到的 t
函數,它定義在 src/mixins/locale.js
中:
import { t } from 'element-ui/src/locale';
export default {
methods: {
t(...args) {
return t.apply(this, args);
}
}
};
複製代碼
其實是在 src/locale/index.js
中定義的 t
函數:
export const t = function(path, options) {
let value = i18nHandler.apply(this, arguments);
if (value !== null && value !== undefined) return value;
const array = path.split('.');
let current = lang;
for (let i = 0, j = array.length; i < j; i++) {
const property = array[i];
value = current[property];
if (i === j - 1) return format(value, options);
if (!value) return '';
current = value;
}
return '';
};
複製代碼
這個函數是根據傳入的 path
路徑,好比咱們例子中的 el.colorpicker.confirm
,從語言包中找到對應的文案。其中 i18nHandler
是一個 i18n
的處理函數,這塊邏輯就是用來兼容外部的 i18n
方案如 vue-i18n
。
let i18nHandler = function() {
const vuei18n = Object.getPrototypeOf(this || Vue).$t;
if (typeof vuei18n === 'function' && !!Vue.locale) {
if (!merged) {
merged = true;
Vue.locale(
Vue.config.lang,
deepmerge(lang, Vue.locale(Vue.config.lang) || {}, { clone: true })
);
}
return vuei18n.apply(this, arguments);
}
};
export const i18n = function(fn) {
i18nHandler = fn || i18nHandler;
};
export const use = function(l) {
lang = l || lang;
};
複製代碼
能夠看到 i18nHandler
默認會嘗試去找 Vue 原型中的 $t
函數,這是 vue-i18@5.x
的實現,會在 Vue 的原型上掛載 $t
方法。
另外它也暴露了 i18n
方法,能夠外部傳入其它的 i18n
方法,覆蓋 i18nHandler
。
若是沒有外部提供的 i18n
方法,那麼就直接找到當前的語言包 let current = lang;
,接下來的邏輯就是從這個語言包對象中讀到對應的字符串值,固然若是字符串須要格式化則調用 format
函數,這塊邏輯同窗們感興趣能夠本身看。
所以在使用對應的語言包的時候必定要註冊:
import lang from 'element-ui/lib/locale/lang/en'
import locale from 'element-ui/lib/locale'
// 設置語言
locale.use(lang)
複製代碼
這樣就註冊了英文語言包,在模板中就能夠正常使用並找到對應的語言了。
若是你要開發一個國際化項目,在運行時才能知道用戶的語言,能夠考慮使用異步動態加載的方式,在渲染頁面前先獲取語言包,另外也能夠考慮作緩存優化,不過這個話題延伸起來就有點多了,將來我可能會單開一個主題去分享業務如何作國際化。
做爲一個優秀的開源組件庫,友好的文檔和 demo 是必不可少的,它也能幫你招攬到很多用戶。做爲一個組件庫的開發者和維護者,也但願用最小的成原本維護文檔和 demo。
element-ui
的文檔和 demo 是融爲一體的,咱們打開它的文檔,能夠看到文檔不只介紹了每一個組件的使用方式,還展現了組件的各類示例,而且還能夠清楚地看到每一個示例的源碼,對用戶而言很是友好。那麼 element-ui
內部是如何去編寫這些 demo 和文檔的呢?實際上,每一個組件的文檔和 demo 都是經過一個單獨的 .md
文件生成的,那麼它又是如何作到這點的呢?
element-ui
的 demo 源碼都在 examples
目錄中維護,當咱們在 element-ui
工程下運行 npm run dev
的時候,會啓動它的開發調試模式,而且運行官方文檔和 demo。
看一下 npm scripts
:
"scripts": {
"bootstrap": "yarn || npm i",
"build:file": "node build/bin/iconInit.js & node build/bin/build-entry.js & node build/bin/i18n.js & node build/bin/version.js",
"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",
}
複製代碼
咱們省略了其它的 scripts,重點看 dev
和相關的幾個命令,其中 bootstrap
的做用是安裝依賴,build:file
的做用是運行 build
目錄下幾個命令,包括對 icon
、entry
、i18n
、version
等初始化。在執行完 bootstrap
和 build:file
後,經過 webpack-dev-server
運行 build/webpack.demo.js
,這個是重點,咱們來看一下這個 webpack 的配置文件。
const webpackConfig = {
mode: process.env.NODE_ENV,
entry: isProd ? {
docs: './examples/entry.js',
'element-ui': './src/index.js'
} : (isPlay ? './examples/play.js' : './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: config.alias,
modules: ['node_modules']
},
devServer: {
host: '0.0.0.0',
port: 8085,
publicPath: '/',
hot: true
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
test: /\.md$/,
use: [
{
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
loader: path.resolve(__dirname, './md-loader/index.js')
}
]
}
]
}
};
複製代碼
因爲整個配置文件內容比較長,我只保留了重點的部分,重點看一下 entry
和 module
下的 rules
。
element-ui
官網本質上就是一個用 vue
開發的應用,當咱們運行 npm run dev
的時候,入口文件是 examples
目錄下的 entry.js
:
import Vue from 'vue';
import entry from './app';
import VueRouter from 'vue-router';
import Element from 'main/index.js';
import hljs from 'highlight.js';
import routes from './route.config';
import demoBlock from './components/demo-block';
import MainFooter from './components/footer';
import MainHeader from './components/header';
import SideNav from './components/side-nav';
import FooterNav from './components/footer-nav';
import title from './i18n/title';
import 'packages/theme-chalk/src/index.scss';
import './demo-styles/index.scss';
import './assets/styles/common.css';
import './assets/styles/fonts/style.css';
import icon from './icon.json';
Vue.use(Element);
Vue.use(VueRouter);
Vue.component('demo-block', demoBlock);
Vue.component('main-footer', MainFooter);
Vue.component('main-header', MainHeader);
Vue.component('side-nav', SideNav);
Vue.component('footer-nav', FooterNav);
const globalEle = new Vue({
data: { $isEle: false } // 是否 ele 用戶
});
Vue.mixin({
computed: {
$isEle: {
get: () => (globalEle.$data.$isEle),
set: (data) => {globalEle.$data.$isEle = data;}
}
}
});
Vue.prototype.$icon = icon; // Icon 列表頁用
const router = new VueRouter({
mode: 'hash',
base: __dirname,
routes
});
router.afterEach(route => {
// https://github.com/highlightjs/highlight.js/issues/909#issuecomment-131686186
Vue.nextTick(() => {
const blocks = document.querySelectorAll('pre code:not(.hljs)');
Array.prototype.forEach.call(blocks, hljs.highlightBlock);
});
const data = title[route.meta.lang];
for (let val in data) {
if (new RegExp('^' + val, 'g').test(route.name)) {
document.title = data[val];
return;
}
}
document.title = 'Element';
ga('send', 'event', 'PageView', route.name);
});
new Vue({ // eslint-disable-line
...entry,
router
}).$mount('#app');
複製代碼
入口文件作的事情很簡單,全引入的方式註冊了 element-ui
組件庫,註冊了一些官網用到的組件,註冊了路由以及路由的全局鉤子函數。
這裏咱們要重點關注路由部分,路由的配置都在 examples/route.config.js
中:
import navConfig from './nav.config';
import langs from './i18n/route';
const LOAD_MAP = {
'zh-CN': name => {
return r => require.ensure([], () =>
r(require(`./pages/zh-CN/${name}.vue`)),
'zh-CN');
},
'en-US': name => {
return r => require.ensure([], () =>
r(require(`./pages/en-US/${name}.vue`)),
'en-US');
},
'es': name => {
return r => require.ensure([], () =>
r(require(`./pages/es/${name}.vue`)),
'es');
},
'fr-FR': name => {
return r => require.ensure([], () =>
r(require(`./pages/fr-FR/${name}.vue`)),
'fr-FR');
}
};
const load = function(lang, path) {
return LOAD_MAP[lang](path);
};
const LOAD_DOCS_MAP = {
'zh-CN': path => {
return r => require.ensure([], () =>
r(require(`./docs/zh-CN${path}.md`)),
'zh-CN');
},
'en-US': path => {
return r => require.ensure([], () =>
r(require(`./docs/en-US${path}.md`)),
'en-US');
},
'es': path => {
return r => require.ensure([], () =>
r(require(`./docs/es${path}.md`)),
'es');
},
'fr-FR': path => {
return r => require.ensure([], () =>
r(require(`./docs/fr-FR${path}.md`)),
'fr-FR');
}
};
const loadDocs = function(lang, path) {
return LOAD_DOCS_MAP[lang](path);
};
const registerRoute = (navConfig) => {
let route = [];
Object.keys(navConfig).forEach((lang, index) => {
let navs = navConfig[lang];
route.push({
path: `/${ lang }/component`,
redirect: `/${ lang }/component/installation`,
component: load(lang, 'component'),
children: []
});
navs.forEach(nav => {
if (nav.href) return;
if (nav.groups) {
nav.groups.forEach(group => {
group.list.forEach(nav => {
addRoute(nav, lang, index);
});
});
} else if (nav.children) {
nav.children.forEach(nav => {
addRoute(nav, lang, index);
});
} else {
addRoute(nav, lang, index);
}
});
});
function addRoute(page, lang, index) {
const component = page.path === '/changelog'
? load(lang, 'changelog')
: loadDocs(lang, page.path);
let child = {
path: page.path.slice(1),
meta: {
title: page.title || page.name,
description: page.description,
lang
},
name: 'component-' + lang + (page.title || page.name),
component: component.default || component
};
route[index].children.push(child);
}
return route;
};
let route = registerRoute(navConfig);
const generateMiscRoutes = function(lang) {
let guideRoute = {
path: `/${ lang }/guide`, // 指南
redirect: `/${ lang }/guide/design`,
component: load(lang, 'guide'),
children: [{
path: 'design', // 設計原則
name: 'guide-design' + lang,
meta: { lang },
component: load(lang, 'design')
}, {
path: 'nav', // 導航
name: 'guide-nav' + lang,
meta: { lang },
component: load(lang, 'nav')
}]
};
let themeRoute = {
path: `/${ lang }/theme`,
component: load(lang, 'theme-nav'),
children: [
{
path: '/', // 主題管理
name: 'theme' + lang,
meta: { lang },
component: load(lang, 'theme')
},
{
path: 'preview', // 主題預覽編輯
name: 'theme-preview-' + lang,
meta: { lang },
component: load(lang, 'theme-preview')
}]
};
let resourceRoute = {
path: `/${ lang }/resource`, // 資源
meta: { lang },
name: 'resource' + lang,
component: load(lang, 'resource')
};
let indexRoute = {
path: `/${ lang }`, // 首頁
meta: { lang },
name: 'home' + lang,
component: load(lang, 'index')
};
return [guideRoute, resourceRoute, themeRoute, indexRoute];
};
langs.forEach(lang => {
route = route.concat(generateMiscRoutes(lang.lang));
});
route.push({
path: '/play',
name: 'play',
component: require('./play/index.vue')
});
let userLanguage = localStorage.getItem('ELEMENT_LANGUAGE') || window.navigator.language || 'en-US';
let defaultPath = '/en-US';
if (userLanguage.indexOf('zh-') !== -1) {
defaultPath = '/zh-CN';
} else if (userLanguage.indexOf('es') !== -1) {
defaultPath = '/es';
} else if (userLanguage.indexOf('fr') !== -1) {
defaultPath = '/fr-FR';
}
route = route.concat([{
path: '/',
redirect: defaultPath
}, {
path: '*',
redirect: defaultPath
}]);
export default route;
複製代碼
這個路由配置文件提供了指南、組件、主題、資源等多個路由頁面的配置,而且支持了多語言,咱們重點關注一下組件路由是如何生成的,它主要經過 registerRoute(navConfig)
方法生成。
其中 navConfig
讀取的是 examples/nav.config.json
文件,這個配置文件太長我就不貼了,它包括了多個語言的配置,維護了左側組件導航菜單路徑映射關係。
registerRoute
函數內部就是遍歷 navConfig
,根據它內部元素的數據結構生成路由配置,若是數據中有 children
則生成子路由。
咱們知道 Vue Router 的本質是根據不一樣的 URL path,<router-view>
組件映射到對應的路由組件,對於每個組件的路由,都是經過 addRoute(nav, lang, index)
方法生成的,該方法內部又調用了 loadDocs(lang, page.path)
獲取到對應的路由組件。
const loadDocs = function(lang, path) {
return LOAD_DOCS_MAP[lang](path);
};
const LOAD_DOCS_MAP = {
'zh-CN': path => {
return r => require.ensure([], () =>
r(require(`./docs/zh-CN${path}.md`)),
'zh-CN');
},
'en-US': path => {
return r => require.ensure([], () =>
r(require(`./docs/en-US${path}.md`)),
'en-US');
},
'es': path => {
return r => require.ensure([], () =>
r(require(`./docs/es${path}.md`)),
'es');
},
'fr-FR': path => {
return r => require.ensure([], () =>
r(require(`./docs/fr-FR${path}.md`)),
'fr-FR');
}
};
複製代碼
以中文爲例,咱們獲取到某個 path
下的路由組件就是一個工廠函數,它對應加載的組件路徑是 exmaples/docs/zh-CN/${path}.md
。這裏要注意的是,和咱們普通的異步組件加載方式不一樣,這裏加載的竟然是一個 .md
文件,而並不是一個 .vue
文件,但卻能和 .vue
文件同樣能渲染成一個 Vue 組件,這是如何作到的呢?
咱們知道,webpack 的理念是一切資源均可以 require
,只要配置了對應的 loader。回到 build/webpack.demo.js
,咱們發現對於 .md
文件咱們配置了相應的 loader:
{
test: /\.md$/,
use: [
{
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
loader: path.resolve(__dirname, './md-loader/index.js')
}
]
}
複製代碼
對於 .md
文件,這裏 use 數組中配置了 2 項,它們執行順序是逆序的,也就是先執行 md-loader
,再執行 vue-loader
,md-loader
的代碼在 build/md-loader/index.js
中:
const {
stripScript,
stripTemplate,
genInlineComponentText
} = require('./util');
const md = require('./config');
module.exports = function(source) {
const content = md.render(source);
const startTag = '<!--element-demo:';
const startTagLen = startTag.length;
const endTag = ':element-demo-->';
const endTagLen = endTag.length;
let componenetsString = '';
let id = 0; // demo 的 id
let output = []; // 輸出的內容
let start = 0; // 字符串開始位置
let commentStart = content.indexOf(startTag);
let commentEnd = content.indexOf(endTag, commentStart + startTagLen);
while (commentStart !== -1 && commentEnd !== -1) {
output.push(content.slice(start, commentStart));
const commentContent = content.slice(commentStart + startTagLen, commentEnd);
const html = stripTemplate(commentContent);
const script = stripScript(commentContent);
let demoComponentContent = genInlineComponentText(html, script);
const demoComponentName = `element-demo${id}`;
output.push(`<template slot="source"><${demoComponentName} /></template>`);
componenetsString += `${JSON.stringify(demoComponentName)}: ${demoComponentContent},`;
// 從新計算下一次的位置
id++;
start = commentEnd + endTagLen;
commentStart = content.indexOf(startTag, start);
commentEnd = content.indexOf(endTag, commentStart + startTagLen);
}
// 僅容許在 demo 不存在時,才能夠在 Markdown 中寫 script 標籤
// todo: 優化這段邏輯
let pageScript = '';
if (componenetsString) {
pageScript = `<script> export default { name: 'component-doc', components: { ${componenetsString} } } </script>`;
} else if (content.indexOf('<script>') === 0) { // 硬編碼,有待改善
start = content.indexOf('</script>') + '</script>'.length;
pageScript = content.slice(0, start);
}
output.push(content.slice(start));
return ` <template> <section class="content element-doc"> ${output.join('')} </section> </template> ${pageScript} `;
};
複製代碼
webpack loader 的原理很簡單,輸入是文件的原始內容,返回的是通過 loader 處理後的內容。對於 md-loader
,輸入的是 .md
文檔,輸出的則是一個 Vue SFC 格式的字符串,這樣它的輸出就能夠做爲下一個 vue-loader
的輸入作處理了。
咱們來簡單看一下 md-loader
中間處理過程。首先執行了 md.render(source)
對 md
文檔解析,提取文檔中 :::demo {content} :::
內容,分別生成一些 Vue 的模板字符串,而後再從這個模板字符串中循環查找 <!--element-demo:
和 :element-demo-->
包裹的內容,從中提取模板字符串到 output
中,提取 script 到 componenetsString
中,而後構造 pageScript
,最後返回的內容就是:
return ` <template> <section class="content element-doc"> ${output.join('')} </section> </template> ${pageScript} `;
複製代碼
最終生成的字符串知足咱們一般編寫的 .vue
SFC 格式,它會做爲下一個 vue-loader
的輸入,因此這樣咱們就至關於經過加載一個 .md
格式的文件的方式加載了 Vue 組件。
這裏面還有不少和 .md
文件解析的細節,若是你對最終生成的 output
和 pageScript
代碼是什麼感興趣,建議你本身調試一番。
element-ui
這種文檔和 demo
的實現方式是很是巧妙的,大大減小了 demo 和文檔的維護成本,而且對於用戶來講也很是友好,若是你也爲本身的庫構建文檔,不妨參考它的實現。
一般 JS 庫都會支持 npm 和 CDN 2 種安裝方式,element-ui
也不例外。
先說一下 CDN 的安裝方式,實際上 element-ui
會把全部組件打包生成一份 CSS 和 JS,官方也提供了例子:
<!-- 引入樣式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css"> <!-- 引入組件庫 --> <script src="https://unpkg.com/element-ui/lib/index.js"></script> 複製代碼
CDN 安裝方式有它的好處,不須要構建工具,開箱即用,但缺點也很明顯,全量引入了全部組件,體積很是大。
因爲大部分人在開發 Vue 項目都是基於 vue-cli
腳手架初始化項目的,因此更推薦使用 npm 方式安裝。
npm i element-ui -S
複製代碼
說到 npm 安裝,不得不提 element-ui
提供的 2 種組件引入方式,完整引入和部分引入。
支持完整引入很是容易,把全部組件打包成一份 CSS 和 JS,而且在 package.json
中配置:
"main": "lib/element-ui.common.js"
複製代碼
這樣當用戶執行 import ElementUI from 'element-ui'
的時候就能夠完整引入了組件的 JS 代碼了。正如咱們以前說的,element-ui
會單獨發佈 CSS,因此你還須要 import 'element-ui/lib/theme-chalk/index.css'
。
完整引入的好處是方便,只須要 2 行代碼就能夠完整地使用 element-ui
全部的組件,但缺點也很明顯,引入的組件包體積很大,一般一個項目也用不到全部的組件,會有資源浪費。
所以最佳實踐就是按需引入:
import Vue from 'vue'
import { Button } from 'element-ui'
Vue.component(Button.name, Button)
複製代碼
大部分人這麼用的時候會以爲理所固然,不知道你們有沒有想過:爲何這種引入方式能夠實現按需引入呢?要搞清楚這個問題,就要搞清楚 import { Button } from 'element-ui'
這個背後都作了什麼。
其實官網已經有答案了,在使用按需引入的時候,要藉助 babel-plugin-component
這個 webpack 插件,而且配置 .babelrc
:
{
"presets": [["es2015", { "modules": false }]],
"plugins": [
[
"component",
{
"libraryName": "element-ui",
"styleLibraryName": "theme-chalk"
}
]
]
}
複製代碼
實際上它是把 import { Button } from 'element-ui'
轉換成:
var button = require('element-ui/lib/button')
require('element-ui/lib/theme-chalk/button.css')
複製代碼
這樣咱們就精準地引入了對應 lib 下的 Button
組件的 JS 和 CSS 代碼了,也就實現了按需引入 Button
組件。
element-ui
這種按需引入的方式雖然方便,但背後卻要解決幾個問題,因爲咱們支持每一個組件能夠單獨引入,那麼若是產生了組件依賴而且同時按需引入的時候,代碼冗餘問題怎麼解決。舉個例子,在 element-ui
中,Table
組件依賴了 CheckBox
組件,那麼當我同時引入了 Table
組件和 CheckBox
組件的時候,會不會產生代碼冗餘呢?
import { Table, CheckBox } from 'element-ui'
複製代碼
若是你不作任何處理的話,答案是會,你最終引入的包會有 2 份 CheckBox
的代碼。那麼 element-ui
是怎麼解決這個問題的呢?實際上只是部分解決了,它的 webpack 配置文件中配置了 externals
,在 build/config.js
中咱們能夠看到這些具體的配置:
var externals = {};
Object.keys(Components).forEach(function(key) {
externals[`element-ui/packages/${key}`] = `element-ui/lib/${key}`;
});
externals['element-ui/src/locale'] = 'element-ui/lib/locale';
utilsList.forEach(function(file) {
file = path.basename(file, '.js');
externals[`element-ui/src/utils/${file}`] = `element-ui/lib/utils/${file}`;
});
mixinsList.forEach(function(file) {
file = path.basename(file, '.js');
externals[`element-ui/src/mixins/${file}`] = `element-ui/lib/mixins/${file}`;
});
transitionList.forEach(function(file) {
file = path.basename(file, '.js');
externals[`element-ui/src/transitions/${file}`] = `element-ui/lib/transitions/${file}`;
});
externals = [Object.assign({
vue: 'vue'
}, externals), nodeExternals()];
複製代碼
externals
能夠防止將這些 import 的包打包到 bundle 中,並在運行時再去從外部獲取這些擴展依賴。
咱們來看一下打包後的 lib/table.js
,咱們能夠看到編譯後的 table.js
對 CheckBox
組件的依賴引入:
module.exports = require("element-ui/lib/checkbox");
複製代碼
這麼處理的話,就不會打包生成 2 份 CheckBox
JS 部分的代碼了,可是對於 CSS 部分,element-ui
並未處理冗餘狀況,能夠看到 lib/theme-chalk/checkbox.css
和 lib/theme-chalk/table.css
中都會有 CheckBox
組件的 CSS 樣式。
其實,要解決按需引入的 JS 和 CSS 的冗餘問題並不是難事,能夠用後編譯的思想,即依賴包提供源碼,而編譯交給應用處理,這樣不只不會有組件冗餘代碼,甚至連編譯的冗餘代碼都不會有,實際上咱們基於 element-ui
fork 的組件庫 zoom-ui
就應用了後編譯技術,以前在滴滴搞的開源組件庫cube-ui
組件庫也是這麼玩的。更多後編譯相關介紹能夠參考這篇文章。
前端對於工程化的要求愈來愈高,element-ui
做爲一個組件庫,它在工程化方面作了哪些事情呢?
首先是開發階段,爲了保證你們代碼風格的一致性,使用了 ESLint,甚至專門寫了 eslint-config-elemefe
做爲 ESint 的擴展規則配置;爲了方便本地開發調試,藉助了 webpack 並配置了 Hot Reload;利用模塊化開發的思想把組件依賴的一些公共模塊放在了 src
目錄,並依據功能拆分出 directives
、locale
、mixins
、transitions
、utils
等模塊。
其次是測試方面,使用了 karma 測試框架,爲每個組件編寫了單元測試,而且利用 Travis CI 集成了測試。
接着是構建方面,element-ui
編寫了不少 npm scripts,以 dist
這個 script 爲例:
"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"
複製代碼
它內部會依次執行多個命令,最終會生成 lib
目錄和打包後的文件。我並不打算介紹全部的命令,感興趣同窗可自行研究,這裏我想介紹一下 build:file
這個 script 作的事情:
"build:file": "node build/bin/iconInit.js & node build/bin/build-entry.js & node build/bin/i18n.js & node build/bin/version.js",
複製代碼
這裏會依次執行 build/bin
目錄下的一些 Node 腳本,對 icon
、entry
、i18n
、version
等作了一系列的初始化工做,它們的內容都是根據一些規則作文件的 IO,這麼作的好處就是徹底經過工具的手段自動化生成文件,比人工靠譜且效率更高,這波操做很是值得咱們學習和應用。
最後是部署,經過 pub
這個 npm script 完成:
"pub": "npm run bootstrap && sh build/git-release.sh && sh build/release.sh && node build/bin/gen-indices.js && sh build/deploy-faas.sh"
複製代碼
主要是經過運行一系列的 bash 腳本,實現了代碼的提交、合併、版本管理、npm 發佈、官網發佈等,讓整個發佈流程自動化完成,腳本具體內容感興趣的同窗可自行查看。
至此,element-ui
的組件庫的總體設計介紹完畢,能夠看到除了這些豐富的組件背後,還有很完整的一套解決方案,不少經驗都值得咱們學習和借鑑,不完美的地方也值得咱們去思考,其中有不少技術細節能夠深刻挖掘。
把不會的東西學會了,那麼你就進步了,若是你以爲這類文章有幫助,也歡迎把它推薦給你身邊的小夥伴。
下一篇預告 :Element-UI 技術揭祕(3)— Layout 佈局組件的設計與實現。
另外,我最近剛開了公衆號「老黃的前端私房菜」,《Element-UI 技術揭祕》系列文章會第一時間在公衆號更新和發佈,除此以外,我還會常常分享一些前端進階知識,乾貨,也會偶爾分享一些軟素質技能,歡迎你們關注喔~