技术铺垫
本节内容以技术铺垫为主,如果你对这里的内容非常自信可以跳到下一节,当然也可以帮我找找错😂
必要性
随着Vue在国内市场的占有率越来越高,组件库同时也火了起来,很多公司内部也为了适应其自身业务而开始开发其自有的组件库,我们现在使用的Element UI(饿了么)、Nut UI(京东)等组件库都是大厂开源出来的组件库。与此同时,拥有组件库开发经验也已经成为了我们在应聘面试中的加分项。
组件库中的组件开发其实给跟我们平常开发组件本质是相同的,只不过我们需要尽可能地考虑到组件的复用性,使组件可以面对多种情况下的业务需求,而且组件库不能和业务耦合,应该和业务完全脱离,即使切换另一种业务也可以使用。
基础API
组件的开发脱离不开prop、slot和event这三个API,此外还要考虑组件之间的通信,比如form和form-item、checkbox和checkbox-group等,他们之间其实是有联系的
prop
prop在我们平常的开发任务中也是经常使用的一个API,用于子组件接受父组件传递的参数,尽管日常使用,但是还是有人在规范性等方面用得并不是很好。
抛弃数组写法:虽然支持数组写法,但是官方都不推荐的数组写方法就扔掉吧
// 摒弃吧
// props: ['status']
// 至少也这样吧
props: {
status: String
// 等同于
// status: {
// type: String
// }
}
键值的写法只是提供了类型限制,除此之外,prop还有其他的属性,比如必填项以及内容验证等;validator的值是一个函数,可以将当前prop接受的值传递给函数,返回值如果是一个失败类型的值就会在控制台抛出异常
下面的验证规则限制了status必须是’success’, ‘warning’, ‘error’中的一个
props: {
status: {
type: String,
validator: function(prop) {
return [
'success',
'warning',
'error'
].indexOf(prop) >= 0
},
default: 'success',
required: true,
// 一般默认值不会和必填项同时出现
}
}
然后我在使用组件时传入一个错误的值
<message status="warning1" />
此外还有一点,子组件不能直接修改接收的prop值
slot
我理解的slot作用就是在子组件中插入父组件的内容,子组件在定义时提供一个插槽,父组件在使用子组件时可以向插槽中插入内容。相当于插座和插头,下面这张图类似于具名插槽,父组件使用的插槽名和子组件中的插槽名不匹配,就无法使用
普通的插槽很简单,在子组件中声明插槽,父组件直接添加内容即可,插槽可以设置默认内容,当父组件没有内容传进来时,就会使用默认内容,类似于prop的default属性
<!-- 父组件 -->
<message>
我是父组件的内容
</message>
<!-- 子组件 -->
<div>
接受插槽:
<slot>后备内容</slot>
</div>
在上面我们提到了具名插槽,其实就是为了有多个插槽内容时区分内容。使用方法也很简单,在子组件声明slot时添加name属性即可,父组件在使用时添加v-slot:[name]属性(原来是slot=”name”,已废弃但没有删除,现阶段这样用也可以)
<!-- 父组件 -->
<message status="warning">
<template v-slot:header>我是父组件的头部内容</template>
<template v-slot:footer>我是父组件的底部内容</template>
</message>
<!-- 子组件 -->
<div>
头部插槽:
<slot name="header"></slot>
<br />
底部插槽:
<slot name="footer"></slot>
</div>
不添加name属性时,默认会赋值name=”default”,父组件在使用时不添加v-slot属性默认就是v-slot:default,所以普通插槽也相当于具名插槽,只是默认名。下面两种用法是一样的
<message status="warning">
<template v-slot:default>我是父组件的内容</template>
</message>
<message status="warning">
<template>我是父组件的内容</template>
</message>
还有一种作用域插槽,用简单的话来说就是,在父组件中使用子组件的数据,这里的作用域也就是指的父组件接收子组件作用域
子组件在声明插槽时绑定一个变量,父组件在使用插槽时使用v-slot=”props”接受这个变量(原来是slot-scope,同样已废弃),然后使用变量我们继续使用上面的例子稍加改造
<!-- 父组件 -->
<message status="warning">
<template v-slot:header="sonData">{{sonData.user.name}}今年{{sonData.user.age}}岁了</template>
<template v-slot:footer>我是父组件的底部内容</template>
</message>
<!-- 子组件 -->
<div>
头部插槽:
<slot name="header" :user="user"></slot>
<br />
底部插槽:
<slot name="footer"></slot>
</div>
v-slot指令可以缩写为
#
,比如v-slot:header可以缩写为#header,默认插槽不可以简写,但是你可以使用#default
event
事件这一块内容虽然很简单,但说起来比较琐碎,看文档吧😂
此外你还需要了解组件之间的通信,组件之间的通讯大致有ABCD这几种情况(大体列一下,不展开细说了)
AB之间父子关系,AC和AD时祖先和后代的关系,CD之间是兄弟关系
- props/$emit,用于父子之间通信
- $on/$emit,可以在任意组件之间通信
- $children/$parent,可用于父子组件
- $ref,通过标记来获取组件实例
- $attrs/$listeners,attrs包含了父作用域中不被 prop 所识别的属性,listeners包含了父作用域中的 (不含 .native 修饰器的) 事件监听器
- provide/inject,可以在祖孙之间传递数据
实战一,Form组件
上一节我们为组件开发做了技术铺垫,这一节开始我们来进入实战环节
首先我们来实现一套Form组件,如果你使用过市面上比较流行的组件库,你应该知道一套Form组件应该包含这样的内容
那么我们首先下一个用例,从用例出发,倒推组件
<f-form :model="userForm" :rules="rules">
<f-form-item label="用户名" prop="username">
<f-input v-model="userForm.username" placeholder="请输入用户名" />
</f-form-item>
<f-form-item label="密码" prop="password">
<f-input type="password" v-model="userForm.password" placeholder="请输入密码" />
</f-form-item>
</f-form>
<script>
export default {
data() {
return {
userForm: {
username: '',
password: ''
},
rules: {
username: [
{required: true, message: '请输入用户名'}
],
password: [
{required: true, message: '请输入密码'}
]
}
}
}
}
</script>
从内而外实现,从input开始,然后form-item,最后form,话不多说开搞
f-input
首先来输入框,你可能会觉着这东西很简单,但是不要忘了我们之前说的,子组件不能直接修改prop接收的值,所以赶紧抛弃你直接v-model绑定prop的想法。
如果你不关闭警告你会看到这样的警告
这里你需要知道,其实v-model是一个语法糖,下面这两行其实作用是一样的
1<input v-model="username" />
<br/>
2<input :value="username" @input="username = $event.target.value" />
<br/>
{{username}}
运行一下看下结果
所以在f-input中你可以这样来实现绑定的传递
<template>
<div>
<input :value="value" @input="inputHandler" />
</div>
</template>
<script>
export default {
props: {
value: {
type: String,
default: ''
}
},
methods: {
inputHandler(e) {
this.$emit('input', e.target.value)
}
}
}
</script>
这时候再去试试,发现已经可以了,控制台也没有报警告
然后你又发现了一个问题,设置的placeholder
没有生效,因为他被添加到f-input上,而不是f-input里面的input上
官网API文档有这么一段
它的作用总结下来就是,如果开启此选项,父组件绑定的属性没有被props接收的,将会存在$attrs里面,可以使用v-bind将$attrs绑定在任意非根组件上,然后把我们的f-input稍加改造
<template>
<div>
<input :value="value" @input="inputHandler" v-bind="$attrs" />
</div>
</template>
<script>
export default {
inheritAttrs: true,
// 省略部分代码
</script>
完美,可以看到设置的密码型输入框也已经生效了
f-form-item
到这里就比上面的输入框稍微有点难度了,但也很简单,这里需要实现的点是
- label文本
- 给表单组件预留插槽
- 按规则校验并提示错误
前两点都很简单,你应该比较畏惧第三点,其实这个也很简单,有现成的“轮子”——async-validator,作为程序员要杜绝重复造轮子😂
想一下我们之前的用例中form-item的属性:label和prop,然后配置要接收的参数
<template>
<div>
<label v-if="label">{{label}}</label>
<slot></slot>
<p v-if="errorMessage">{{errorMessage}}</p>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
default: ''
},
prop: {
type: String,
}
},
data() {
return {
errorMessage: ''
}
},
}
</script>
这样我们第一步和第二步都完成了,接下来就是validate了,接受的prop的作用就是为了从form组件中绑定的rules中获取需要使用的验证规则,同时也使用prop从form绑定的表单内容中获取当前表单项的值。这里可以使用provide/inject来传递数据
<script>
import Schema from 'async-validator'
export default {
inject: ['form'],
props: {
label: {
type: String,
default: ''
},
prop: {
type: String,
}
},
data() {
return {
errorMessage: ''
}
},
methods: {
validate() {
const rule = this.form.rules[this.prop]
const value = this.form.model[this.prop]
// 获取验证规则实例
const description = {[this.prop]: rule}
const schema = new Schema(description)
// 使用验证规则实例的验证方法
return schema.validate({[this.prop]: value}, (error, field) => {
if(error) {
this.errorMessage = error[0].message
console.log(`${field}验证未通过`);
} else {
this.errorMessage = ''
}
})
}
},
}
</script>
然后添加验证事件的监听器,在内部表单项值修改时动态验证
mounted() {
this.$on('validate', this.validate)
}
再顺便修改一下f-input的内容
methods: {
inputHandler(e) {
this.$emit('input', e.target.value)
// 通知验证
this.$parent.$emit('validate')
}
}
OK~~form-item齐活
f-form
在完成了form-item和input组件之后,form组件就简简单单了,再来看一下form要做的事
- 接收验证规则和表单数据
- 给form-item预留插槽
- 全局校验方法
首先我们来实现前两步,很简单,不要忘了之前form-item使用inject接收的数据,这里需要绑定上
<template>
<div>
<slot></slot>
</div>
</template>
<script>
export default {
provide() {
return {
form: this
}
},
props: {
model: {
type: Object,
required: true
},
rules: {
type: Object
}
}
}
</script>
接下来全局的校验,接受一个回调函数,回调函数的参数是一个布尔值,代表校验是否通过,使用Promise.all来执行多个验证器,只要有失败的就返回false
methods: {
validate(cb) {
const validators = this.$children
.filter(item => item.prop)
.map(item => item.validate())
Promise.all(validators)
.then(() => cb(true))
.catch(() => cb(false))
}
},
然后在修改一下用例,添加一个提交按钮,绑定全局校验
<template>
<div>
<f-form :model="userForm" :rules="rules" ref="formRef">
<f-form-item label="用户名" prop="username">
<f-input v-model="userForm.username" placeholder="请输入用户名" />
</f-form-item>
<f-form-item label="密码" prop="password">
<f-input type="password" v-model="userForm.password" placeholder="请输入密码" />
</f-form-item>
<button @click="submit">提交</button>
</f-form>
</div>
</template>
<script>
// 省略部分代码
methods: {
submit() {
this.$refs.formRef.validate(valid => {
if(valid) {
console.log('通过验证');
} else {
console.log('验证失败');
}
})
}
},
}
</script>
到现在我们就已经粗略的完成了一套form组件,当然他的功能并不止于此,这只是起了个头具体功能完善也就不在这里细说了,效果展示如下,样式细节也不在这里扣了
实战二,Message组件
上一节我们实现了写在模板里面的组件,还有一种组件比如message消息提示,它的使用要求比较灵活,不确定在什么组件中出现,所以不能写死在模板中,要使用js动态控制
这种组件就需要手动地实现挂载与卸载了,在开始之前需要做一下铺垫Vue.extend(options),创建一个子类包含配置的选项
首先我们来构建一个消息的模板
<template>
<div class="message">
<p>{{ message }}</p>
</div>
</template>
<script>
export default {
name: "message",
data() {
return {
message: "message",
};
},
</script>
然后再创建一个Message.js,用于渲染这个模板
import Vue from 'vue';
import Message from './Message.vue'
export default function() {
const MessageConstructor = Vue.extend(Message)
const component = new MessageConstructor().$mount()
document.body.appendChild(component.$el)
}
上面的最后一段代码,我们调用了 $mount
方法对组件进行了手动渲染,这里渲染之后的结果是一个Node节点,我们需要手动将它插入到document中
使用组件也很简单,导入并执行这个函数。我们来使用一个按钮触发这个消息渲染
<template>
<div>
<button @click="alert">消息</button>
</div>
</template>
<script>
import Message from './Message.js'
export default {
methods: {
alert() {
Message()
}
}
}
</script>
来看下效果
还行,我们来继续完善一下,首先需要自动删除节点,我们要在出现提示之后一定时间内取消展示,思路也很简单,在mounted之后添加一个延时卸载Node节点的方法
mounted() {
setTimeout(() => {
// duration 后通过父级移除子元素的方式移除该组件实例和DOM节点
this.$destroy(true);
this.$el.parentNode.removeChild(this.$el);
}, this.duration);
},
现在我们来考虑一下通过配置控制消息内容
- 调用组件时传入一个配置对象
- 在创建渲染实例时将参数传递进去,覆盖掉继承来的属性
对我们的Message.js稍加改造,让参数覆盖data选项
export default function(options) {
const MessageConstructor = Vue.extend(Message)
const component = new MessageConstructor({data: options}).$mount()
document.body.appendChild(component.$el)
}
然后我们来使用一下,配合我们之前的表单校验一起试一下
<template>
<div id="app">
<f-form :model="userForm" :rules="rules" ref="formRef">
<f-form-item label="用户名" prop="username">
<f-input v-model="userForm.username" placeholder="请输入用户名" />
</f-form-item>
<f-form-item label="密码" prop="password">
<f-input type="password" v-model="userForm.password" placeholder="请输入密码" />
</f-form-item>
<button @click="submit">提交</button>
</f-form>
</div>
</template>
<script>
import FFormItem from './components/FFormItem.vue'
import FInput from './components/FInput.vue'
import FForm from './components/FForm.vue'
import Message from './components/Message'
export default {
name: 'App',
components: {
FFormItem,FInput,FForm
},
data() {
return {
userForm: {
username: '',
password: ''
},
rules: {
username: [
{required: true, message: '请输入用户名', tigger: 'blur'}
],
password: [
{required: true, message: '请输入密码', tigger: 'blur'}
]
},
username: ''
}
},
methods: {
submit() {
this.$refs.formRef.validate(valid => {
if(valid) {
Message({
message: '通过验证',
duration: 5000
})
} else {
Message({
message: '验证失败',
duration: 2000
})
}
})
},
},
}
</script>
可以看到时间、内容都已经生效了
然后为了使用方便我们可以在入口文件将Message挂载到Vue的原型上Vue.prototype.$message = Message
,,然后其他组件内部再使用时就可以直接this.$message()
这里我们只实现了一个简单的message,他还可以有很多扩展方向,比如通知类型(根据类型控制message颜色样式不一样)或者内嵌icon等,我们在这就不说了,有了这个结构再去拓展就很简单了
实战三,树形组件和动态组件
之前我们介绍了两种类型的组件封装,现在我们来说一下另外一种组件,递归组件。这种组件可用于列表展开、树形表格等
递归组件
我们在使用组件时一般都是先使用import导入,然后在components选项中声明,最后在组件中使用;但其实还有一种使用组件的方法,当组件声明了选项name之后就可以使用name调用自身了,这也是递归组件的原理
但是不可以直接使用,需要有一个限制条件,不然就会调用栈溢出了,比如下面这样
我们来实现一个简易的树形组件,用来展开和闭合列表
首先我们来创建一个模板
<template>
<li>
<div @click="toggle">
{{model.title}}
<span v-if="isFolder">[{{open ? '-' : '+'}}]</span>
</div>
</li>
</template>
这里只是做示范,在使用外层需要有个ul包裹,当有子组件时会显示+
和-
,展开式显示-
,关闭时显示+
,isFolder就是一个计算属性,用来判断有没有子组件,通过事件来控制open的开闭
export default {
name: "Tree",
props: {
model: {
type: Object,
required: true,
},
},
data() {
return {
open: false
};
},
computed: {
isFolder() {
return this.model.children && this.model.children.length;
}
},
methods: {
toggle() {
if (this.isFolder) {
this.open = !this.open;
}
},
},
};
我们来添加一下数据来试一下效果
<template>
<ul>
<tree v-for="(model, index) in treeData" :key="index" :model="model"></tree>
</ul>
</template>
<script>
export default {
data() {
return {
treeData: [
{
title: '打野',
children: [
{
title: '刺客',
children: [
{
title: '兰陵王'
},{
title: '李白'
}
]
// ……省略大段数据代码
}
}
}
</script>
现在的效果是这样的
接下来我们就来注入灵魂,实现组件递归,将tree组件做一下修改
<template>
<li>
<div @click="toggle">
{{model.title}}
<span v-if="isFolder">[{{open ? '-' : '+'}}]</span>
</div>
<ul v-show="open" v-if="isFolder">
<tree class="item" v-for="model in model.children" :model="model" :key="model.title"></tree>
</ul>
</li>
</template>
然后再看效果
完成,当然这只是粗略版本,这样拿出去肯定是用不了的,还需要拓展功能调整样式