Vue 基础体系 · 第 28/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 渲染函数与 JSX:VNode、h、Slots、事件和适用边界

在 Vue 3 中,模板、渲染函数和 JSX 最终都要完成同一件事:根据组件状态生成一棵 VNode 树,再由 Vue 将这棵树与上一次渲染结果比较,最后更新真实 DOM 或子组件。

可以把一次渲染抽象为:

statetrenderVNodeTreetpatchDOMt\text{state}_t \xrightarrow{\text{render}} \text{VNodeTree}_t \xrightarrow{\text{patch}} \text{DOM}_t

其中:

  • state_t 是当前响应式状态;
  • render 是模板编译结果、手写渲染函数或 JSX 编译结果;
  • VNodeTree_t 是当前虚拟节点树;
  • patch 是 Vue 根据新旧 VNode 的差异执行更新的过程。

渲染函数与 JSX 的核心价值,不是“换一种写 HTML 的语法”,而是直接参与 VNode 的构造。当模板语法难以表达动态组件、递归结构、可配置插槽或复杂节点组合时,它们可以提供更直接的控制能力。


一、VNode 是什么

1. VNode 是对渲染结果的描述

VNode,即 Virtual Node,虚拟节点,是一个普通 JavaScript 对象,用来描述某个节点应当如何被渲染。

例如:

const vnode = h(
  'button',
  {
    class: 'primary',
    disabled: false,
    onClick: () => {
      console.log('clicked')
    }
  },
  '保存'
)

它表达的意图大致是:

<button class="primary">保存</button>

vnode 本身不是 HTMLButtonElement,也不是已经插入文档的 DOM 节点。它只是描述:

  • 节点类型是 button
  • 节点属性中有 classdisabled
  • 节点绑定了一个点击事件;
  • 子节点是文本 保存

Vue 后续会读取这个描述,并创建或更新真实 DOM。

2. VNode 与 DOM 的区别

下面三者必须区分:

const vnode = h('div', 'hello')
const element = document.createElement('div')
element.textContent = 'hello'
  • vnode 是 Vue 的中间描述;
  • element 是浏览器中的真实 DOM;
  • render(vnode, container) 执行后,VNode 才会影响 DOM。

如果需要访问实际元素,应当使用模板引用、ref 或组件实例暴露的 API,而不是把 VNode 当作 DOM:

const vnode = h('input')

// 错误:VNode 不是 HTMLElement
// vnode.focus()

// 正确:在组件中使用模板引用
const input = ref<HTMLInputElement | null>(null)

onMounted(() => {
  input.value?.focus()
})

3. VNode 不是可随意复用的 DOM 模板对象

一个 VNode 通常应当只出现在一次渲染结果中。不要把同一个 VNode 对象重复放入多个位置:

const child = h('span', '文本')

// 不应这样做
return h('div', [child, child])

正确做法是每次创建独立的 VNode:

return h('div', [
  h('span', '文本'),
  h('span', '文本')
])

原因是 Vue 的更新过程会为 VNode 建立与实际节点相关的内部关系。重复使用同一个 VNode 可能导致运行时警告、更新异常或节点移动行为不符合预期。

4. VNode 的常见类型

h 的第一个参数可以是多种类型:

h('div')                 // 原生元素
h(MyComponent)           // Vue 组件
h(resolveComponent('X')) // 运行时解析出的组件
h(Fragment, [...])       // Fragment
h(Text, '文本')          // 文本节点

工程代码中最常见的是:

  • 字符串:原生 HTML 元素名;
  • 组件对象:一个已导入的 Vue 组件;
  • 动态组件引用:根据条件选择的组件。

二、渲染函数如何参与组件生命周期

Vue 组件的 setup 可以返回一个渲染函数:

import { ref, h } from 'vue'

export default {
  setup() {
    const count = ref(0)

    return () =>
      h('button', {
        onClick: () => {
          count.value++
        }
      }, `点击次数:${count.value}`)
  }
}

组件首次挂载时,Vue 执行渲染函数:

count.value === 0
        │
        ▼
h('button', ..., '点击次数:0')
        │
        ▼
创建 button,并插入 DOM

点击按钮后:

count.value++
        │
        ▼
触发该组件的响应式渲染副作用
        │
        ▼
重新执行渲染函数
        │
        ▼
得到新的 VNode:'点击次数:1'
        │
        ▼
比较新旧 VNode
        │
        ▼
只更新文本节点

渲染函数不是只执行一次的初始化函数。只要渲染函数读取了响应式数据,该数据发生变化时,Vue 就会重新执行渲染函数。

不要在渲染函数外提前读取响应式值

const count = ref(0)

// 错误示例:这里只读取一次
const text = `点击次数:${count.value}`

return () => h('div', text)

此时 text 是普通字符串。即使 count.value 改变,渲染函数也没有读取新的 count.value,因此不会得到更新后的文本。

应该在渲染函数执行期间读取:

return () => h('div', `点击次数:${count.value}`)

或者使用计算属性:

const label = computed(() => `点击次数:${count.value}`)

return () => h('div', label.value)

三、h 的参数和子节点规则

h 是 Vue 提供的 VNode 创建函数。常见形式如下:

h(type, propsOrChildren?, children?)

其中:

  • type:节点类型;
  • propsOrChildren:属性对象或子节点;
  • children:子节点。

1. 只传类型

h('div')

等价于创建一个没有属性和子节点的 div

2. 传属性

h('button', {
  id: 'save-button',
  class: 'primary',
  disabled: true
})

3. 传文本子节点

h('p', '保存成功')

因为第二个参数是字符串,Vue 会把它识别为子节点,而不是属性对象。

4. 同时传属性和子节点

h(
  'button',
  {
    class: 'primary',
    type: 'button'
  },
  '保存'
)

5. 传多个子节点

h('ul', [
  h('li', { key: 1 }, '第一项'),
  h('li', { key: 2 }, '第二项')
])

数组中的元素可以是:

  • 字符串;
  • 数字;
  • VNode;
  • 条件表达式返回的 null
  • map 创建的 VNode 数组。

例如:

const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
]

return () =>
  h(
    'ul',
    users.map(user =>
      h('li', { key: user.id }, user.name)
    )
  )

这里 key 不是 HTML 属性,而是 Vue 用于识别同级节点身份的更新提示。

6. 为什么列表需要稳定的 key

假设旧列表为:

[A, B, C]

新列表为:

[B, C, D]

有稳定 key 时,Vue 可以推断:

A 被移除
B 保留并移动
C 保留并移动
D 新增

如果使用数组下标作为 key:

items.map((item, index) =>
  h('li', { key: index }, item.name)
)

插入或删除中间项时,节点身份可能被错误复用。尤其当列表项包含输入框、组件内部状态或 DOM 状态时,用户会看到输入内容“跑到另一行”等问题。

稳定 key 的条件是:

  1. 在同一个列表的同级范围内唯一;
  2. 在项目生命周期内尽可能稳定;
  3. 不应随着数组位置变化而变化。

四、属性、DOM Property、组件 Props 和事件

VNode 的第二个参数通常叫 props,但它不仅对应 HTML attribute。Vue 会根据节点类型和属性名称,将它们应用到 DOM 或组件上。

1. 原生元素属性

h('input', {
  id: 'username',
  class: ['field', { invalid: true }],
  placeholder: '请输入用户名',
  value: 'Alice'
})

Vue 会对这些值进行适当处理。class 可以是字符串、数组或对象;style 也可以是对象:

h('div', {
  style: {
    color: 'red',
    fontSize: '14px'
  }
}, '警告')

2. 布尔属性

h('button', {
  disabled: isSaving.value
}, '保存')

isSaving.valuetrue 时,按钮被禁用;为 false 时,Vue 会移除或关闭对应状态。

3. 组件 Props

h(UserCard, {
  name: 'Alice',
  age: 30
})

如果 UserCard 声明了:

const props = defineProps<{
  name: string
  age: number
}>()

那么这些值会作为组件 props 进入组件。

原生元素的属性和组件 props 的处理路径不同:

h('button', { disabled: true })
        │
        ▼
设置 DOM property / attribute

h(UserCard, { name: 'Alice' })
        │
        ▼
创建组件实例,并填充 props

4. 事件名使用 on 前缀

Vue 3 的渲染函数中,原生 DOM 事件通常写成:

h('button', {
  onClick: handleClick
}, '点击')

而不是 Vue 2 时代常见的:

// Vue 3 渲染函数中不要使用这种写法
h('button', {
  on: {
    click: handleClick
  }
})

onClickClick 对应 DOM 事件名 click。常见写法包括:

h('input', {
  onInput: (event) => {
    const target = event.target as HTMLInputElement
    console.log(target.value)
  },
  onFocus: () => {},
  onKeydown: (event: KeyboardEvent) => {
    if (event.key === 'Enter') {
      // 提交
    }
  }
})

5. 事件修饰符需要手动表达

模板可以写:

<button @click.stop.prevent="submit">
  提交
</button>

渲染函数中没有直接的 .stop.prevent 语法,需要在处理器中明确调用:

h('button', {
  onClick: (event: MouseEvent) => {
    event.stopPropagation()
    event.preventDefault()
    submit()
  }
}, '提交')

键盘修饰符也需要手动判断:

h('input', {
  onKeydown: (event: KeyboardEvent) => {
    if (event.key !== 'Enter') return
    submit()
  }
})

这不是 Vue 运行时缺少事件能力,而是因为修饰符原本由模板编译器翻译为事件处理逻辑;手写渲染函数时,调用者需要自己提供这段逻辑。


五、组件事件与 emits 的边界

原生元素事件和组件事件在表面上都使用 onXxx,但含义不同。

1. 原生元素事件

h('button', {
  onClick: () => console.log('DOM click')
}, '按钮')

这里 onClick 最终会被注册为 DOM 点击监听器。

2. 组件自定义事件

子组件:

import { h } from 'vue'

export default {
  emits: ['change'],

  setup(_, { emit }) {
    return () =>
      h('button', {
        onClick: () => emit('change', 'new-value')
      }, '改变值')
  }
}

父组件:

h(ChildComponent, {
  onChange: (value: string) => {
    console.log(value)
  }
})

组件发出:

emit('change', value)

父组件接收:

onChange

也就是说,Vue 将事件名 change 映射为组件监听 prop onChange

3. v-model 对应的事件

Vue 3 中,默认的 v-model 约定是:

  • prop:modelValue
  • 事件:update:modelValue

使用 h 时:

h(CustomInput, {
  modelValue: value.value,
  'onUpdate:modelValue': (nextValue: string) => {
    value.value = nextValue
  }
})

这里属性名包含 :,因此在对象字面量中必须使用引号。

如果组件有命名模型,例如:

<MyInput v-model:title="title" />

对应的渲染函数形式通常是:

h(MyInput, {
  title: title.value,
  'onUpdate:title': (nextTitle: string) => {
    title.value = nextTitle
  }
})

4. 声明 emits 的意义

emits 不只是类型说明,它还影响:

  • 组件哪些事件是正式对外接口;
  • 事件监听器是否应被当作组件事件处理;
  • 未声明事件在属性透传中的行为;
  • 开发环境下的校验和警告。

TypeScript 项目中可以使用:

const emit = defineEmits<{
  change: [value: string]
}>()

这会让:

emit('change', 'Alice')

获得参数类型检查。


六、Slots:把 VNode 生产逻辑传给子组件

Slot 不是已经生成好的静态 HTML,而通常是一组由父组件提供、由子组件在渲染时调用的函数。

可以将默认插槽抽象为:

type Slot = (...args: any[]) => VNode[]

实际类型由 Vue 定义,但“插槽是延迟执行的渲染函数”是理解其行为的关键。

1. 使用 h 传递默认插槽

子组件:

import { h } from 'vue'

export default {
  setup(_, { slots }) {
    return () =>
      h('section', { class: 'panel' }, [
        h('header', '面板标题'),
        h('main', slots.default ? slots.default() : [])
      ])
  }
}

父组件:

h(Panel, null, {
  default: () => h('p', '这是面板内容')
})

第三个参数是一个插槽对象:

{
  default: () => VNode
}

这里使用 null 是为了明确表示:第二个参数没有 props,第三个参数才是 slots。

2. 具名插槽

子组件:

import { h } from 'vue'

export default {
  setup(_, { slots }) {
    return () =>
      h('article', [
        h('header', slots.header?.()),
        h('main', slots.default?.()),
        h('footer', slots.footer?.())
      ])
  }
}

父组件:

h(ArticleLayout, null, {
  header: () => h('h1', '文章标题'),
  default: () => h('p', '正文'),
  footer: () => h('small', '页脚')
})

如果插槽可能不存在,应使用可选调用或默认空数组:

slots.footer?.() ?? []

这样可以避免把 undefined 直接作为不符合预期的子节点传递下去。

3. 作用域插槽

子组件可以在调用插槽时传入参数:

import { h } from 'vue'

export default {
  setup(_, { slots }) {
    const user = {
      name: 'Alice',
      role: 'admin'
    }

    return () =>
      h('div', [
        slots.default?.(user)
      ])
  }
}

父组件传入的插槽函数接收这个参数:

h(UserPanel, null, {
  default: (user: { name: string; role: string }) =>
    h('p', `${user.name}:${user.role}`)
})

数据流是:

子组件准备 user
        │
        ▼
调用 slots.default(user)
        │
        ▼
执行父组件提供的渲染函数
        │
        ▼
得到使用 user 的 VNode

这与模板中的:

<template #default="{ name, role }">
  <p>{{ name }}:{{ role }}</p>
</template>

表达的是同一类关系。

4. 插槽必须在渲染上下文中调用

插槽函数可能读取父组件的响应式状态,因此应在子组件的渲染函数执行期间调用:

return () => h('div', slots.default?.())

不要在 setup 初始化时提前调用:

// 不推荐
const content = slots.default?.()

return () => h('div', content)

提前调用会使插槽脱离正常的渲染依赖追踪上下文,可能出现更新不及时或开发环境警告。插槽的正确生命周期是:子组件每次需要渲染插槽时调用插槽函数,由父组件的渲染作用域生成最新 VNode。


七、JSX 是如何进入 Vue 的

JSX 是一种 JavaScript 语法扩展:

const vnode = <button>保存</button>

它本身不是浏览器原生语法,也不是 Vue 运行时直接解析的字符串。Vite 会通过 JSX 插件将其编译为创建 VNode 的代码。

在 Vue 3 + Vite + TypeScript 项目中,安装:

npm install vue
npm install -D vite @vitejs/plugin-vue @vitejs/plugin-vue-jsx typescript vue-tsc

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

export default defineConfig({
  plugins: [
    vue(),
    vueJsx()
  ]
})

tsconfig.json 至少需要允许 TypeScript 处理 JSX:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "jsx": "preserve",
    "jsxImportSource": "vue",
    "types": ["vite/client"]
  }
}

对于 Vue JSX,jsxImportSource: "vue" 让 TypeScript 使用 Vue 的 JSX 类型定义。具体字段可能因项目已有的 Vite 模板和 TypeScript 版本不同而存在差异,但 @vitejs/plugin-vue-jsx 是 Vue 3 JSX 的关键编译插件。


八、JSX 与模板、h 的对应关系

1. 原生元素

JSX:

const vnode = <button class="primary">保存</button>

渲染函数:

const vnode = h(
  'button',
  { class: 'primary' },
  '保存'
)

模板:

<button class="primary">保存</button>

三者最终都产生类似的 VNode 结构。

2. 动态内容

JSX 中使用花括号插入 JavaScript 表达式:

const count = ref(0)

return () => (
  <button onClick={() => count.value++}>
    点击次数:{count.value}
  </button>
)

不能把模板语法直接写进 JSX:

// 错误
<button v-if={visible}>按钮</button>

Vue JSX 不支持把模板指令当作普通 JSX 属性使用。应该使用 JavaScript 条件表达式:

return () => (
  <div>
    {visible.value ? <button>按钮</button> : null}
  </div>
)

3. classstyle

return () => (
  <div
    class={['card', { active: active.value }]}
    style={{
      color: active.value ? 'green' : 'gray',
      marginTop: '8px'
    }}
  >
    内容
  </div>
)

这里的写法与 Vue 运行时对 classstyle 的处理规则一致。

4. 事件

return () => (
  <button onClick={handleClick}>
    保存
  </button>
)

键盘事件:

return () => (
  <input
    onKeydown={(event) => {
      if (event.key === 'Enter') {
        submit()
      }
    }}
  />
)

事件修饰符不能照搬模板:

// 不应把模板修饰符当成 JSX 语法
<button onClick.stop={submit}>提交</button>

应当显式处理:

<button
  onClick={(event) => {
    event.stopPropagation()
    event.preventDefault()
    submit()
  }}
>
  提交
</button>

九、JSX 中的组件和插槽

1. 使用组件

import UserCard from './UserCard.vue'

export default defineComponent({
  setup() {
    return () => (
      <UserCard
        name="Alice"
        age={30}
      />
    )
  }
})

组件名必须是 JavaScript 作用域中可访问的变量:

import UserCard from './UserCard.vue'

不能像模板那样依赖字符串形式的自动解析:

// 这不是动态组件的通用写法
<UserCard />

如果组件是动态的,可以直接使用变量:

const currentComponent = computed(() =>
  activeTab.value === 'users' ? UserList : SettingsPanel
)

return () => h(currentComponent.value)

或在 JSX 中:

return () => {
  const Component = currentComponent.value
  return <Component />
}

2. 默认插槽

import Panel from './Panel.vue'

return () => (
  <Panel>
    <p>面板内容</p>
  </Panel>
)

在 Vue JSX 中,更复杂的插槽可以使用 v-slots

return () => (
  <Panel
    v-slots={{
      default: () => <p>正文</p>,
      header: () => <h2>标题</h2>,
      footer: () => <small>页脚</small>
    }}
  />
)

v-slots 是 Vue JSX 插件提供的 JSX 形式,用于明确传递插槽对象。

也可以直接使用函数子节点表达默认插槽,但在包含具名插槽、作用域插槽或复杂条件时,v-slots 更清晰:

return () => (
  <UserList
    v-slots={{
      item: (user: User) => (
        <strong>{user.name}</strong>
      )
    }}
  />
)

对应子组件可能这样调用:

import { defineComponent } from 'vue'

export default defineComponent({
  props: {
    users: {
      type: Array,
      required: true
    }
  },

  setup(props, { slots }) {
    return () => (
      <ul>
        {(props.users as User[]).map(user => (
          <li key={user.id}>
            {slots.item?.(user) ?? user.name}
          </li>
        ))}
      </ul>
    )
  }
})

十、一个可运行的 TypeScript JSX 示例

下面实现一个支持:

  • 响应式搜索;
  • 列表渲染;
  • 稳定 key;
  • 作用域插槽;
  • 原生输入事件;
  • 组件自定义事件;

的最小示例。

1. UserList.tsx

import {
  defineComponent,
  PropType,
  type VNodeChild
} from 'vue'

export interface User {
  id: number
  name: string
  role: string
}

export default defineComponent({
  name: 'UserList',

  props: {
    users: {
      type: Array as PropType<User[]>,
      required: true
    }
  },

  emits: {
    select: (user: User) => Boolean(user && user.id > 0)
  },

  setup(props, { emit, slots }) {
    return () => (
      <ul>
        {props.users.map(user => (
          <li key={user.id}>
            <button
              type="button"
              onClick={() => emit('select', user)}
            >
              {slots.item?.(user) ?? (
                <span>
                  {user.name}({user.role})
                </span>
              )}
            </button>
          </li>
        ))}
      </ul>
    )
  }
})

这里有几个关键点:

  • PropType<User[]> 用于让运行时 props 声明与 TypeScript 类型结合;
  • key={user.id} 保持列表项身份稳定;
  • slots.item?.(user) 调用作用域插槽;
  • emit('select', user) 触发组件事件;
  • 父组件对应监听器名称是 onSelect

VNodeChild 在某些复杂插槽类型声明中有用,但本示例依赖 Vue 对 JSX 返回值的推断,不需要显式使用它。

2. App.tsx

import {
  defineComponent,
  computed,
  ref
} from 'vue'
import UserList, { type User } from './UserList'

export default defineComponent({
  name: 'App',

  setup() {
    const keyword = ref('')
    const selectedUser = ref<User | null>(null)

    const users = ref<User[]>([
      { id: 1, name: 'Alice', role: '管理员' },
      { id: 2, name: 'Bob', role: '编辑' },
      { id: 3, name: 'Carol', role: '访客' }
    ])

    const filteredUsers = computed(() => {
      const normalized = keyword.value.trim().toLowerCase()

      if (!normalized) {
        return users.value
      }

      return users.value.filter(user =>
        user.name.toLowerCase().includes(normalized) ||
        user.role.toLowerCase().includes(normalized)
      )
    })

    const handleSelect = (user: User) => {
      selectedUser.value = user
    }

    return () => (
      <main>
        <h1>用户列表</h1>

        <input
          value={keyword.value}
          placeholder="搜索姓名或角色"
          onInput={(event) => {
            const target = event.target as HTMLInputElement
            keyword.value = target.value
          }}
        />

        <UserList
          users={filteredUsers.value}
          onSelect={handleSelect}
          v-slots={{
            item: (user: User) => (
              <span>
                <strong>{user.name}</strong>
                <small> · {user.role}</small>
              </span>
            )
          }}
        />

        {selectedUser.value ? (
          <p>
            当前选择:{selectedUser.value.name}
          </p>
        ) : (
          <p>尚未选择用户</p>
        )}
      </main>
    )
  }
})

运行过程如下:

  1. keyword 初始为空,filteredUsers 返回全部用户;
  2. 输入框触发 onInput
  3. 事件处理器从 event.target 读取新值并写入 keyword.value
  4. 计算属性重新计算筛选结果;
  5. App 的渲染函数重新执行;
  6. UserList 接收新的 users
  7. 列表根据 id 对比新旧 VNode;
  8. 点击某个用户时,子组件发出 select
  9. 父组件的 onSelect 更新 selectedUser
  10. 页面底部只需更新当前选择文本。

3. 运行方式

标准 Vite Vue 项目中,确保入口正常挂载:

// main.ts
import { createApp } from 'vue'
import App from './App'

createApp(App).mount('#app')

然后执行:

npm run dev

预期行为:

  • 页面显示三个用户;
  • 输入 管理员 后只显示 Alice;
  • 点击用户后,底部显示当前选择的用户;
  • 通过 v-slots 可以改变列表项的显示结构,而不修改 UserList 的遍历逻辑。

十一、模板、渲染函数和 JSX 的关系

1. 模板不是另一套运行机制

模板:

<button
  :class="{ active }"
  @click="count++"
>
  {{ count }}
</button>

编译器会把它转换为与下面类似的渲染逻辑:

return () =>
  h('button', {
    class: { active: active.value },
    onClick: () => count.value++
  }, String(count.value))

真实编译产物会包含块树、静态提升、patch flag 等优化信息,不能简单等同于上面的手写代码,但两者的基本目标一致:生成 VNode 并让 Vue 执行更新。

2. JSX 也不是字符串模板

JSX:

return () => (
  <button onClick={increment}>
    {count.value}
  </button>
)

通过 JSX 转换后,大致会变成:

return () =>
  createVNode(
    'button',
    { onClick: increment },
    String(count.value)
  )

具体生成函数名和辅助代码由 JSX 编译器决定,开发者不应依赖未公开的编译产物细节。

3. 三种方式的抽象层次

方式 主要抽象 优势 主要代价
模板 声明式视图语言 可读性好,编译器优化充分 动态结构和 JavaScript 控制能力受模板语法约束
渲染函数 直接构造 VNode 控制精确,依赖少 代码冗长,容易手写错误
JSX JavaScript 中的 VNode 表达式 组件组合和条件逻辑自然 需要构建配置,模板用户不熟悉时可读性下降

“JSX 更快”或“渲染函数更高级”都不是普遍成立的结论。性能取决于节点数量、更新频率、key、静态内容、组件边界以及编译器能否做出优化。


十二、常见失败表现与诊断方法

1. 把 VNode 当成 DOM

错误:

const vnode = h('input')
vnode.focus()

失败原因是 vnode 没有浏览器 DOM API。

诊断方式:

console.log(vnode)
console.log(vnode.el)

在挂载完成前,vnode.el 通常不存在;即使某些内部阶段可能出现,也不应把它当作稳定公共 API 使用。应该使用 ref 获取元素。

2. 渲染函数没有返回 VNode

错误:

setup() {
  const count = ref(0)

  // 没有 return 渲染函数
}

或者:

return () => {
  if (!visible.value) {
    return
  }

  return h('div', '内容')
}

组件渲染函数必须返回合法渲染结果。条件为空时可以返回 null

return () => {
  if (!visible.value) {
    return null
  }

  return h('div', '内容')
}

3. 把 slots 当成已经生成的 VNode

错误:

return () => h('div', slots.default)

slots.default 是函数,不是 VNode 数组。应该调用:

return () => h('div', slots.default?.())

4. 把组件事件写成 DOM 事件配置

父组件:

h(Child, {
  onClick: handleClick
})

这是否能工作,取决于 Child 是否:

  • 声明并发出 click 事件;
  • 或者该监听器被作为未声明属性透传到了根元素。

如果希望建立明确的组件接口,应声明:

emits: ['select']

并使用:

h(Child, {
  onSelect: handleSelect
})

不要依赖根元素透传来模拟组件事件,因为子组件根节点结构变化后,行为可能改变。

5. 事件处理器没有正确读取输入值

错误:

<input onInput={(event) => {
  keyword.value = event.target.value
}} />

TypeScript 通常会提示 event.target 类型可能是 EventTarget,没有 value 属性。

正确做法:

<input onInput={(event) => {
  const target = event.target as HTMLInputElement
  keyword.value = target.value
}} />

更严格的项目也可以通过事件类型和运行时检查避免不安全断言。

6. JSX 中使用了错误的属性大小写

常见写法:

<button onClick={handleClick} />

不要写成模板字符串式的:

<button @click={handleClick} />

对于组件事件:

<MyComponent onSelect={handleSelect} />

对于 update:modelValue,在 JSX 中应根据 JSX 编译器支持的属性语法使用对应写法;使用 h 时最明确、最不容易产生解析歧义:

h(MyComponent, {
  modelValue: value.value,
  'onUpdate:modelValue': updateValue
})

项目升级或更换 JSX 转换器时,应通过实际类型检查和构建验证这类带冒号的事件属性。

7. 在 map 中忘记返回

错误:

<ul>
  {users.value.map(user => {
    <li key={user.id}>{user.name}</li>
  })}
</ul>

花括号函数体没有 return,结果是每一项都返回 undefined

正确写法:

<ul>
  {users.value.map(user => (
    <li key={user.id}>{user.name}</li>
  ))}
</ul>

或:

<ul>
  {users.value.map(user => {
    return <li key={user.id}>{user.name}</li>
  })}
</ul>

十三、渲染函数中的 Fragment、文本和空结果

组件渲染函数不一定只能返回单个显式元素。

1. 返回多个根节点

JSX:

return () => (
  <>
    <h1>标题</h1>
    <p>正文</p>
  </>
)

这会生成 Fragment。Vue 3 支持多根节点组件,但属性透传需要特别注意:如果组件有多个根节点,Vue 无法自动判断 $attrs 应该落到哪个根节点。

渲染函数中也可以使用数组:

return () => [
  h('h1', '标题'),
  h('p', '正文')
]

如果需要明确表达 Fragment,可以使用 Vue 提供的 Fragment

import { Fragment, h } from 'vue'

return () =>
  h(Fragment, [
    h('h1', '标题'),
    h('p', '正文')
  ])

2. 返回 null

条件不满足时:

return () => visible.value
  ? h('div', '显示')
  : null

null 表示不渲染节点,不等于渲染字符串 "null"

3. 文本节点

通常直接使用字符串即可:

h('div', 'hello')

只有在需要直接操作特殊 VNode 类型时,才会显式使用 Text。业务代码中直接传文本更清晰。


十四、指令、透传属性和特殊边界

1. JSX 不会自动获得模板指令语法

模板中的:

<div v-show="visible">内容</div>

不是普通 HTML 属性。渲染函数中应使用对应的 DOM 逻辑,或使用 withDirectives 等 Vue API:

import { h, withDirectives, vShow } from 'vue'

return () =>
  withDirectives(
    h('div', '内容'),
    [[vShow, visible.value]]
  )

这类 API 适合确实需要复用已有指令语义的场景。若只是控制显示,条件返回 null 或用 style 可能更直接,但两者与 v-show 的 DOM 保留行为不同,不能简单互换。

2. $attrs 不会自动消失

组件可以接收未声明为 props 或 emits 的属性,并将其透传到根节点。使用渲染函数时,如果关闭或自定义属性继承,需要显式处理:

import { h, mergeProps } from 'vue'

export default {
  inheritAttrs: false,

  setup(_, { attrs }) {
    return () =>
      h('button', mergeProps(
        {
          class: 'base-button'
        },
        attrs
      ), '按钮')
  }
}

mergeProps 可以合并 class、style、事件等常见属性,但合并顺序会影响最终结果,尤其是同名事件处理器。应明确哪个属性覆盖哪个属性,而不是依赖隐含顺序。

3. SSR 和水合边界

服务端渲染时,VNode 先被转换为 HTML 字符串;客户端水合时,Vue 会将客户端生成的 VNode 与服务端已有 DOM 对照。

如果渲染结果依赖不稳定数据:

return () => <p>{Math.random()}</p>

服务端和客户端很可能生成不同文本,导致水合不匹配警告或客户端重新修正。

同样的问题也可能来自:

  • 在渲染函数中读取当前时间;
  • 服务端和客户端环境变量不同;
  • 浏览器专属 API 只在客户端可用;
  • 列表排序依据不稳定;
  • 随机生成 key。

需要保证首次服务端渲染和客户端首次渲染具有一致输入;浏览器专属逻辑应放到客户端生命周期中执行。


十五、渲染函数和 JSX 的适用边界

1. 适合使用渲染函数或 JSX 的场景

动态节点结构

当节点类型、组件类型或属性结构由数据决定时:

const tag = computed(() =>
  level.value === 1 ? 'h1' : 'h2'
)

return () => {
  const Tag = tag.value
  return <Tag>{title.value}</Tag>
}

递归组件和树结构

树节点的子节点数量和类型不固定,使用 JavaScript 递归表达通常比模板嵌套更直接:

function renderNode(node: TreeNode): VNode {
  return (
    <li key={node.id}>
      <span>{node.label}</span>

      {node.children?.length
        ? <ul>{node.children.map(renderNode)}</ul>
        : null}
    </li>
  )
}

高度可配置的组件

表格列、菜单项、字段渲染器、编辑器节点等场景通常需要把“数据如何变成 VNode”作为配置传入:

interface Column<T> {
  key: string
  title: string
  render: (row: T) => VNodeChild
}

这时渲染函数或 JSX 可以让 render 直接返回节点。

需要与 JavaScript 控制流紧密结合的组件

例如:

  • 根据多个条件组合不同组件;
  • 运行时动态构造属性对象;
  • 根据权限生成不同操作节点;
  • 将函数、数据和 VNode 统一组合。

2. 不适合优先使用的场景

普通表单和页面布局

<form>
  <label>用户名</label>
  <input v-model="username" />
  <button type="submit">登录</button>
</form>

这类静态结构使用模板更易读,也更方便设计师和业务开发者维护。

依赖大量模板指令和编译器优化的页面

模板对以下内容提供了更直接的表达:

  • v-if
  • v-for
  • v-model
  • 事件修饰符;
  • v-bind
  • 模板引用;
  • 静态节点分析。

手写 JSX 可以实现相同逻辑,但需要承担更多类型、事件和节点组合细节。

团队缺乏统一 JSX 规范时

JSX 允许在视图中嵌入任意 JavaScript。如果没有约束,渲染逻辑可能混入请求、状态修改和复杂计算,最终比模板更难理解。这里的问题不是 JSX 能力不足,而是视图表达式与业务副作用的边界被破坏。


十六、性能与维护取舍

1. VNode 数量不是唯一性能指标

手写 h 或 JSX 不会自动比模板更快。性能主要受以下因素影响:

  • 每次渲染生成多少 VNode;
  • 哪些响应式数据被读取;
  • 列表是否有稳定 key;
  • 是否重复创建昂贵对象;
  • 子组件边界是否合理;
  • 更新是否集中在必要的组件范围;
  • 编译器能否识别静态节点。

例如,下面的代码在每次渲染时都会创建一个新的对象:

return () => (
  <Widget
    options={{
      dense: true,
      theme: 'light'
    }}
  />
)

这不一定是错误,因为 Vue 仍可正确更新;但如果子组件或相关逻辑依赖对象身份,可能导致额外工作。可以把真正静态的对象移到组件外:

const widgetOptions = {
  dense: true,
  theme: 'light'
}

return () => (
  <Widget options={widgetOptions} />
)

如果对象中包含响应式值,则不能简单移出:

const options = computed(() => ({
  dense: dense.value,
  theme: theme.value
}))

2. 事件处理器的新建不必被机械优化

下面的代码每次渲染都会创建闭包:

{users.value.map(user => (
  <button onClick={() => select(user)}>
    {user.name}
  </button>
))}

这是表达作用域数据最直接的方式,通常不应仅因为“创建了函数”就改写。只有在确认存在实际更新开销,并且改写后仍然保持可读性时,才需要考虑事件委托、缓存或结构调整。

3. 编译器优化边界

模板编译器可以进行静态提升、节点标记等优化。手写渲染函数和 JSX 的具体优化能力取决于 Vue JSX 转换器与构建配置,不能假设它们总能获得与模板完全相同的编译优化。

因此,性能敏感场景应通过实际构建产物、组件更新分析和运行时测量验证,而不是根据语法形式推断性能。


十七、选择方式时的判断顺序

可以按以下顺序判断:

  1. 结构是否主要由页面标记组成?
    是:优先模板。

  2. 是否需要递归、动态组件、运行时节点工厂或复杂作用域插槽?
    是:考虑渲染函数或 JSX。

  3. 团队是否已经使用 TypeScript 和 JSX 工具链?
    如果没有,优先评估构建、类型检查和调试成本。

  4. 组件对外接口是否包含复杂插槽和渲染器配置?
    是:使用 JSX 或渲染函数往往更自然,但仍应保持 props、emits、slots 接口清晰。

  5. 问题是语法表达能力,还是组件设计问题?
    如果只是组件过于庞大、状态职责混乱,换成 JSX 不会自动解决问题。


结语

VNode 是 Vue 对渲染结果的结构化描述;h 是直接创建 VNode 的 API;渲染函数是由响应式状态生成 VNode 树的函数;JSX 则是把这种 VNode 构造过程写进 JavaScript 的语法形式。

它们之间的核心关系可以概括为:

响应式状态
   │
   ▼
渲染函数 / JSX
   │
   ▼
VNode
   │
   ▼
Vue patch
   │
   ▼
DOM 或子组件更新

Slots 的本质是延迟执行的 VNode 生成函数,事件的本质是通过 onXxx 传入原生元素或组件的监听器,key 的本质是帮助 Vue 保持列表节点身份。掌握这些机制后,渲染函数和 JSX 才不只是模板的替代写法,而是处理动态组件、递归结构和可配置渲染逻辑的底层工具。

但在普通页面结构中,模板仍然通常具有更好的可读性和编译器支持。合理的边界不是“全面使用 JSX”或“完全避免渲染函数”,而是根据结构复杂度、接口形式、团队工具链和实际更新路径选择合适的表达层。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。