最近一直在用vue来开发页面,使用的ui框架是饿了么的前端框架Element-ui,个人还在一直学习的阶段。用了那么久的Element,但是它的内部到底是怎么实现的?I know nothing…Jhon S…咳咳,我也不知道它是怎么实现的。

现如今心血来潮,想分析一下Element的源码,也能进一步了解vue的使用。于是挑选了比较常用的表单插件 el-form 来进行分析。

组件的使用

先来看一段Element官网的demo:

1
2
3
4
5
6
7
8
9
<el-form :model="numberValidateForm" ref="numberValidateForm" label-width="100px" class="demo-ruleForm">
<el-form-item label="年龄" prop="age">
<el-input type="age" v-model.number="numberValidateForm.age" auto-complete="off"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('numberValidateForm')">提交</el-button>
<el-button @click="resetForm('numberValidateForm')">重置</el-button>
</el-form-item>
</el-form>

我们使用el-form的时候,表单的每一项我们都要使用到el-form-item,然后el-form-item里面再用到各个类型的输入框。

如果我们是第一次使用vue,以前用的都是传统的html、jsp的页面,看到这里可能有点疑惑:el-form?el-form-item?这些是什么标签?并不是我们常见的html标签啊……

其实这都是Element的组件,Element已经把这些组件声明为全局组件,vue使用组件的时候可以直接使用。

我们可以根据组件的name属性,来使用同名的标签,表明我们使用的是这个特定的组件。使用组件的时候有一个有趣的地方,组件的name属性我们一般用驼峰命名,但是我们使用标签的时候,要用连接线的方式才行。比如el-form标签,其name属性为:

1
name: 'ElForm'

使用slot来嵌套组件

为了让组件可以组合,我们需要一种方式来混合父组件的内容与子组件自己的模板。这个过程被称为内容分发 (即 Angular 用户熟知的“transclusion”)。Vue.js 实现了一个内容分发 API,参照了当前 Web Components 规范草案,使用特殊的 <slot> 元素作为原始内容的插槽。

实际上,el-form里面会嵌套着el-form-item,用到的正是vue的slot功能。slot是用来分发内容的,就是可以把东西放入到el-form组件里面。

单个插槽

1
2
3
4
5
6
7
8
<template>
<form class="el-form" :class="[
labelPosition ? 'el-form--label-' + labelPosition : '',
{ 'el-form--inline': inline }
]">
<slot></slot>
</form>
</template>

我们来看看el-form的源码中的html代码部分,比较简单,form标签里面只有一个slot插槽。我们把el-form-item放在el-form标签里面的时候,el-form-item就会被插入到slot的位置。

这就是使用slot的方法:你把其他的html元素放入到该组件的标签里面,这些html元素就会被插入到组件的slot标签的位置。

所以我们平时能像html一样使用这些组件的标签,就是使用了slot。

具名插槽

除了单个插槽,还有具名插槽。顾名思义,就是有名字的插槽,插槽有一个name属性。比如el-form-item里面用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div class="el-form-item" :class="{
'is-error': validateState === 'error',
'is-validating': validateState === 'validating',
'is-required': isRequired || required
}">
<label :for="prop" class="el-form-item__label" v-bind:style="labelStyle" v-if="label || $slots.label">
<slot name="label">{{label + form.labelSuffix}} </slot>
</label>
<div class="el-form-item__content" v-bind:style="contentStyle">
<slot></slot>
<transition name="el-zoom-in-top">
<div class="el-form-item__error" v-if="validateState === 'error' && showMessage && form.showMessage">{{validateMessage}}</div>
</transition>
</div>
</div>

可以看到,label标签下使用了具名slot.在父组件调用的时候,可以在label下的某个标签附上属性slot=”label”,则会被插入到这个具名slot的位置。一般我们不需要自定义标签,所以不会用到这个具名slot.这时会使用这个slot的默认内容NaN。(slot里面的内容就是备用内容,如果用户没有放别的东西进来,就默认显示备用内容)

例如:

1
<h1 slot="label">这里可能是一个页面标题</h1>

transition过渡

顺带一提,这里还用到了transition标签,transition标签是VUE提供的过渡效果的标签。VUE会根据name属性去查找CSS。当插入或删除包含在 transition 组件中的元素时,Vue 将会做以下处理:

自动嗅探目标元素是否使用了 CSS 过渡或动画,如果使用,会在合适的时机添加/移除CSS 过渡 class。

如果过渡组件设置了 JavaScript 钩子函数,这些钩子函数将在合适的时机调用。

如果没有检测到 CSS 过渡/动画,并且也没有设置 JavaScript钩子函数,插入和/或删除 DOM 的操作会在下一帧中立即执行。(注意:这里的帧是指浏览器逐帧动画机制,和 Vue 的 nextTick 概念不同)

el-form-item获取el-form里的表单对象

el-form-item和el-form既然是2个不同的插件,我们把表单对象绑定到el-form的model属性的时候,el-form-item是怎么获取到的呢?

我在el-form-item源码里发现了计算属性form:

1
2
3
4
5
6
7
8
9
10
11
12
form() {
let parent = this.$parent;
let parentName = parent.$options.componentName;
while (parentName !== 'ElForm') {
if (parentName === 'ElFormItem') {
this.isNested = true;
}
parent = parent.$parent;
parentName = parent.$options.componentName;
}
return parent;
}

el-form-item通过$parent获取el-form实例,这里并不只是获取el-form那么简单,如果item是嵌套在另一个item里面的,还要继续往上获取form,直到获取到form。

接下来,根据el-form-item的prop属性(开发者用于绑定表单对象中的字段的属性)来获取model中的值:

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
35
36
37
fieldValue: {
cache: false,
get() {
var model = this.form.model;
if (!model || !this.prop) { return; }

var path = this.prop;
if (path.indexOf(':') !== -1) {
path = path.replace(/:/, '.');
}

return getPropByPath(model, path).v;
}
},

function getPropByPath(obj, path) {
let tempObj = obj;
path = path.replace(/\[(\w+)\]/g, '.$1');
path = path.replace(/^\./, '');

let keyArr = path.split('.');
let i = 0;

for (let len = keyArr.length; i < len - 1; ++i) {
let key = keyArr[i];
if (key in tempObj) {
tempObj = tempObj[key];
} else {
throw new Error('please transfer a valid prop path to form item!');
}
}
return {
o: tempObj,
k: keyArr[i],
v: tempObj[keyArr[i]]
};
}

小伙伴们看一下代码就明白了,根据prop的key,获取到model中对应的值。

属性的默认值

1
2
3
4
showMessage: {
type: Boolean,
default: true
}

使用default来赋予默认值,type指定属性的种类,可以有多个种类,用[Boolean,String]的格式。

图标的设置

平时我们可以看到一些按钮上可能有一些图标,比如这篇文章的标题下面,时间、分类的前面都有个小图标,这可不是插入了一张小图片,而是用了矢量图标,这实际上是通过设置class属性来实现的。

我们来看看常用的el-input,也就是我们的输入框组件,它有一个icon属性,我们可以根据 Element图标 来设置icon属性。官网的原文是这么说的:

直接通过设置类名为 el-icon-iconName 来使用即可。

它都这么说了,我就乖乖地把icon=”el-icon-el-icon-search”,想设置一个搜索图标。惊喜地发现了&¥#@&……¥¥&#根本不管用啊!!

下面我们看看它的代码实现:

1
2
3
4
5
6
7
8
9
10
<slot name="icon">
<i class="el-input__icon"
:class="[
'el-icon-' + icon,
onIconClick ? 'is-clickable' : ''
]"
v-if="icon"
@click="handleIconClick">
</i>
</slot>

可以发现,只要根据官网的图标的名字,把 el-icon- 后面的名字作为icon属性就行了,根本不是官网说的那样。你自己做了处理还不说清楚……

$slots获取slot内容

1
2
3
4
<!-- 前置元素 -->
<div class="el-input-group__prepend" v-if="$slots.prepend">
<slot name="prepend"></slot>
</div>

这里用到了$slots,可以通过$slots.具名slot的name来获取这个slot的内容,来判断是否有前置元素的存在。

el-input里面的input标签

1
2
3
4
5
6
7
8
9
10
11
<input
v-if="type !== 'textarea'"
class="el-input__inner"
v-bind="$props"
:autocomplete="autoComplete"
:value="currentValue"
ref="input"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
>

上面这段代码是el-input里面的input标签,想想也是,el-input只是在原生的input标签上封装了一层而已。

这里有3个事件input,focus,blur,这里Element没有做过多的处理,都是用$emit来触发当前实例的事件(我理解为el-input组件的上层所定义的事件,@click之类的,交给用户自己来决定怎么处理),比如$emit(‘input’, value)可以把el-input组件的值传回我们的实例v-model绑定的值,来实现组件之间的值传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
handleFocus(event) {
this.$emit('focus', event);
},
handleInput(event) {
const value = event.target.value;
this.$emit('input', value);
this.setCurrentValue(value);
this.$emit('change', value);
},
handleIconClick(event) {
if (this.onIconClick) {
this.onIconClick(event);
}
this.$emit('click', event);
},

表单验证

我们可以看官网的例子:点我点我点我看例子

Form 组件提供了表单验证的功能,只需要通过 rule 属性传入约定的验证规则,并 Form-Item 的 prop属性设置为需校验的字段名即可。

但是本质上他们是怎么实现表单验证的呢?

在el-form里的mounted钩子,获取rules.可以看出同时获取了form的rules和form-item里面的rules。

1
2
3
4
5
let rules = this.getRules();
if (rules.length) {
this.$on('el.form.blur', this.onFieldBlur);
this.$on('el.form.change', this.onFieldChange);
}
1
2
3
4
5
6
7
8
getRules() {
var formRules = this.form.rules;
var selfRules = this.rules;

formRules = formRules ? formRules[this.prop] : [];

return [].concat(selfRules || formRules || []);
},

然后开始监听blur和change事件。这2个事件在el-input里我们也看到了,在发生input的时候,el-input就会触发这2个事件。

1
2
3
4
5
6
7
8
9
10
onFieldBlur() {
this.validate('blur');
},
onFieldChange() {
if (this.validateDisabled) {
this.validateDisabled = false;
return;
}
this.validate('change');
}

可以看到,实际上进行校验的实际上是validate这个函数。

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
import AsyncValidator from 'async-validator';

validate(trigger, callback = noop) {
var rules = this.getFilteredRule(trigger);
if (!rules || rules.length === 0) {
callback();
return true;
}

this.validateState = 'validating';

var descriptor = {};
descriptor[this.prop] = rules;

var validator = new AsyncValidator(descriptor);
var model = {};

model[this.prop] = this.fieldValue;

validator.validate(model, { firstFields: true }, (errors, fields) => {
this.validateState = !errors ? 'success' : 'error';
this.validateMessage = errors ? errors[0].message : '';

callback(this.validateMessage);
});
},

可以看出Element也是用了async-validator库来做的验证,所以配置的rules的时候根据async-validator的规则来配置即可。