在项目里经常使用element-ui,有点好奇一个工业级的组件库怎么实现的。

首先

从github clone element-ui的源码

1
git clone https://github.com/ElemeFE/element.git

element-ui的源码结构如下:

1
2
3
4
5
CHANGELOG.en-US.md LICENSE            element_logo.svg   src
CHANGELOG.es.md Makefile examples test
CHANGELOG.fr-FR.md README.md node_modules types
CHANGELOG.zh-CN.md build package.json yarn.lock
FAQ.md components.json packages

​ 组件的源码在packages目录下,使用make dev可以运行examples目录下的样例代码,通过http://127.0.0.1:8085/来访问。在examples引用了packages里面的组件,这样修改里面的组件立马可以看到效果。scss文件在packages/theme-chalk中。

Element-UI BEM命名

​ 在element-ui中css的命名方式使用BEM(block、element、modified)方式。将所有东西划分为独立的模块,block承载了element,modified表达状态。在BEM中,使用双下划线连接Block和Element,比如menu__item。使用单下划线连接Block和Modified或者连接Element和Modified,比如menu_active。当这个组件需要多个单词来描述时,就用-中划线来连接多个单词,比如start-menu。

​ 在theme-chalk/src/mixins/mixin.scss中定义了针对BEM格式的mixin。

  • 对于block,传入名称后被替换成el-名称

    1
    2
    3
    4
    5
    6
    7
    @mixin b($block) {
    $B: $namespace+'-'+$block !global;

    .#{$B} {
    @content;
    }
    }
  • 对于element,将类名改为.el-row__unit@content表示之后@include里面包括的属性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    @mixin e($element) {
    $E: $element !global;
    $selector: &;
    $currentSelector: "";
    @each $unit in $element {
    $currentSelector: #{$currentSelector + "." + $B + $element-separator + $unit + ","};
    }

    @if hitAllSpecialNestRule($selector) {
    @at-root {
    #{$selector} {
    #{$currentSelector} {
    @content;
    }
    }
    }
    } @else {
    @at-root {
    #{$currentSelector} {
    @content;
    }
    }
    }
    }
  • 对于modifer,类名改为el-row—-flex,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @mixin m($modifier) {
    $selector: &;
    $currentSelector: "";
    @each $unit in $modifier {
    $currentSelector: #{$currentSelector + & + $modifier-separator + $unit + ","};
    }

    @at-root {
    #{$currentSelector} {
    @content;
    }
    }
    }

Layout布局组件

layout是由ElRow、ElCol组成。

El-Row

El-Row有5个属性,默认是一个div块,通过tag来改变由什么实现。

gutter:设置列之间的间隔。配置后,El-Col会读取父节点的gutter属性,也就是El-Col的gutter来设置与其他列之间padding。

type:设置display为flex布局。这样就可以设置Row中的元素按照什么形式进行对齐。

El-Col

El-Col配合El-Row实现栅栏布局,关键在于pacages/theme-chalk/src/col.scss中,将每一行分割为24等份。

默认使用float:left。当Row使用flex后变为弹性项目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@for $i from 0 through 24 {
.el-col-#{$i} {
width: (1 / 24 * $i * 100) * 1%;
}

.el-col-offset-#{$i} {
margin-left: (1 / 24 * $i * 100) * 1%;
}

.el-col-pull-#{$i} {
position: relative;
right: (1 / 24 * $i * 100) * 1%;
}

.el-col-push-#{$i} {
position: relative;
left: (1 / 24 * $i * 100) * 1%;
}
}

Container布局容器组件

布局容器组件包括:container、header、aside、main、footer。

  1. Container:使用flex布局,通过一个计算属性isVertical来修改子元素方向。内部使用<section></section>

  2. Header:内部使用<header></header>实现。表示section的页眉。

  3. Aside:内部使用<aside></aside>实现,表示侧边栏,存放相关资料、标签。

  4. Main:内部使用<main></main>实现

  5. Footer:内部使用<footer></footer>实现,表示section的页脚。

之所以这么实现,为了web语意化,也就是让正确的标签做正确的事情。便于浏览器搜索引擎解析,利于爬虫标记、利于SEO。

Icon图标组件

直接通过el-icon-iconName来使用。

组件代码在/packages/icon/src/中,其中icon是<i :class="'el-icon-'+name"></i>。关键在icon的scss文件。

这里使用的一种技术叫webfont,比如下面的编辑图标:

1
2
3
.el-icon-edit:before {
content: "\e78c";
}

在unicode中E000到F8FF属于用户造字区,elementui首先在icon.scss中通过@font-face下载elementui自己的字体,这个字体里有自定义的矢量图标。

1
2
3
4
5
6
7
@font-face {
font-family: 'element-icons';
src: url('#{$--font-path}/element-icons.woff') format('woff'), /* chrome, firefox */
url('#{$--font-path}/element-icons.ttf') format('truetype'); /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
font-weight: normal;
font-style: normal
}

然后所有的el-icon都会使用这个叫element-icons的字体。

1
2
3
4
5
[class^="el-icon-"], [class*=" el-icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'element-icons' !important;
}

这个字体文件中编号E78C是一个edit的图标。

Button按钮组件

El-button内部使用的是<button></button>。包含属性:

  1. type:通过type组合成class: el-button—primary来切换样式。
  2. size、loading、disabled、plain、autofocus、round、circle控制相关样式。

里面少见的是vue对象的inject属性:elForm、elFormItem。

elForm是由ElForm这个祖先组件提供,向所有后代提供表格这个组件的访问。而elFormItem是由ElFormItem提供。之所以提供这两个依赖是因为button常常用于表格中,所以需要统一大小。

  1. 可以看到对button的原生click事件捕获同时emit click event给el-button的父节点。这样在外部使用el-button时就不需要专门写@click.native修饰符,之所以这样因为v-on默认认为的click事件是自定义事件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<button
class="el-button"
@click="handleClick"
>
<span v-if="$slots.default"><slot></slot></span>
</button>
</template>
<script>
export default {
name: 'ElButton',

methods: {
handleClick(evt) {
this.$emit('click', evt);
}
}
};
</script>

Input组件

Input组件分为两种:一种是普通input、另一种是textarea。

Element UI Mixins

Element-ui通过mixins给input 组件添加了broadcast、dispatch两个函数,broadcast用于祖先组件向指定的子组件emit事件,dispatch用于子组件向祖先组件emit事件。

普通input组件

首先,从结构来说,input前后分别可以添加prepend、append,用于比如按钮和标签这样的需求。对于input中的内容,包含前置内容、后置内容.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<template>
<div

>
<template v-if="type !== 'textarea'">
<!-- 前置元素 -->
<div class="el-input-group__prepend" v-if="$slots.prepend">
<slot name="prepend"></slot>
</div>
<input> <!-- Input输入 -->
<!-- 前置内容 -->
<span class="el-input__prefix" v-if="$slots.prefix || prefixIcon">
<slot name="prefix"></slot>
<i class="el-input__icon"
v-if="prefixIcon"
:class="prefixIcon">
</i>
</span>
<!-- 后置内容 -->
<span
class="el-input__suffix">
<span class="el-input__suffix-inner">
<template v-if="!showClear || !showPwdVisible">
<slot name="suffix"></slot>
</template>
</span>
</span>
<!-- 后置元素 -->
<div class="el-input-group__append" v-if="$slots.append">
<slot name="append"></slot>
</div>
</template>
</div>
</template>

input会监听的事件有:

  1. input事件:每当输入的时候都会触发。

  2. change事件:当input失去焦点且内容改变时触发。

  3. blur事件:当input失去焦点时触发。

  4. composition事件:复合事件,当用手机输入法时,会显示选择框,选择框打开也就是compositionstart事件,选择好就是compositionend事件,因为在输入时也会触发input事件,所以应该等选择好输入的词后在进行handleInput()。

    1
    2
    3
    4
    5
    6
    7
    8
    handleInput(event) {
    if (this.isOnComposition) return; // 当composition事件完成时,再进行之后的逻辑
    this.$emit('input', event.target.value);
    this.$nextTick(() => {
    let input = this.getInput();
    input.value = this.value;
    });
    },

值验证:

与button一样,当input放在form中时,被注入ElForm和ElFormItem,并有一个watcher监听value的变化,每当发生变化便会dispatch到父节点ElForm,同时验证value合法。

input组件属性:

对于没有写入props的属性,input使用v-bind="$attrs"来绑定

Textarea组件

textarea最复杂的功能是根据内容增多使得组件的高度变大。通过watcher监听value的变化:

1
2
3
4
5
watch: {
value(val) {
this.$nextTick(this.resizeTextarea);
}
},

每次变化都会调用this.resizeTextarea,然后计算textarea在当前行高下的整体高度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default function calcTextareaHeight(
targetElement, minRows = 1, maxRows = null
) {
hiddenTextarea.setAttribute('style', `${contextStyle};${HIDDEN_STYLE}`);
hiddenTextarea.value = targetElement.value || targetElement.placeholder || '';

let height = hiddenTextarea.scrollHeight; // 获得的高度就是scrollHeight
const result = {};
let singleRowHeight = hiddenTextarea.scrollHeight - paddingSize;

if (minRows !== null) {
let minHeight = singleRowHeight * minRows;
height = Math.max(minHeight, height);
result.minHeight = `${ minHeight }px`;
}
if (maxRows !== null) {
let maxHeight = singleRowHeight * maxRows;
height = Math.min(maxHeight, height);
}
result.height = `${ height }px`;
return result;
};

计算过程:首先创建隐藏textarea,将内容放入其中,看看scrollHeight是多少,将textarea的高度设置为这个值。如果指定minRows、maxRows时,textarea的高度就是scrollHeight的高度

总结

感觉写一个组件库需要考虑好多细节,这个就很难,得有很多经验才行。