3


图片来自element

为什么写这篇文章

可能有人跟我一样,用了这么久elementUI组件库,还不清楚组件库是怎么搭建起来的,
或者我们也想做一个类似的ui组件库,实现基本功能,不知道怎么下手。

所以准备从学习element项目结构开始,写这篇文章主要记录自己对项目的理解。

从文章中能学到什么

不会分析每个组件的源码实现,
而是分析了element的项目架构。

不会分析每个文件的作用及package.json中所有script脚本意义,
而是从构建ui组件库可能遇到的问题出发,去看项目结构。

所以看完文章,希望可以解决以下疑问:

  1. 如何约定项目结构
  2. 如何实现组件按需引入
  3. 如何实现主题定制
  4. 如何实现文档站点和组件示例
  5. 支持国际化
  6. 支持typescript
  7. 如何搭建组件模板

目录结构

.
├── 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;

省略了配置中的loaderplugins,主要看这里的入口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 解析

  1. 约定的文档格式
    必须在::: demo 中写演示的例子,因为::: 属于Markdown 中的拓展语法,通过它来自定义容器。
  2. 利用markdown-it识别文档
    markdown-it可以把普通的 Markdown 文本转换成 HTML
  3. 提取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文件。最终产物就是
image.png

文档路径加到路由配置中,文档站点不同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函数解析语言包,可能有两种情况:

  1. 文案是字符串文本
    比如中文包中 el.table.emptyText: 暂无数据, 直接一层一层遍历对象,找到最后的value就行
  2. 文案中有变量
    中文中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 将数据保存到文件里面。

最后

回到开头看看列出的疑问,基本都能解释通了。


不可能的是
2.3k 声望123 粉丝

stay hungry&&foolish


« 上一篇
Chrome extensions