Skip to content

渲染函数

在绝大多数情况下,Vue 推荐使用模板语法来创建应用。然而在某些使用场景下,我们真的需要用到 JavaScript 完全的编程能力。这时渲染函数就派上用场了。

创建 VNode

Vue 提供了一个 h() 函数用于创建 VNode:

ts
import { h } from 'vue'

const vnode = h(
  'div', // type
  { id: 'foo', class: 'bar' }, // props
  [
    /* children */
  ],
)

一个更准确的名称应该是 createVNode(),但当你需要多次使用渲染函数时,一个简短的名字会更省力。

h(type,props?,childrenOrSlots?)

ts
h(type,props?,childrenOrSlots?)

type:标签名 / 组件

  • string:HTML 元素名称

props:属性、props、事件等

  • Object

childrenOrSlots:子节点

第三个参数是对象时,表示slots;key 是插槽名,value 必须是返回 VNode 的函数。

  • string
  • VNode
  • Array<VNode>
  • Slots
ts
[name: string]:  ()=>VNode
ts
h('div')
h('div', { id: 'foo' })

// attribute 和 property 都能在 prop 中书写
// Vue 会自动将它们分配到正确的位置
h('div', { class: 'bar', innerHTML: 'hello' })

// 像 `.prop` 和 `.attr` 这样的属性修饰符
// 可以分别通过 `.` 和 `^` 前缀来添加
h('div', { '.name': 'some-name', '^width': '100' })

// 类与样式可以像在模板中一样
// 用数组或对象的形式书写
h('div', { class: [foo, { bar }], style: { color: 'red' } })

// 事件监听器应以 onXxx 的形式书写
h('div', { onClick: () => {} })

// children 可以是一个字符串
h('div', { id: 'foo' }, 'hello')
ts
h(MyCard, null, {
  default: () => h('p', '正文内容'),
  footer: () => h('button', '确认'),
})

h(type,childrenOrSlots)

ts
// 没有 props 时可以省略不写
h('div', 'hello')
h('div', [h('span', 'hello')])

// children 数组可以同时包含 vnodes 与字符串
h('div', ['hello', h('span', 'hello')])

声明渲染函数

我们可以在 setup 钩子将渲染函数返回,渲染函数是交给 vue 重复运行的,因此必须返回一个 Function:

ts
import { ref, h } from 'vue'

export default {
  props: {
    /* ... */
  },
  setup(props) {
    const count = ref(1)

    // 返回渲染函数
    return () => h('div', props.msg + count.value)
  },
}

除了返回一个 vnode,你还可以返回字符串或数组:

ts
export default {
  setup() {
    return () => 'hello world!'
  },
}
ts
import { h } from 'vue'

export default {
  setup() {
    // 使用数组返回多个根节点
    return () => [h('div'), h('div'), h('div')]
  },
}

请确保返回的是一个函数而不是一个值

setup() 函数在每个组件中只会被调用一次,如果你直接返回一个值(比如 VNode)而不是函数。那么 Vue 就没有一个“可重复执行的渲染逻辑”,无法根据响应式数据变化重新渲染。

VNode 必须唯一

VNode 必须唯一,每次出现都必须是新的实例。不能重复使用同一个 VNode 对象。

VNode 和实际 DOM 节点是一一对应的,同一个 VNode 实例只能对应一个 DOM 位置,组件树中的 vnode 必须是唯一的。下面是错误示范:

ts
function render() {
  const p = h('p', 'hi')
  return h('div', [
    // 重复的 vnodes 是无效的
    p,
    p,
  ])
}

Vue 无法把它映射为两个不同的 DOM <p>,因为:

  • 它没有两个 VNode
  • 没有两个独立的状态
  • diff 算法无法区分它们
  • 会破坏整棵 VDOM 树的结构一致性

所以复用一个 VNode 实例两次是被禁止的。正确的做法:

ts
function render() {
  return h('div', [h('p', 'hi'), h('p', 'hi')])
}

JSX/TSX

VNode 还支持 JSX:

jsx
const vnode = <div>hello</div>

如果你想在 vue 中使用 jsx 则需要进行配置

虽然最早是由 React 引入,但实际上 JSX 语法并没有定义运行时语义,并且能被编译成各种不同的输出形式。如果你之前使用过 JSX 语法,那么请注意 Vue 的 JSX 转换方式与 React 中 JSX 的转换方式不同,因此你不能在 Vue 应用中使用 React 的 JSX 转换。与 React JSX 语法的一些明显区别包括:

  • 可以使用 HTML attributes 比如 classfor 作为 props - 不需要使用 classNamehtmlFor
  • 传递子元素给组件 (比如 slots) 的方式不同

Vue 的类型定义也提供了 TSX 语法的类型推导支持。当使用 TSX 语法时,确保在 tsconfig.json 中配置了 "jsx": "preserve",这样的 TypeScript 就能保证 Vue JSX 语法转换过程中的完整性。

渲染函数实例

v-if

vue
<div>
  <div v-if="ok">yes</div>
  <span v-else>no</span>
</div>
ts
h('div', [ok.value ? h('div', 'yes') : h('span', 'no')])
tsx
<div>{ok.value ? <div>yes</div> : <span>no</span>}</div>

v-for

vue
<ul>
  <li v-for="{ id, text } in items" :key="id">
    {{ text }}
  </li>
</ul>
ts
h(
  'ul',
  items.value.map(({ id, text }) => {
    return h('li', { key: id }, text)
  }),
)
tsx
<ul>
  {items.value.map(({ id, text }) => {
    return <li key={id}>{text}</li>
  })}
</ul>

v-on

on 开头,并跟着大写字母的 props 会被当作事件监听器。比如,onClick 与模板中的 @click 等价。

ts
h(
  'button',
  {
    onClick(event) {
      /* ... */
    },
  },
  'Click Me',
)
tsx
<button
  onClick={(event) => {
    /* ... */
  }}
>
  Click Me
</button>

事件修饰符

对于 .passive.capture.once 事件修饰符,可以使用驼峰写法将他们拼接在事件名后面:

js
h('input', {
  onClickCapture() {
    /* 捕捉模式中的监听器 */
  },
  onKeyupOnce() {
    /* 只触发一次 */
  },
  onMouseoverOnceCapture() {
    /* 单次 + 捕捉 */
  },
})
jsx
<input
  onClickCapture={() => {}}
  onKeyupOnce={() => {}}
  onMouseoverOnceCapture={() => {}}
/>

对于事件和按键修饰符,可以使用 withModifiers 函数:

ts
import { withModifiers } from 'vue'

h('div', {
  onClick: withModifiers(() => {}, ['self']),
})
tsx
<div onClick={withModifiers(() => {}, ['self'])} />

组件

在给组件创建 vnode 时,传递给 h() 函数的第一个参数应当是组件的定义。这意味着使用渲染函数时不再需要注册组件了 —— 可以直接使用导入的组件:

ts
import Foo from './Foo.vue'
import Bar from './Bar.jsx'

function render() {
  return h('div', [h(Foo), h(Bar)])
}
tsx
function render() {
  return (
    <div>
      <Foo />
      <Bar />
    </div>
  )
}

如果一个组件是用名字注册的,不能直接导入 (例如,由一个库全局注册),可以使用 resolveComponent() 来解决这个问题。

ts
import TDesign from 'tdesign-vue-next'

const app = createApp(App)
app.use(TDesign) // 内部注册了 t-button、t-table 等组件
app.component('MyComp', MyComp)
vue
<!--因为模板编译阶段会按名字自动解析组件-->
<t-button theme="primary">按钮</t-button>

使用渲染函数时,我们跳过了模板编译阶段,因此 h 无法直接使用。实际上模板编译时帮助我们调用了resolveComponent

错误示范:

ts
import { h } from 'vue'

export default {
  setup() {
    return () => h('t-button', { theme: 'primary' }, '按钮')
  }
}

正确示范:

ts
import { h, resolveComponent } from 'vue'

export default {
  setup() {
    const TButton = resolveComponent('t-button')

    return () => h(TButton, { theme: 'primary' }, '按钮')
  },
}

内置组件

诸如 <KeepAlive><Transition><TransitionGroup><Teleport><Suspense>内置组件在渲染函数中必须导入才能使用:

ts
import { h, KeepAlive, Teleport, Transition, TransitionGroup } from 'vue'

export default {
  setup() {
    return () => h(Transition, { mode: 'out-in' } /* ... */)
  },
}

插槽

理解

提示

插槽的本质不是“内容”,而是一段可以延迟执行的渲染逻辑

原生元素或普通 vnode 传子节点时,children 就是一个 VNode / VNode 数组

ts
h('div', null, [h('span', 'A'), h('span', 'B')])

组件不接收children,而是接收 插槽(slots)

ts
// ❌ 这是给元素用的,不是给组件用的
h(MyComp, null, [h('span', '内容')])

默认插槽:

ts
h(MyComp, null, () => [h('span', '内容')])

多个插槽:

ts
h(MyComp, null, {
  default: () => h('span', '默认插槽'),
  footer: () => h('div', '底部插槽'),
})

定义插槽

在渲染函数中,插槽可以通过 setup() 的上下文来访问。每个 slots 对象中的插槽都是一个返回 vnodes 数组的函数

ts
export default {
  props: ['message'],
  setup(props, { slots }) {
    return () => [
      // 默认插槽:
      // <div><slot /></div>
      h('div', slots.default()),

      // 具名插槽:
      // <div><slot name="footer" :text="message" /></div>
      h(
        'div',
        slots.footer({
          text: props.message,
        }),
      ),
    ]
  },
}
tsx
// 默认插槽
<div>{slots.default()}</div>

// 具名插槽
<div>{slots.footer({ text: props.message })}</div>

传递插槽

ts
// 单个默认插槽
h(MyComponent, () => 'hello')

// 具名插槽
// 注意 `null` 是必需的
// 以避免 slot 对象被当成 prop 处理
h(MyComponent, null, {
  default: () => 'default slot',
  foo: () => h('div', 'foo'),
  bar: () => [h('span', 'one'), h('span', 'two')],
})
tsx
// 默认插槽
<MyComponent>{() => 'hello'}</MyComponent>

// 具名插槽
<MyComponent>{{
  default: () => 'default slot',
  foo: () => <div>foo</div>,
  bar: () => [<span>one</span>, <span>two</span>]
}}</MyComponent>
ts
// 子组件
export default {
  setup(props, { slots }) {
    const text = ref('hi')
    return () => h('div', null, slots.default({ text: text.value }))
  },
}
tsx
<MyComponent>
  {{
    default: ({ text }) => <p>{text}</p>,
  }}
</MyComponent>

v-model

v-model 是语法糖,在渲染函数中我们必须手动实现:

vue
<script setup>
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  //触发父组件的update:modelValue事件
  <SomeComponent
    :model-value="modelValue"
    @update:model-value="(val) => emit('update:modelValue', val)"
  />
</template>
ts
export default {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  setup(props, { emit }) {
    return () =>
      h(SomeComponent, {
        modelValue: props.modelValue,
        'onUpdate:modelValue': (value) => emit('update:modelValue', value),
      })
  },
}

自定义指令

可以使用 withDirectives 将自定义指令应用于 vnode:

ts
import { h, withDirectives } from 'vue'

// 自定义指令
const pin = {
  mounted() {
    /* ... */
  },
  updated() {
    /* ... */
  },
}

// <div v-pin:top.animate="200"></div>
const vnode = withDirectives(h('div'), [[pin, 200, 'top', { animate: true }]])

当一个指令是以名称注册并且不能被直接导入时,可以使用 resolveDirective 函数来解决这个问题。

模板引用

模板引用是通过将字符串值作为 prop 传递给 vnode 创建的:

ts
import { h, useTemplateRef } from 'vue'

export default {
  setup() {
    const divEl = useTemplateRef('my-div')

    // <div ref="my-div">
    return () => h('div', { ref: 'my-div' })
  },
}