图片来自element
为什么写这篇文章
可能有人跟我一样,用了这么久elementUI组件库,还不清楚组件库是怎么搭建起来的,
或者我们也想做一个类似的ui组件库,实现基本功能,不知道怎么下手。
所以准备从学习element项目结构开始,写这篇文章主要记录自己对项目的理解。
从文章中能学到什么
不会分析每个组件的源码实现,
而是分析了element的项目架构。
不会分析每个文件的作用及package.json中所有script脚本意义,
而是从构建ui组件库可能遇到的问题出发,去看项目结构。
所以看完文章,希望可以解决以下疑问:
- 如何约定项目结构
- 如何实现组件按需引入
- 如何实现主题定制
- 如何实现文档站点和组件示例
- 支持国际化
- 支持typescript
- 如何搭建组件模板
目录结构
.
├── build
│ ├── bin # js指向文件,大量文件操作
│ ├── md-loader # 自定义实现的md-loader,markdown语法解析loader
│ ├── config.js # 功能的webpack选项配置,`alias``externals`等
│ ├── webpack.xxx.js # webpack 配置文件
├── examples
│ ├── components # 文档站点搭建组件
│ ├── extension # element chrome插件
│ ├── i18n # 文档站点中用到国际化语言包
│ ├── pages # 文档站点中不同页面对应的vue组件,不同语言生成不同vue组件
│ ├── entry.js # 文档站点vue编译入口文件
│ ├── index.tpl # 文档站点的index.html 模板
│ ├── route.config.js # 文档站点的路由生成
│ ├── nav.config.json # 文档站点的路由配置文件
├── lib # 组件打包后的产物目录
├── packages # 每个组件对应一个文件,组件的具体实现(不包括样式)
│ ├── theme-chalk # 默认主题样式文件
│ ├── alert # alert组件源码
│ ├── ... # 其它组件源码
├── src # 一些基础的dom操作库,工具库,本地化,指令, mixins等实现
│ ├── index.js # 组件注册入口
│ ├── directives # 自定义指令
│ ├── locale # 内置国际化语言包及使用api
├── tests # 测试用例
├── Makefile # makefile
├── components.json # 所有组件列表
...
按需加载
babel-plugin-component
利用babel-plugin-component库实现按需加载,按照文档上解释:
Converts
import { Button } from 'components'
to
var button = require('components/lib/button')
require('components/lib/button/style.css')
所以,element库里面每个组件及其样式文件都要单独打包,才能实现按需加载
组件单独打包
对应package.json里面的打包脚本
"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",`
一长串的命令就是编译项目,生成目标文件。只用关注里面打包组件的命令是
webpack --config build/webpack.component.js
再看build/webpack.component.js配置文件。
const path = require('path');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const Components = require('../components.json');
const config = require('./config');
const webpackConfig = {
mode: 'production',
entry: Components,
output: {
path: path.resolve(process.cwd(), './lib'),
publicPath: '/dist/',
filename: '[name].js',
chunkFilename: '[id].js',
libraryTarget: 'commonjs2'
},
...
};
module.exports = webpackConfig;
省略了配置中的loader
和plugins
,主要看这里的入口entry
,对应最外层的components.json
文件(这个文件很多地方都会用到)
// components.json
{
"pagination": "./packages/pagination/index.js",
"dialog": "./packages/dialog/index.js",
"autocomplete": "./packages/autocomplete/index.js",
"dropdown": "./packages/dropdown/index.js",
"dropdown-menu": "./packages/dropdown-menu/index.js",
"dropdown-item": "./packages/dropdown-item/index.js",
"menu": "./packages/menu/index.js",
...
}
里面的内容就是{key: packageName, value: packageDir}
,列出了所有的组件及路径。
再看webpack.component.js里面的output.filename: [name].js
,保留原组件名,实现了每个组件单独打包。
主题定制
为了实现主题定制,所以每个组件的样式单独放在一个文件里面。上面单独打包的组件中,也不包含任务样式。默认主题放在packages/theme-chalk
目录下。
样式单独打包
对应package.json里面的打包脚本
"build:theme": "node build/bin/gen-cssfile && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib lib/theme-chalk"
同样是一长串命令,跟打包样式都有关系,我们分开来看。
首先是node build/bin/gen-cssfile
执行gen-cssfile文件
var fs = require('fs');
var path = require('path');
var Components = require('../../components.json'); // 存放所有组件的列表
var themes = [ // 默认主题名
'theme-chalk'
];
Components = Object.keys(Components);
var basepath = path.resolve(__dirname, '../../packages/');
function fileExists(filePath) {
try {
return fs.statSync(filePath).isFile();
} catch (err) {
return false;
}
}
themes.forEach((theme) => {
var isSCSS = theme !== 'theme-default'; // 默认主题使用scss
var indexContent = isSCSS ? '@import "./base.scss";\n' : '@import "./base.css";\n';
Components.forEach(function(key) {
if (['icon', 'option', 'option-group'].indexOf(key) > -1) return;
var fileName = key + (isSCSS ? '.scss' : '.css');
indexContent += '@import "./' + fileName + '";\n'; // 遍历components.json列表,引入所有的组件样式
var filePath = path.resolve(basepath, theme, 'src', fileName);
if (!fileExists(filePath)) {
fs.writeFileSync(filePath, '', 'utf8');
console.log(theme, ' 创建遗漏的 ', fileName, ' 文件');
}
});
// 所有的组件样式引入代码,写入index文件
fs.writeFileSync(path.resolve(basepath, theme, 'src', isSCSS ? 'index.scss' : 'index.css'), indexContent);
});
主要是遍历components.json
组件列表,把所有组件的引入代码片段写入packages/theme-chalk/index.scss
中,最后结果就是
// packages/theme-chalk/index.scss
@import "./base.scss";
@import "./pagination.scss";
@import "./dialog.scss";
@import "./autocomplete.scss";
@import "./dropdown.scss";
@import "./dropdown-menu.scss";
@import "./dropdown-item.scss";
@import "./menu.scss";
...
接着执行gulp脚本打包
gulp build --gulpfile packages/theme-chalk/gulpfile.js
看下gulpfile.js 文件
'use strict';
const { series, src, dest } = require('gulp');
const sass = require('gulp-sass');
const autoprefixer = require('gulp-autoprefixer');
const cssmin = require('gulp-cssmin');
function compile() {
return src('./src/*.scss')
.pipe(sass.sync()) // 使用sass处理
.pipe(autoprefixer({ // 处理浏览器前缀
browsers: ['ie > 9', 'last 2 versions'],
cascade: false // 关闭级联展示
}))
.pipe(cssmin())
.pipe(dest('./lib'));
}
function copyfont() { // 拷贝font文件
return src('./src/fonts/**')
.pipe(cssmin())
.pipe(dest('./lib/fonts'));
}
exports.build = series(compile, copyfont);
分别进行了sass编译, autoprefixer 浏览器兼容, cssmin压缩,dest目录是packages/theme-chalk/lib
文件。
最后执行脚本,拷贝到最外层lib产物目录下
cp-cli packages/theme-chalk/lib lib/theme-chalk"
整个命令执行成功后,
~
├── lib #打包后生成目录
│ ├── theme-chalk
│ │ ├── fonts 字体文件
│ │ ├── alert.css
│ │ ├── aside.css
│ │ ├── autocomplete.css
...
改变主题
element官网上提供了两种改变主题方法,结合上面样式文件打包看下
项目中改变scss变量
如果项目使用了 SCSS,可以直接在项目中改变 Element 的样式变量。
新建一个样式文件,例如 element-variables.scss
,
/* 改变主题色变量 */
$--color-primary: teal;
/* 改变 icon 字体路径变量,必需 */
$--font-path: '~element-ui/lib/theme-chalk/fonts';
@import "~element-ui/packages/theme-chalk/src/index";
默认样式文件放在了packages/theme-chalk/
目录下,样式中使用的所有颜色都以变量形式放在了packages/theme-chalk/src/common/var.scss
文件中,改变主题色变量,再重新引入,从而改变了组件颜色
命令行主题工具
element 提供了主题生成工具 element-theme。
通过et -i
命令,生成element-variables.scss
文件,里面保存了主题所用到的所有变量,直接修改变量color就行,
然后运行命令et
. 内部其实以theme-chalk,作为样式模板,而它跟packages/theme-chalk/
目录下的内容基本一致, 用element-variables.scss文件中的内容写入样式模板中src/common/vars.scss中,覆盖原有的主题变量。
最后重新打包scss文件,生成新的样式文件,保存到theme文件下,
两种方式都依赖于样式文件单独打包了,并且样式变量单独放在src/common/var.scss
目录中。
文档站点和代码示例
文档站点可以看成一个单独的vue项目,文档一般用Markdown,问题可以看成如何把Markdown转成vue组件,答案是自定义md-loader。
文档站点打包
对应package.json里面的打包脚本
"deploy:build": "npm run build:file && cross-env NODE_ENV=production webpack --config build/webpack.demo.js && echo element.eleme.io>>examples/element-ui/CNAME"
先看npm run build:file
"build:file": "node build/bin/iconInit.js & node build/bin/build-entry.js & node build/bin/i18n.js & node build/bin/version.js"
-
node build/bin/iconInit.js
: 从packages/theme-chalk/src/icon.scss
里面提取icon名,放在examples/icon.json
里面 -
node build/bin/build-entry.js
: 遍历components.json
组件列表,利用json-templater/string
库生成内容,分别注册组件,最后将生成内容写入src/index
入口文件 -
node build/bin/i18n.js
: 生成国际化文档。 -
node build/bin/version.js
: 生成版本数字数组。
再看,打包文档站点的打包脚本
cross-env NODE_ENV=production webpack --config build/webpack.demo.js
文档站点打包对应的webpack配置文件是webpack.demo.js文件,重点看对makedown的解析
const webpackConfig = {
module: {
rules: [
{
test: /\.md$/,
use: [
{
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
loader: path.resolve(__dirname, './md-loader/index.js')
}
]
},
]
},
webpack loader是从右到左地取值并执行,所以Markdown 先经由 md-loader 处理,然后再交由 vue-loader 处理。
最后看
echo element.eleme.io>>examples/element-ui/CNAME
将内容element.eleme.io写入到CNAME文件中,主要作用是把文档站点的域名指向element.eleme.io
md-loader实现
这里只概述一下实现方法,具体细节可以看下谈谈 Element 文档中的 Markdown 解析
- 约定的文档格式
必须在::: demo 中写演示的例子,因为::: 属于Markdown 中的拓展语法,通过它来自定义容器。 - 利用markdown-it识别文档
markdown-it可以把普通的 Markdown 文本转换成 HTML - 提取demo容器中的代码
将容器中包含的代码片段,将转换成 HTML 片段,放在
<pre v-pre>
<code class="html">...</code>
</pre>
里面作为html片段展示。同时利用vue-template-compiler
编译代码,渲染成组件。
支持typescript
声明文件在types/*.d.ts 文件中,每个组件对应一个文件,
比如alert.d.ts 文件
/\*\* Alert Component \*/
export declare class ElAlert extends ElementUIComponent {
/\*\* Title \*/
title: string
/\*\* Component type \*/
type: AlertType
/\*\* Descriptive text. Can also be passed with the default slot \*/
description: string
/\*\* If closable or not \*/
closable: boolean
/\*\* whether to center the text \*/
center: boolean
/\*\* Customized close button text \*/
closeText: string
/\*\* If a type icon is displayed \*/
showIcon: boolean
/\*\* Choose effect \*/
effect: AlertEffect
}
组件声明类都继承了ElementUIComponent类,
ElementUIComponent 类继承了Vue, 所以每个组件都能通过ts编译时检查。
国际化
element组件库里面国际化,可以从两个角度看:1.文档站点国际化 2.组件支持国际化
文档站点国际化
文档站点的目录在example目录下,对应package.json的script脚本
"i18n": "node build/bin/i18n.js",
看下build/bin/i18n.js
'use strict';
var fs = require('fs');
var path = require('path');
var langConfig = require('../../examples/i18n/page.json');
langConfig.forEach(lang => {
try {
fs.statSync(path.resolve(__dirname, `../../examples/pages/${ lang.lang }`));
} catch (e) {
fs.mkdirSync(path.resolve(__dirname, `../../examples/pages/${ lang.lang }`));
}
Object.keys(lang.pages).forEach(page => {
var templatePath = path.resolve(__dirname, `../../examples/pages/template/${ page }.tpl`);
var outputPath = path.resolve(__dirname, `../../examples/pages/${ lang.lang }/${ page }.vue`);
var content = fs.readFileSync(templatePath, 'utf8');
var pairs = lang.pages[page];
Object.keys(pairs).forEach(key => {
content = content.replace(new RegExp(`<%=\\s*${ key }\\s*>`, 'g'), pairs[key]);
});
fs.writeFileSync(outputPath, content);
});
});
读取examples/i18n/page.json 里面的数组内容,可以看到里面主要有四种语言,每个语言对象里面用序号表示不用语言的文案。详细内容点这里
[
{
"lang": "zh-CN",
"pages": {
"index": {
"1": "网站快速成型工具",
"2": "Element,一套为开发者、设计师和产品经理准备的基于 Vue 2.0 的桌面端组件库",
"3": "指南",
"lang": "zh-CN",
"titleSize": "34",
"paraSize": "18"
},
...
}
},
{
"lang": "en-US",
...
},
{
"lang": "es",
...
},
{
"lang": "fr-FR",
...
}
]
接着,通过langConfig.forEach 遍历不同语言对象,再用Object.keys(lang.pages).forEach()变量不同语言对象里面包含的页面, 每个页面对应一个xxx.tpl文件在examples/pages/template/
目录下面。
最后通过repace 加 RegExp正则替换页面中的文案,替换examples/pages/template/
内容中的key
,生成不同语言的vue文件。最终产物就是
文档路径加到路由配置中,文档站点不同path,对应不同语言页面
https://element.eleme.cn/#/en-US/guide/design 英语
https://element.eleme.cn/#/zh-CN/guide/design 中文
添加路由配置的文件在 examples/route.config.js
文件中生成,这里不详细介绍,主要遍历examples/nav.config.json
中配置的路由对象。
组件支持国际化
语言包目录
不同语言包放在src/locale/lang/
目录下点这里,比如中文包
// Zh-CN.js
export default {
el: {
colorpicker: {
confirm: '确定',
clear: '清空'
},
datepicker: {
now: '此刻',
today: '今天',
cancel: '取消',
clear: '清空',
...
使用语言包
在src/local/index.js
中
import defaultLang from 'element-ui/src/locale/lang/zh-CN';
import Vue from 'vue';
import deepmerge from 'deepmerge';
import Format from './format';
const format = Format(Vue);
let lang = defaultLang;
let merged = false;
// 兼容Vue I18n,优先使用Vue I18n提供的$t方法,不存在则使用内置语言包
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 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 '';
};
// 接受参数,改变默认语言
// 设置语言 locale.use(lang)
export const use = function(l) {
lang = l || lang;
};
export const i18n = function(fn) {
i18nHandler = fn || i18nHandler;
};
export default { use, t, i18n };
通过t函数解析语言包,可能有两种情况:
- 文案是字符串文本
比如中文包中el.table.emptyText: 暂无数据
, 直接一层一层遍历对象,找到最后的value就行 - 文案中有变量
中文中el.pagination.total : 共 {total} 条
,通过传入options,替换total值
在pagination组件中执行this.t('el.pagination.total', { total: this.$parent.total })
在组件中,引入examples/mixins/locale.js
里面封装的mixins,来使用
import { t } from 'element-ui/src/locale';
export default {
methods: {
t(...args) {
return t.apply(this, args);
}
}
};
最后只要在组件里面引入locale mixin, 就可以通过t方法展示不同语言文案,
一行命令新建组件
可以在 Linux(unix )环境下使用GNU 的make工具,项目最外层定义了Makefile文件,可以看成一些命令的别名
新建组件对应命名
new:
node build/bin/new.js $(filter-out $@,$(MAKECMDGOALS))
新建一个button按钮,可以用make工具
make new button 按钮
执行的js文件 build/bin/new.js,里面基本是fs, file-save 文件操作,及字符串拼接。
比如新建组件,需要在packages/theme-chalk/src/index.css中添加引入
// 添加到 index.scss
const sassPath = path.join(\_\_dirname, '../../packages/theme-chalk/src/index.scss');
const sassImportText = \`${fs.readFileSync(sassPath)}@import "./${componentname}.scss";\`;
fileSave(sassPath)
.write(sassImportText, 'utf8')
.end('\\n');
通过fs.readFileSync 同步读取index.css文件内容,再用拼接字符串的形式,把新建组件的scss文件引入加到index.scss后面,最后使用fileSave 将数据保存到文件里面。
最后
回到开头看看列出的疑问,基本都能解释通了。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。