Vue2/3中如何为已经创建的VNode添加属性或事件

5,914 阅读6分钟

本文将从源码角度分析介绍如何在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创建时具备以下特点:

  1. 普通节点创建时,VNodeData中的数据基本没有经过处理,可以认为其为原始数据,可以直接使用mergeProps合并。
  2. 组件节点创建时,会从VNodeData中提取出组件的propsDatalisteners,并将其保存在componentOptions下。提取之后,VNodeData中剩余的属性和事件属于VNode对应的dom元素。这种情况下我们想要给组件添加事件和属性时可能需要修改vnode.componentOptions下的propsDatalisteners以及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函数,均是可以达到目的的。

本文纯属抛砖引玉,重在提供思路,若有错误,也欢迎评论区指正和讨论。