本文将从源码角度分析介绍如何在Vue2/3中为一个已经创建的VNode节点添加属性和事件。
我们通过渲染函数h
创建VNode
节点时,可以为其指定事件和属性,但创建完成之后,一个VNode
节点就确定下来了。通常我们再想修改其绑定的事件或属性,便是通过打印并观察创建出的VNode节点的结构,然后凭感觉修改一些可能需要修改的属性以达到目的。倘若只是为了完成一时间的需求而做的hack无可厚非,但要是应用于通用模块则有些欠妥。为了方便我们做高级定制,知晓原理还是很有必要的。
属性和事件的合并
在使用jsx编写模板时我们经常会使用展开符(...)快速传递组件属性,如下:
import mergeProps from '@vue/babel-helper-vue-jsx-merge-props'
const MyComponent = {
render(h) {
// original: <button onClick={$event => console.log($event)} {...{ on: { click: $event => doSomething($event) } }} />
return h(
'button',
mergeProps([
{ on: { click: $event => console.log($event) } },
{ on: { click: $event => doSomething($event) } },
]),
)
},
}
此时,负责转换jsx的babel插件将会调用一个辅助函数@vue/babel-helper-vue-jsx-merge-props进行属性与事件的合并,我们可以将这个函数命名为mergeProps
。我们在编写组件时经常需要处理属性和事件的合并问题,有了这个函数,便是如虎添翼。实际上这个函数在vue3版本中直接内置并导出了,但很多小伙伴并不知道这个函数在vue2中其实也存在。
既然是合并属性和事件,那么有必要了解数据对象VNodeData的结构,可以参见官方文档「深入数据对象」章节。
VNode创建过程
首先我们来到渲染函数createElement函数源码部分。
可以看到在创建VNode节点的核心片段中,只出现了new VNode()
和createComponent
两种形式的创建方式,所以为了方便理解我们可以粗略的将VNode类型分为普通节点和组件节点。
if (typeof tag === 'string') {
let Ctor
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
if (config.isReservedTag(tag)) {
// platform built-in elements
...
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
} else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// component
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// unknown or unlisted namespaced elements
// check at runtime because it may get assigned a namespace when its
// parent normalizes children
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
} else {
// direct component options / constructor
vnode = createComponent(tag, data, context, children)
}
接着我们来到上面提到的createComponent源码部分。
...
// extract props
const propsData = extractPropsFromVNodeData(data, Ctor, tag)
...
...
// extract listeners, since these needs to be treated as
// child component listeners instead of DOM listeners
const listeners = data.on
// replace with listeners with .native modifier
// so it gets processed during parent component patch.
data.on = data.nativeOn
...
...
// return a placeholder vnode
const name = Ctor.options.name || tag
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
)
这里处理的是组件节点的创建,观察发现只有这里在new VNode()
时传递了第7、8个参数。下面是VNode构造函数源码部分。
// VNode constructor
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
){
...
this.componentOptions = componentOptions
this.componentInstance = undefined
...
}
观察createElement
函数所有分支,以及VNode
源码可以发现:只有组件VNode才具有componentOptions项,且该项是创建时便已设置,而非VNode渲染后异步挂载上来的(如:componentInstance)。因此我们可以通过这个特点来判定一个VNode节点是否是组件节点。
综上所述,VNode创建时具备以下特点:
- 普通节点创建时,VNodeData中的数据基本没有经过处理,可以认为其为原始数据,可以直接使用
mergeProps
合并。 - 组件节点创建时,会从VNodeData中提取出组件的
propsData
和listeners
,并将其保存在componentOptions
下。提取之后,VNodeData中剩余的属性和事件属于VNode对应的dom元素。这种情况下我们想要给组件添加事件和属性时可能需要修改vnode.componentOptions
下的propsData
和listeners
以及vnode.data.attrs
,他们分别对应了组件内部的$props
,$listeners
,$attrs
合并属性
具体思路参见注释
import { merge, pick, omit } from 'lodash';
import mergeProps from '@vue/babel-helper-vue-jsx-merge-props';
function mergeVNodeProps(vnode, ...args) {
if (vnode.componentOptions) {
const { propsData, listeners, Ctor } = vnode.componentOptions;
// 为了便于合并, 我们只取on和attrs两个参数,分别合并事件和属性
const { on, attrs } = mergeProps([{ on: listeners, attrs: propsData }, ...args]);
// 获取组件中定义的props
const keys = Object.keys(Ctor.options.props || {});
// 分离出组件需要的propsData和attrs
const [$props, $attrs] = [pick(attrs, keys), omit(attrs, keys)];
// 这里偷懒使用lodash.merge函数进行递归合并
merge(vnode, {
data: { attrs: $attrs },
componentOptions: {
listeners: on,
propsData: $props,
},
});
} else {
// 非组件VNode,直接合并data。但需要注意事件使用的是on而非nativeOn
// 属性根据情况可以使用attrs或domProps,这二者的区别见后文
vnode.data = mergeProps([vnode.data, ...args]);
}
return vnode;
}
vue3中如何合并
vue3中,VNode的属性和事件不分节点类型,统一以平铺的形式存放至vnode.props
中,并且vue3内置了mergeProps
函数。因此我们可以直接调用mergeProps合并vnode的props,以达到为已经创建好的VNode添加属性或事件的目的,如下:
import { mergeProps } from 'vue'
vnode.props = mergeProps(vnode.props, {
title: 'test',
onClick(){},
onChange(){},
'onUpdate:modelValue'(){},
})
写到这里,不禁感叹vue3的优雅。尤大曾在直播中提到,vue3在一定程度上偿还清了vue2欠下的技术债。而当我们越发深入了解vue3,发现许多vue2中不太容易实现的细节却在vue3中变得如此优雅轻松的时候,才恍然大悟,这就是所谓的“技术债”吗!
应用场景
说了这么多,我们为什么需要修改一个已经创建好的VNode节点呢?既然要为VNode增加属性和事件,为什么不在创建它的时候直接使用mergeProps或手动merge的形式添加呢?
这是个好问题,那么为什么我们不在创建VNode时候直接为其指定我们所需的事件和属性呢?
一般情况下,我们直接编写出了需要的VNode并达成目的。但当需要开发通用组件或工具库时,考虑到通用型和易用性,我们经常需要提供自定义渲染函数,通过传入上下文,获取返回的VNode,允许用户自定义渲染的内容。这种情况下我们拿到的便是已经存在的VNode,于是上面提到的mergeVNodeProps
便有了用武之地,如下:
// 用户侧手动书写的配置
const config = {
title: '用户名称',
key: 'username',
render: (h, data) => (
<CustomInput
value={data.username}
onInput={value => {
data.username = value;
}}
/>
),
}
// 工具侧根据配置生成内容
export default {
name: 'FormItem',
props: ['config'],
render(h) {
const { render, title } = this.config;
return (
<div class="form-item">
<label class="form-label">{title}</label>
<div class="form-content">{render(h, this.formData)}</div>
</div>
);
},
};
在上方的render函数中做了一件事,通过jsx的方式创建了CustomInput
的组件VNode并手动进行了v-model
双向数据绑定。作为工具开发者的你,一定想节约用户编写配置的时间,提升用户体验。很明显,双向数据绑定我们可以在工具内部自动进行,如下:
// 用户侧手动书写的配置
const config = {
title: '用户名称',
key: 'username',
render: (h) => <CustomInput />,
}
// 工具侧根据配置生成内容
export default {
name: 'FormItem',
props: ['config'],
render(h) {
const { render, title, key } = this.config;
const contentVNode = render(h, this.formData);
// 进行v-model双向绑定,如果是vue3,直接修改vnode.props即可
mergeVNodeProps(contentVNode, {
attrs: { value: this.formData[key] },
on: {
input: value => {
this.formData[key] = value;
},
},
});
return (
<div class="form-item">
<label class="form-label">{title}</label>
<div class="form-content">{contentVNode}</div>
</div>
);
},
};
我们发现render: (h) => <CustomInput />
还可以进一步化简,即:render: CustomInput
。如此一来,既然用户提供的是组件,那么我们便可以直接进行渲染并绑定数据和参数,在FormItem组件中添加相应判断分支即可,不再赘述。
最后
mergeVNodeProps
这个函数想怎么封装完全取决你自己想实现什么功能。本文实现的这个函数虽能为组件VNode添加事件和属性,却暂时还不能为组件VNode对应的dom节点进行修改。但我相信明白原理之后,合理使用mergeProps
函数,均是可以达到目的的。
本文纯属抛砖引玉,重在提供思路,若有错误,也欢迎评论区指正和讨论。