Vue读懂这篇,进阶高级
注:得VueComponent着得Vue,通过此文了解VueComponent的强大。
超级代理
我们已经知道了 "props down events up",但日常的业务远远不止父子之间的“交互”,例如:子孙之间、曾孙之间、曾曾孙之间……!,我该如何想我的下级传递命令?下级做好了一件事,如何向上级报告?情况就越演越烈。
通常的解决办法如下:
严格遵守单向数据流, props一层一层的传递,像传递奥运火炬一样。events一层一层向上冒泡。不仅在编写方面冗余且容易出错,更加大了组件间的“交互”成本。
Bus又过于称重且不易维护,在日常开发过程中,往往因为业务功能加大了组件的维护成本,那有没有一个方法可以直达?
一、$listeners【代理events】
官方解读:在 vue2.4 中,Vue 提供了一个$listeners属性,它是一个对象,里面包含了作用在这个组件上的所有监听器。可以配合 v-on="$listeners" 将所有的事件监听器指向这个组件的某个特定的子元素。
// these are also reactive so they may trigger child update if the child
vm.$listeners = listeners || emptyObject
官方文档解释甚微,以下我以km-grid里的一段代码为例:
注:km-grid-item为km-grid的子组件。
<div :class="cls" :style="tableStyles">
<km-grid-item v-if="fixedLeftCol&&fixedLeftCol.length" fixed="left" v-on="$listeners" :columns="fixedLeftCol" :header-styles="leftFixedHeaderStyles" :body-styles="leftFixedBodyStyles"></km-grid-item>
<km-grid-item v-on="$listeners" :columns="centerCol" :expandColumn="expandCol" :header-styles="headerStyles" :body-styles="bodyStyles"></km-grid-item>
<km-grid-item v-if="fixedRightCol&&fixedRightCol.length" fixed="right" v-on="$listeners" :columns="fixedRightCol" :header-styles="rightFixedHeaderStyles" :body-styles="rightFixedBodyStyles"></km-grid-item>
</div>
这里在km-grid-item加上 v-on="$listeners"意思就是说将所有km-grid的监听器指向 km-grid-item,即我在km-grid-item里的所有通过$emit抛出的事件都可以被km-grid的$listeners属性采集到。即km-grid-item代理了km-grid的事件。
通过v-on="$listeners",我们就可以消除 events地狱,降低组件“交互”间的成本,提高了代码可维护性,提高了性能。
二、$attrs【代理props】
官方解读:包含了父作用域中不作为 prop 被识别 (且获取) 的特性绑定 (class 和 style 除外)。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 (class 和 style 除外),并且可以通过 v-bind="$attrs" 传入内部组件——在创建高级别的组件时非常有用。
// these are also reactive so they may trigger child update if the child
vm.$attrs = parentVnode.data.attrs || emptyObject
即当我在子组件加上 v-bind="$attrs"时,并没有在子组件内部用props接收,在Vue v2.4之后,多余的属性将会被$attrs接收,即可在子组件内部通过this.$attrs获取。
通过v-bind="$attrs",我们不用在子组件上去同步props,代理了props,但仍解决不了props地狱的问题。
推荐:使用$parent、$parent.$parent获取父组件的 VueCompont,因为VueCompont是响应式的。在子组件、孙组件引用只是对对象的引用,能解决props地狱的问题,但切记这是对对象的引用,若只想获取父组件值,请使用deepCopy方法。
export const deepCopy = data => {
const t = typeOf(data)
let o
if (t === 'array') {
o = []
} else if (t === 'object') {
o = {}
} else {
return data
}
if (t === 'array') {
for (let i = 0; i < data.length; i++) {
o.push(deepCopy(data[i]))
}
} else if (t === 'object') {
for (let i in data) {
o[i] = deepCopy(data[i])
}
}
return o
}
超越父子亲情
使用这两个方法之前要在render树上存在父子孙关系,可越级。不熟悉render树的可以看我之前写的一篇js执行过程及vue编译过程
什么是render树?即我们使用的.vue文件最终都会通过vue-Compiler生成render树。通过slot最终也会正确的变为render树,是vnode的原型,也是DOM树的映射。如下是一个简单的render树:
with(this){
return (isShow) ?
_c('ul', {
staticClass: "list",
class: bindCls
},
_l((data), function(item, index) {
return _c('li', {
on: {
"click": function($event) {
clickItem(index)
}
}
},
[_v(_s(item) + ":" + _s(index))])
})
) : _e()
}
各位看官也可在chorme浏览器调试模式下的Sources查看编译之后的代码。
一、dispatch
dispatch子广播,父接收,跟events有点区别。
export default {
methods: {
dispatch(componentName, eventName, params) {
let parent = this.$parent || this.$root
let name = parent.$options.name
while (parent && (!name || name !== componentName)) {
parent = parent.$parent
if (parent) name = parent.$options.name
}
if (parent) parent.$emit.apply(parent, [eventName].concat(params))
}
}
}
以下我以添加form表单label自适应的需求为例:
<Form ref="entity" class="simpleModal" justify :model="entity">
<Row>
<Col span="24">
<FormItem label="角色名称" prop="name" :required="true" :maxLen="10">
<Input v-model="entity.name" placeholder :maxlength="10" />
</FormItem>
</Col>
</Row>
<Row>
<Col span="24">
<FormItem label="职能范围" :required="true" prop="functionScope">
<Select
v-model="entity.functionScope"
:disabled="!entity.isNewEntity"
@on-change="changeFunctionScope"
>
<Option v-for="(txt,key) in functionScopes" :value="key" :key="key">{{ txt }}</Option>
</Select>
</FormItem>
</Col>
</Row>
</Form>
首先我们在父组件Form的created钩子里添加监听:
created() {
this.$on('on-form-item-label', (field) => {
this.labelWidthArr.push(field)
})
}
computed: {
labelWidthMax: {
get () {
return this.labelWidthArr.sort((a, b) => {return a - b;})[this.labelWidthArr.length-1];
},
set (val) {
this.labelWidthArr.sort((a, b) => {return a - b;})[this.labelWidthArr.length-1] = val
}
}
}
然后在子组件FormItem的created钩子里dispatch:
created () {
if (this.form.justify) {
let span = document.createElement('span')
let mock = document.createElement('div')
let FormItemPadding = 12
span.innerHTML = this.label
span.style.fontSize = '14px'
mock.appendChild(span)
document.body.appendChild(mock)
let widthContained = span.offsetWidth
document.body.removeChild(mock)
if (this.required || this.getRules().some(v => v.required)) {
widthContained += 10
}
widthContained += FormItemPadding
this.dispatch('iForm', 'on-form-item-label', widthContained)
}
}
通过这样,子组件每次created都像父组件抛出当前计算的label,然后在父组件接收计算出最大值,按最大的那个加载,即可实现自适应。dispatch适用于无限向上发送,只要是存在最终通过vue-Compiler编译的 render树上的父子层级关系即可。
显然Form组件里面有很多的slot,但是存在render树的“父子”,是子主动dispatch。
二、broadcast
broadcast父主动派发,子孙接收,跟props有点区别。
function broadcast(componentName, eventName, params) {
this.$children.forEach(child => {
const name = child.$options.name
if (name === componentName) {
child.$emit.apply(child, [eventName].concat(params))
} else {
broadcast.apply(child, [componentName, eventName].concat([params]))
}
})
}
export default {
methods: {
broadcast(componentName, eventName, params) {
broadcast.call(this, componentName, eventName, params)
}
}
}
以下,我一实现一个在路由激活时,重新加载以下数据为例:
首先在需要更新的组件B里监听:
created () {
this.loadInventory()
this.$on('on-load', () => { this.loadInventory() })
}
然后在当前路由组件A里的activated主动broadcast:
activated () {
const listDataCache = this.$refs.list.data.DataList
if (listDataCache && listDataCache.length) {
this.broadcast('checkWatch', 'on-load')
}
}
当然这两个组件之间没有任何的父子关系,只是存在render树的“父子”,即A组件在render树的名义上和B组件存在父子关系,且是父主动broadcast。
唾手可得的VueComponent
有的同学要问,什么是VueComponent?VueComponent有什么用?
VueComponent即为组件实例,是响应式的,也是Vue的核心。我们可以通过VueComponent访问组件的data、computed,调用methods。而且可以通过挂载的$xx来获取更多的信息,并且这些信息都是响应式的。可以说 得VueComponent着得天下。
属性名 | 描述 |
---|---|
$attrs | 上面已说明 |
$listeners | 上面已说明 |
$data | Vue 实例观察的数据对象。Vue 实例代理了对其 data 对象属性的访问。 |
$router | vue-router路由对象 |
$route | 当前路由对象 |
$slots | 用来访问被插槽分发的内容。每个具名插槽 有其相应的属性 (例如:v-slot:foo 中的内容将会在 vm.$slots.foo 中被找到)。default 属性包括了所有没有被包含在具名插槽中的节点,或 v-slot:default 的内容。 |
$scopedSlots | 用来访问作用域插槽。对于包括 默认 slot 在内的每一个插槽,该对象都包含一个返回相应 VNode 的函数。 |
$el | 返回DOM,HtmlElement对象 |
$refs | 一个对象,持有注册过 ref 特性 的所有 DOM 元素和组件实例。 |
$children | 当前实例的直接子组件。需要注意 $children 并不保证顺序,也不是响应式的。如果你发现自己正在尝试使用 $children 来进行数据绑定,考虑使用一个数组配合 v-for 来生成子组件,并且使用 Array 作为真正的来源。 |
$data | Vue 实例观察的数据对象。Vue 实例代理了对其 data 对象属性的访问。 |
$props | 当前组件接收到的 props 对象。Vue 实例代理了对其 props 对象属性的访问。 |
$root | 当前组件树的根 Vue 实例。如果当前实例没有父实例,此实例将会是其自己。 |
$parent | 父实例,如果当前实例有的话。 |
$options | 用于当前 Vue 实例的初始化选项。需要在选项中包含自定义属性时会有用。 |
一、春晖寸草 findComponentUpward & findComponentUpward
1、findComponentUpward
export const findComponentUpward = (context, componentName, componentNames) => {
if (typeof componentName === 'string') {
componentNames = [componentName]
} else {
componentNames = componentName
}
let parent = context.$parent
let name = parent.$options.name
while (parent && (!name || componentNames.indexOf(name) < 0)) {
parent = parent.$parent
if (parent) name = parent.$options.name
}
return parent
}
findComponentUpward 向上匹配最近的componentName的VueComponent。componentName可传String或Array,切记componentName为Vue.use()的那个name,若使用Vue.component()注册,则是注册的key。
//match one
const Tree = findComponentUpward(this, 'Tree');
//match someone
this.$Modal.confirm({
el: findComponentUpward(this, ['SheetPage', 'InfoPage']).$el,
content: `确定要删除${this.title}吗?`,
onOk: async () => {
let { data } = await this.doDeleteEntity(this.id, this.Action)
if (data.code === 0) {
this.$Message.success('删除成功。')
this.$emit('on-sheet-delete', this.id)
}
},
onCancel: () => { }
})
2、findComponentsUpward
export const findComponentsUpward = (context, componentName) => {
let parents = []
const parent = context.$parent
if (parent) {
if (parent.$options.name === componentName) parents.push(parent)
return parents.concat(findComponentsUpward(parent, componentName))
} else {
return []
}
}
findComponentsUpward 向上匹配所有的componentName的VueComponent。componentName传String,切记componentName为Vue.use()的那个name,若使用Vue.component()注册,则是注册的key。
二、老牛舐犊 findComponentDownward & findComponentsDownward
1、findComponentDownward
export const findComponentDownward = (context, componentName) => {
const childrens = context.$children
let children = null
if (childrens.length) {
for (const child of childrens) {
const name = child.$options.name
if (name === componentName) {
children = child
break
} else {
children = findComponentDownward(child, componentName)
if (children) break
}
}
}
return children
}
findComponentDownward 向下匹配最近的componentName的VueComponent。componentName传String,切记componentName为Vue.use()的那个name,若使用Vue.component()注册,则是注册的key。
this.infoInstence = findComponentDownward(this.$parent, 'SheetPage')
2、findComponentsDownward
export const findComponentsDownward = (context, componentName) => {
return context.$children.reduce((components, child) => {
if (child.$options.name === componentName) components.push(child)
const foundChilds = findComponentsDownward(child, componentName)
return components.concat(foundChilds)
}, [])
}
findComponentsDownward 向下匹配所有的componentName的VueComponent。componentName传String,切记componentName为Vue.use()的那个name,若使用Vue.component()注册,则是注册的key。
let SlideInfoVnode = this.$refs.SlideInfo.$children[0]
let FormItems = findComponentsDownward(SlideInfoVnode, 'FormItem')
四、情同手足 findBrothersComponents
export const findBrothersComponents = (context, componentName, exceptMe = true) => {
let res = context.$parent.$children.filter(item => {
return item.$options.name === componentName
})
let index = res.findIndex(item => item._uid === context._uid)
if (exceptMe) res.splice(index, 1)
return res
}
findBrothersComponents 向兄弟匹配所有的componentName的VueComponent。componentName传String,切记componentName为Vue.use()的那个name,若使用Vue.component()注册,则是注册的key,exceptMe默认为false,排除自己,否则不排除自己。
进阶render
一、函数式组件 functional
官方解读:使组件无状态 (没有 data) 和无实例 (没有 this 上下文)。他们用一个简单的 render 函数返回虚拟节点使它们渲染的代价更小。
以下我以km-grid业务自定义下拉render为例,首先我在columns定义
this.columns = [
{
type: 'expand',
width: 50,
render: (h, params) => {
if (params.row.status != '4') return ''
return h(checkWatch, {
props: {
row: params.row
}
})
}
}
]
km-grid-item render,直接调用columns配置的render函数,这个render函数会传至td-render这个组件里面:
<div v-if="!fixed&&expandColumn.render" :class="cls+'-tr-expand'">
<div style="width:100%" v-if="row._clicked">
<td-render :row="row" :render="expandColumn.render"></td-render>
</div>
</div>
td-render是如何调用配置的render来实现渲染的呢?
export default {
name: 'TdRender',
functional: true,
props: {
row: Object,
render: Function,
index: Number,
column: {
type: Object,
default: null
}
},
render: (h, ctx) => {
const params = {
row: ctx.props.row,
index: ctx.props.index
};
if (ctx.props.column) params.column = ctx.props.column;
return ctx.props.render(h, params);
}
}
原来创建组件有两种方法,一种是通常的template模板字符串形式,另一种是字符串模板的代替方案,允许你发挥 JavaScript最大的编程能力。该渲染函数接收一个 createElement 方法作为第一个参数用来创建 VNode。若组件标记为 functional,这意味它无状态 (没有响应式数据),也没有实例 (没有 this 上下文),于是我们可以利用render来提供上下文。
在td-render通过render提供第二个参数context作为上下文来渲染,并且开销比template要小。