# 1、vue组件化实践
# 目录
- 组件化开发知识盘点
- 通用UI库设计思路
- 组件开发实践:表单
- 组件开发实践:弹窗类组件
- 组件开发实践:递归组件、树形菜单
- Element-UI源码学习思路
# 学习目标
- 深入理解Vue的组件化机制
- 掌握Vue组件化常用技术
- 能够设计并实现多种类型的组件
- 加深对一些vue原理的理解
- 学会看开源组件库源码
# 组件化
组件通信常用方式
- props
- $emit/$on
- event bus
- vuex
边界情况(不推荐使用)
- $parent
- $children
- $root
- $refs
- provide/inject
- 非prop特性
- $attrs
- $listeners
# 事件总线
任意两个组件之间传值常用事件总线 或 vuex的方式。eventBus:Vue.prototype.$bus = new Bus();
// Bus:事件派发、监听和回调管理
class Bus {
constructor() {
this.callbacks = {};
}
$on(name, fn) {
this.callbacks[name] = this.callbacks[name] || [];
this.callbacks[name].push(fn);
}
$emit(name, args) {
if (this.callbacks[name]) {
this.callbacks[name].forEach(cb => cb(args));
}
}
}
// main.js
Vue.prototype.$bus = new Bus();
// child1
this.$bus.$on("foo", handle);
// child2
this.$bus.$emit("foo");
# 非prop特性($attrs、$listeners)
官方解释:包含了父作用域中不作为 prop 被识别 (且获取) 的特性绑定 ( class 和 style 除外)。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 ( class 和 style 除外),并且可以通过 v-bind="$attrs" 传入内部组件 —— 在创建高级别的组件时非常有用。
即child中没有在props中声明的属性,child也能通过$attrs拿到:
// child:并未在props中声明foo
<p>{{$attrs.foo}}</p>
// parent
<HelloWorld foo="foo"/>
但其实,其最主要应用场景是「隔代传参
」:
<!-- child -->
<template>
<div>
<h3>child</h3>
<!-- $attrs -->
<p>{{$attrs.msg}}</p>
<!-- 隔代传参, v-bind会展开$attrs -->
<Grandson v-bind="$attrs" v-on="$listeners"></Grandson>
</div>
</template>
child要将parent的属性,转发(透传)给 孙组件,就可以通过v-bind="$attrs"
;同理,要将parent组件的事件,转发(透传)给 孙组件,就可以通过v-on="$listeners"
。
v-bind相当于展开$attrs的所有属性(...$attrs)
,在孙组件中,最好还是应该将用到的props声明出来。同理,v-on也相当于展开$listeners的所有事件(...$listeners)
。
# provide/inject
隔很多代传,实现组件和后代之间传值。
// ancestor
provide() {
return {foo: 'foo'}
}
// descendant
inject: ['foo']
// 为解决命名冲突,改名
inject: {
foo1: {
from: 'foo'
}
}
注意:provide传值,对于基础数据类型,不是响应式的,而如果传入的是引用类型的响应式数据,或者实例,那么inject拿到的就是响应式的。
# 插槽
插槽语法是 Vue 实现的内容分发 API,用于复合组件开发,类似占位符的作用。该技术在通用组件库开发中有大量应用。
# 匿名插槽
// comp1
<div>
<slot></slot>
</div>
// parent
<comp1>hello</comp1>
# 具名插槽:将内容分发到子组件指定位置
// comp2
<div>
<slot></slot>
<slot name="content"></slot>
</div>
// parent
<Comp2>
<!-- 默认插槽用default做参数 -->
<template v-slot:default>具名插槽</template>
<!-- 具名插槽用插槽名做参数 -->
<template v-slot:content>内容...</template>
</Comp2>
# 作用域插槽:父组件分发内容要用到子组件中的传出来的数据
// comp3
<div>
<slot :foo="foo"></slot>
</div>
// parent
<Comp3>
<!-- 把v-slot的值指定为作用域上下文对象 -->
<template v-slot:default="slotProps">
来自子组件数据:{{slotProps.foo}}
</template>
</Comp3>
# 通用表单组件
# 实现思路
- InchForm
- 提供数据、校验规则
- InchFormItem
- label标签添加
- 执行校验
- 显示错误信息
- InchInput
- 维护数据
- 图标、反馈
# 代码地址
inch-vue-components: form (opens new window)
# 实现关键点
- 属性继承
- inheritAttrs: false 把vue默认的属性继承去掉,继承对class和style是很有用的,但在这里,对于比如placeholder,不应该继承在自定义组件的div上,而应该关掉继承后,手动通过v-bind="$attrs"继承在input标签上。
- 触发校验
- 触发校验肯定是在InchInput上
- this.$emit('validate') 这样是没办法写的,因为外层的InchFormItem中是插槽,插槽还没有被替换成最外层的InchForm,所以无法绑定事件,也就无法监听。因此需要另辟蹊径。
- 参考事件总线eventBus的实现:
事件的派发者和监听者,必须是同一个角色
,- 如果是当前实例InchInput派发事件,则在外层使用InchInput时也必须是它自己监听
- 同理,考虑让父组件,即 InchFormItem 来派发和监听:
this.$parent.$emit('validate')
- 监听校验
- InchFormItem 可以在挂载后的mounted钩子中监听
// InchFormItem.vue mounted() { // InchFormItem在mounted钩子中监听校验触发 this.$on('validate', () => { this.validate() }); }, methods: { validate() { // 需要校验规则、校验值 } },
- InchFormItem 可以在挂载后的mounted钩子中监听
- 校验规则和校验值传递
- validate方法需要拿到校验规则和校验值,但数据在外层的InchForm上(InchForm不一定直接包裹InchFormItem,有可能有中间层组件)
- 因此,比较适合的传值方式是使用provide/inject:
// InchForm.vue provide() { return { form: this // 直接传this实例,则 校验规则rules、校验值model都能一并传下去 } }, // InchFormItem.vue inject: ['form'], // 注入
- 校验规则如何对应
- rules传入InchFormItem后,由于是整体传入的,需要对应到各自的表单item上
- 这里联想elementUI表单实现中,FormItem如果有校验,必须要设置prop属性,其实它的作用就相当于一个key,方便对应
- (element form-item的prop,其实就是key,但是key是保留字段,因此用prop)
// InchFormItem.vue props: { prop: string } methods: { validate() { // 校验规则 const rules = this.form.rules[this.prop] // 校验值 const value = this.form.model[this.prop] // 注意value是变化的,因为外面是双向绑定的,且provide的是对象 } },
- 校验如何完成
- 自己实现校验规则成本太高,可以考虑使用第三方库,比如elementUI就使用第三方库async-validator
npm install async-validator -S
- 引入库后新建实例,校验要返回 Promise,因为校验有可能是异步操作
- 另外,给validator实例设置数据源时,要使用ES6的计算属性来设置键值
[this.prop]
// InchFormItem.vue import Schema from 'async-validator'; export default { methods: { validate() { // 校验规则 const rules = this.form.rules[this.prop] // 校验值 const value = this.form.model[this.prop] // 注意value是变化的,因为外面是双向绑定的,且provide的是对象 // 创建校验器实例 const validator = new Schema({ [this.prop]: rules }) // 使用ES6的计算属性设置键名 // 校验,返回 Promise,因为校验有可能是异步操作 return Schema.validate({ [this.prop]: value }, errors => { if (errors) { // 显示错误信息 this.error = errors[0].message } else { this.error = '' } }) } }, }
- 自己实现校验规则成本太高,可以考虑使用第三方库,比如elementUI就使用第三方库async-validator
- 全局校验
- 点击登录按钮时,需要触发全局校验
<!-- index.vue --> <template> <div> <InchForm :model="model" :rules="rules" ref="loginForm"> <InchFormItem label="用户名" prop="username"> <InchInput v-model="model.username" placeholder="请输入用户名"></InchInput> <!-- {{model.username}} --> </InchFormItem> <InchFormItem label="密码" prop="password"> <InchInput type="password" v-model="model.password" placeholder="请输入密码"></InchInput> </InchFormItem> <InchFormItem> <button @click="login">登录</button> </InchFormItem> </InchForm> </div> </template>
// index.vue methods: { login() { // 传入回调函数 this.$refs.loginForm.validate(valid => { if (valid) { alert('校验通过,可以登录') } else { alert('登录失败') } }) } },
- loginForm的validate方法还没有实现,它接收一个参数,是回调函数:
// InchForm.vue methods: { // 接收一个参数,是回调 validate(cb) { // 调用所有含有prop属性的子组件的validate方法并得到Promise数组 const tasks = this.$children .filter(item => item.prop) // 过滤掉button的包裹 .map(item => item.validate()) // 所有任务必须全部成功才算校验通过,任一失败则校验失败 Promise.all(tasks) .then(() => cb(true)) .catch(() => cb(false)) } },
# 弹窗组件
- 弹窗这类组件的特点是它们在当前vue实例之外独立存在,通常挂载于body;
- 它们是通过JS动态创建的,不需要在任何组件中声明。
- 常⻅使用方式
this.$create(Notice, { title: '标题', message: '提示信息', duration: 1000 }).show();
# 任务目标
需要创建组件实例,如果没有组件实例,就无法实现挂载,也就不能转化为真实的dom元素出现在页面中:
- 1、构建Component的实例
- 方法1:“借鸡生蛋”,使用Vue构造函数,先得到Vue实例vm,vm在执行挂载之后,就能得到Component组件实例
const vm = new Vue({ render(h) { // h是createElement // 它可以返回一个vnode return h(Component, { props }) } }).$mount()
- 方法2:使用Vue.extend得到组件实例
const comp = { data: {}, props: {} } const Ctor = Vue.extend(comp) // 得到组件构造函数 new Ctor({ propsData: {} }) // 得到组件实例,传参需要指定propsData参数
- 方法1:“借鸡生蛋”,使用Vue构造函数,先得到Vue实例vm,vm在执行挂载之后,就能得到Component组件实例
- 2、挂载到body上
- 注意:不能直接$mount('body'),会直接把body覆盖替换掉,所以第一步中先不设置挂载目标,依然可以转换vnode为真实节点$el
- $mount()会产生一个真实节点$el,可以手动追加到body上
# 代码实现
# 使用Vue构造函数得到组件实例
// create.js
import Vue from 'vue'
// Component - 组件配置对象
// props - 传递给它的属性
function create(Component, props) {
// 1.构建Component的实例
const vm = new Vue({
render(h) {
// h是createElement,返回vnode,是虚拟dom
// 需要挂载才能编程真实dom
return h(Component, { props })
}
}).$mount()
// 一般 $mount() 需要指定挂载点,比如弹窗组件应挂载到body上,但这里的行为是覆盖的,所以应该之后追加到body上而不是直接挂载覆盖
// 不设置挂载目标,依然可以转换vnode为真实节点$el
// 2.挂载到body上
// $mount()会产生一个真实节点$el,可以手动追加到body上
document.body.appendChild(vm.$el)
// 3.获取组件实例
const comp = vm.$children[0] // 由于new Vue的根实例vm来说,只有一个组件实例
comp.remove = () => {
// 移除组件实例,释放内存
document.body.removeChild(vm.$el)
vm.$destroy()
}
return comp
}
export default create
// 使用
create(Notice, {
title: '这是弹窗组件呀',
message: valid ? '校验通过,可以登录' : '校验失败'
}).show()
# 使用Vue.extend得到组件实例
- Vue.extend之后得到构造函数Ctor
- new Ctor之后就可以直接得到组件实例
- 组件实例进行挂载,将虚拟dom转换得到真实节点$el
- 追加到body上
// create.js
import Vue from 'vue'
// Component - 组件配置对象
// props - 传递给它的属性
function create(Component, props) {
// 构建Component的实例
const Ctor = Vue.extend(Component)
// 获得组件实例
const comp = new Ctor({propsData: props})
// 必须挂载,得到真实dom$el
comp.$mount()
// 追加都body上
document.body.appendChild(comp.$el)
comp.remove = () => {
// 移除组件实例,释放内存
document.body.removeChild(comp.$el) // 注意,这里不是vm了
comp.$destroy()
}
return comp
}
export default create
# 思考
树形组件、递归组件(待续)
学习elementUI的源码,修正input组件中$parent的写法问题(略)