前言

在一般情况下,我们在vue中,推荐使用template模版语法来创建组件,然后在某些场景下,我们需要使用js完全的coding能力(也就是以js函数的思维来编写组件),这个时候就可以使用渲染函数
关于渲染函数,官方的描述已经讲解得比较详细了,具体见 渲染函数 的详细说明!
本文将从底层的角度来分析何为vnodeh()函数的使用方式、分析关于h函数的执行过程,以及扩展一下思维,了解关于什么是h高阶组件

vnode虚拟节点

vue提供了一个h()函数,用于创建vnode,也就是说

1
const vnode = h('div', {}, [])

🤔 那么,什么是vnode?为什么要生成vnode?对于vnode应该如何使用?

👉 关于vnode的相关知识点,可以查看之前的一篇文章:vm实例如何渲染

简而言之,vnode是一个用于描述用户界面结构的JavaScript对象

简而言之,如下图所示:
vue组件渲染成vnode
👉 将一个组件渲染成vnode,然后通过对比vnode的不同,形成最终待更新界面的vnode,然后只更新需要更新的dom节点,其工作流程如下图所示:
h函数执行过程

h()函数的使用方式

用来创建vnode虚拟节点的统一方法API
h()函数更准确地说,应该是createVnode(),只不过经常使用,因此使用h()来替代,关于这个h()函数的签名如下:

1
2
3
4
5
function h(
type: string | Object | Function,
props?: Object | null,
children?: string | Array | Object
): Vnode

关于上述三个参数的意义代表如下:

  1. type: 可以是html标签、组件、函数;
  2. props: 一个包含属性的对象,可以是null;
  3. children: 可以是字符串、数组、对象,代表的子节点

关于h()函数的使用方式有很多,以下是对应的使用场景(这边结合源码的方式来阐述)
在开始罗列相关的使用方式之前,先整理一下基础的成员类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// *********** 基础定义 ***********
// 基础的html标签对象接口
interface HTMLElementTagNameMap{
"a": HTMLAnchorElement;
"abbr": HTMLElement;
"address": HTMLElement;
// ... 其他的标签定义
}
// html的事件名称类型集合,每一个成员都是可选的
type HTMLElementEventHandler = {
[K in keyof HTMLElementEventMap as `on${Capitalize<K>}`]?: (
ev: HTMLElementEventMap[K],
) => any
}
// 孩子成员类型
type RawChildren =
| string
| number
| boolean
| VNode
| VNodeArrayChildren
| (() => any)
// 虚拟节点属性
type VNodeProps = {
key?: string | number | symbol
ref?: VNodeRef
ref_for?: boolean
ref_key?: string
// vnode hooks
onVnodeBeforeMount?: VNodeMountHook | VNodeMountHook[]
onVnodeMounted?: VNodeMountHook | VNodeMountHook[]
onVnodeBeforeUpdate?: VNodeUpdateHook | VNodeUpdateHook[]
onVnodeUpdated?: VNodeUpdateHook | VNodeUpdateHook[]
onVnodeBeforeUnmount?: VNodeMountHook | VNodeMountHook[]
onVnodeUnmounted?: VNodeMountHook | VNodeMountHook[]
}
// 成员属性
type RawProps = VNodeProps & {
// used to differ from a single VNode object as children
__v_isVNode?: never
// used to differ from Array children
[Symbol.iterator]?: never
} & Record<string, any>

👇 关于每个h函数都是通过重载的方式来实现不同的调用方式!

渲染普通的html标签

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 源码定义
export function h<K extends keyof HTMLElementTagNameMap>(
type: K,
children?: RawChildren
): VNode
// 使用方式: h('a', '我是a标签')
// 结果html: <a>我是a标签</a>

// 源码定义
export function h<K extends keyof HTMLElementTagNameMap>(
type: K,
props?: (RawProps & HTMLElementEventHandler) | null,
children?: RawChildren | RawSlots
): VNode
// 使用方式: h('a', { alt: '我是链接描述' }, '我是a标签')
// 结果html: <a alt="我是链接描述">我是a标签</a>

渲染自定义element标签

1
2
3
4
5
6
7
8
9
// 源码定义
export function h(type: string, children?: RawChildren): VNode
// 使用方式: h('custom-node', '我是自定义element')
// 结果html: <custom-node>我是自定义element</custom-node>

// 源码定义
export function h(type: string, props?: RawProps | null, children?: RawChildren | RawSlots)
// 使用方式: h('custom-node', { id: 'foo' }, '我是自定义element')
// 结果html: <custom-node id="foo">我是自定义element</custom-node>

渲染文本或者注释

1
2
3
4
5
6
7
8
9
10
11
export const Text = Symbol.for('v-txt')
export const Comment = Symbol.for('v-cmt')
// 源码定义
export function h(type: typeof Text | typeof Comment, children?: string | number | boolean): VNode
export function h(
type: typeof Text | typeof Comment,
props?: null,
children?: string | number | boolean,
): VNode
// 使用方式:h(Comment, '我是被渲染的文本')
// 结果html: <!-- 我是被渲染的文本 -->

渲染fragment

🤔 这里引入fragment是因为在vue3中可以在template模版中无需再嵌套一个公共的父容器节点,可以直接在template下嵌套多个孩子节点的情况,这个情况,其实就是在template模版中隐式的使用一个fragment来包裹template下的孩子节点,使用方式如下:

1
2
3
4
<template>
<div>我是元素一</div>
<div>我是元素一</div>
</template>

vue-tool开发工具中查看,结果如下:
隐式嵌套的fragment
🥸template中包含了两个根级别的div元素,它们被渲染为同一级别的兄弟元素,而且没有任何额外的包裹元素,且在vue-tool视图下可见被fragment包裹其中

vue3中,Fragment是一个特殊的类型,用于在模版中返回多个元素而不需要将多个元素包裹在一个额外的DOM元素中

1
2
3
4
5
6
7
// Fragment是一个可以被new构造调用的,带有__isFragment=true属性的,接收$props=VNodeProps属性的函数
export const Fragment = Symbol.for('v-fgt') as any as {
__isFragment: true
new (): {
$props: VNodeProps
}
}

这边再次根据上述的例子,进行h()调用并输出对应的vnode,结果如下:
fragment的虚拟dom
🥸 我们的确可以看到在输出的vnode中,拥有这个__isFragmentSymbol(v-fgt)属性

将这个fragment延伸至JSX,完整的例子如下:

fragment在vue3中的使用
fragment在h()函数中的使用
1
2
3
4
5
6
7
8
9
// 源码定义
export function h(type: typeof Fragment, children?: VnodeArrayChildren): VNode
export function h(
type: typeof Fragment,
props?: RawProps | null,
children?: VNodeArrayChildren
): VNode
// 使用方式:h(Fragment, [h('div', '我是div1'), h('div', '我是div2')])
// 结果html: <div>我是div1</div><div>我是div2</div>

渲染teleport

官方Teleport介绍

👉 用于将一个组件内部的一部分模版丢到该组件的DOM结构外侧的位置上!

渲染suspense

Suspense,从 官方Suspense文档 来看,就是一个用于控制异步加载的高阶组件,当 🈶 异步的setup()时,将会触发对应的插槽钩子组件作为展示,然后再在加载完毕时,才展示真正的组件!
其代码定义如下:

1
2
3
4
5
6
7
8
9
10
11
declare var __FEATURE_SUSPENSE__: boolean
export const Suspense = (__FEATURE_SUSPENSE__ ? SuspenseImpl : null) as unknown as {
__isSuspense: true,
new (): {
$props: VNodeProps & SuspenseProps,
$slots: {
defualt(): VNode[],
fallback(): VNode[]
}
}
}

🌟 从上述的类型定义,我们可以看出这个Suspense类型是一个可以被new constructor()调用,并返回一个带有$props, $slots: {default, fallback}属性的对象!

渲染函数式组件

函数式组件是一种定义自身没有任何状态的组件的方式,就像纯函数一样(只要接收参数,返回结果一般是一样的),接收props,返回vnodes在渲染过程中不会创建组件实例,也不会出发常规的组件生命周期钩子👉 可以认为就是一个用来创建静态html(vnode在底层转html)的方法,也就是渲染函数!
由于没有this关键词的引用,因此Vue会把props当作第一个参数传入,其方法定义如下:

1
function MyComponent(props, { attrs, emit, slots }){}

除了props以及emitss,普通常规组件所使用的配置选项在函数式组件中不可用,但是我们可以通过 👇 的方式来给一个函数式组件添加对应的属性来进行声明:

1
2
MyComponent.props = ['value']
MyComponent.emits = ['click']

而且,如果没有在上述将对应的props给定义出来的话,那么传递给到这个函数式组件的相关属性都被作为普通的attr属性来使用了!

h高阶组件函数的运用-defineComponent

defineComponent方法是vue3用于定义组件的函数,它通过一个对象(或者一个返回对象的函数)来描述组件的选项,返回一个组件选项对象,这个方法不仅增强了TypeScript中的类型推断,还明确了组件的意图
关于这个方法的简单介绍,具体可以见我的另一篇文章以及官网的介绍

这里主要分析一下关于官方并没有提及到的:

  1. defineComponent拥有两种调用方式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 方式一:直接传递一个setup函数以及一个包含props、emits、slots属性的对象
    defineComponent(
    (props, ctx) => {}, // setup函数
    options: {props, emits, slots}
    )
    // 方式二:直接传递一个options对象,该对象包含方式一中的所有参数
    defindComponent(
    {
    // 可以是vue2+中的ComponentOptions以及setup函数
    }
    )

    👉 其实这里底层都是传递的当前对象,也就是当前对象options被直接返回!

  2. 如果在defineComponent中options中同时定义了data以及setup函数,依然可以正常运行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import { defineComponent, ref } from 'vue'
    export default defineComponent({
    data() {
    return {
    message: '你好'
    }
    },
    setup() {
    const count = ref(0)
    function increment() {
    count.value ++
    }
    return { count, increment }
    }
    })

    🌟 这个例子中message以及countincrementVue会自动处理它们的合并以及响应式,但是 🈶 两个需要注意的地方:
    +. 执行时机:setup函数会在组件的beforeCreate钩子之后、created钩子之前执行,因此,在setup中定义响应式数据会早于data中的数据准备就绪;
    +. 合并行为:Vue会自动合并setupdata以及methods等返回的响应式状态,如果出现重名,则setup函数返回的属性将会覆盖到选项中的同名响应式状态。

💯 因此为避免出现覆盖的情况,建议在同个组件中尽量保持一致,避免混用两种风格!!

  1. setup除了可以返回一个响应式变量组成的对象,也可以返回一个渲染函数
    1
    2
    3
    4
    5
    6
    7
    import { h, ref } from 'vue'
    export default {
    setup() {
    const count = ref(0)
    return () => h('div', count.value)
    }
    }
    👉 返回一个渲染函数将会阻止返回其他,也就是说将会忽略template模版,以及render渲染函数,这将允许我们使用Composition API时从这个setup方法中返回的渲染函数来直接使用,也就是高阶组件的由来
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // useXXX.ts
    import {h} from 'vue'

    export function useXXX(params){
    return defineComponent({
    setup(){
    return h('span', params)
    }
    })
    }
    // demo.vue
    const XXX = useXXX('我是组件内容')

    <template>
    <XXX></XXX>
    </template>
    🌟 这里我们通过一个useXXX()方法来创建一个XXX组件,并直接在界面上使用,如果span替换为其他自定义组件,然后接收其他的一些参数,那么就可以实现高阶组件的效果!

👉 这个setup函数还可以返回一个渲染函数,也就是h函数的引用,因为在vue中将会使用这个h函数来创建一个vnode组件,这与直接通过template➕对象来创建一个vnode的方式,是一致的,在defineComponent方法中,返回一个渲染函数,将会替代掉原本使用template与options的其他属性,因为返回一个渲染函数就h是当前组件的最终结果了,执行结果是一个vnode对象,与使用template以及options对象的目标是一致的,而且效率可能还更高!!

因为这个defineComponent()的实现如下:

1
2
3
4
5
6
// src/.../apiDefineComponent.ts 第301行
export function defineComponent(options: unknown, extraOptions?: ComponentOptions){
return isFunction(options) ?
extend({name: options.name, extraOptions, { setup: options }})
: options
}