Vue3 还在用 template 写组件?render 函数才是进阶天花板吗?
其实这个问题得分人,小白可能觉得 template 够香够直观,甚至连 Vue 的官方文档都把 template 放在入门第一位,但中高级开发者或者做特定场景的人,一定绕不开 render 函数,今天咱们就好好聊聊,render 函数到底是什么,什么时候该用它,它真的比 template 高级吗?
template 和 render 函数到底是什么关系?
别把这俩当成对立面,它们本质上是“好兄弟——一个负责前台沟通,一个负责后台干活”。 写 Vue 组件时,你写的
<template>
<div class="hello">{{ msg }}</div>
</template>
<script setup>
import { ref } from 'vue'
const msg = ref('Hello Vue3')
</script>
然后去 Vue DevTools 里看这个组件的源码编译结果(不是你写的代码,是编译后的),就能看到一行类似 render(ctx, _cache, $props, $setup, $data, $options) 的函数,里面全是通过 Vue 的 createElement 或者 h 函数生成 VNode 的逻辑。 所以说,template 是 render 函数的“语法糖”,专门给不想写复杂 VNode 逻辑的人用的,它更符合 HTML 的书写习惯,容易上手,也能让非技术的设计师看懂并调整布局;而 render 函数是编译后的“最终形态”,没有任何语法限制,完全用 JS 控制组件的渲染,自由度拉满。
什么时候必须抛弃 template 用 render 函数?
虽然 template 方便,但它也有“力不从心”的时候,遇到以下这几种场景,你就得掏出 render 函数了:
需要动态生成复杂的、嵌套层级很深的组件或标签
比如你要做一个动态表单生成器,后端给你返回一个 JSON 配置,里面可能有普通输入框、下拉框、多选框、日期选择器,甚至还有分组嵌套的情况,每个组件的属性、事件、插槽都不一样。 如果用 template 写,你可能要写一大堆 v-if v-else-if v-for,嵌套个五六层都正常,代码可读性会变得极差,维护起来像拆炸弹;而且如果后端加了一种新的表单控件(比如评分组件),你还得去改 template 里的逻辑,加新的 v-if,很容易出错。 但用 render 函数就不一样了,你可以先写一个配置表,把每种表单控件对应的组件、默认属性、插槽映射都写进去,然后拿到后端的 JSON 后,递归遍历这个 JSON,直接调用对应的组件和 h 函数,动态生成所有的节点,这样代码逻辑清晰,加新控件只需要更新配置表,不用动核心渲染代码,扩展性非常强。
需要根据运行时的数据动态决定标签名或组件名
比如你要做一个通用的内容展示组件,后端给你返回的内容里,可能有 h1 标题、p 段落、img 图片,甚至还有你自己封装的自定义组件;而且标签名或者组件名不是固定的,是后端配置的。
用 template 的话,虽然 Vue3 提供了
需要对 VNode 进行细粒度的操作
比如你要做一个虚拟列表,虚拟列表的核心原理是只渲染可视区域内的 DOM 节点,其他节点用占位符代替,等用户滚动的时候,再动态替换占位符的内容。 用 template 写虚拟列表不是不行,但很难做到极致的性能优化,因为 template 的编译逻辑是固定的,你很难直接控制 VNode 的创建、更新、销毁过程;而且虚拟列表需要根据滚动位置计算可视区域的索引、偏移量,这些逻辑用 JS 写本来就方便,再结合 render 函数直接操作 VNode,性能会比 template 好很多。 再比如你要做一个拖拽排序组件,有时候需要在拖拽过程中临时修改某个节点的样式、位置或者属性,用 JS 直接操作 DOM 虽然可以,但 Vue 是数据驱动的,直接操作 DOM 会破坏数据和视图的一致性;但如果用 render 函数,你可以在 render 函数里根据拖拽的状态(比如当前拖拽的节点、目标位置的节点)动态生成 VNode,这样既能实现效果,又能保持数据和视图的一致性。
需要复用一些通用的渲染逻辑
比如你有好几个组件,它们的渲染逻辑很像,只是数据不一样、样式不一样或者有几个小细节不一样。 用 template 写的话,你可能会把这些组件的公共部分抽成一个插槽组件,然后每个组件调用插槽组件,通过插槽传数据、传样式,但插槽的写法有时候也比较啰嗦,而且如果公共逻辑很复杂,插槽组件的可读性也会变差。 用 render 函数的话,你可以把这些通用的渲染逻辑抽成一个高阶函数,这个高阶函数接受一些参数(比如数据、样式、细节配置),然后返回一个 render 函数,最后每个组件直接调用这个高阶函数,拿到对应的 render 函数就行,这种写法非常符合函数式编程的思想,代码复用率很高,而且逻辑也很清晰。
render 函数真的比 template 难吗?看完 h 函数的用法你就懂了
很多人不敢用 render 函数,是觉得它太复杂,其实不是的,Vue3 把 render 函数简化了很多,核心就是一个 h 函数(hyperscript 的缩写,意思是“生成超文本的脚本”),你只要搞懂 h 函数的三个参数,就能写出大部分的 render 函数了。 h 函数的完整语法是这样的:
h(type, props, children)
咱们一个个来看:
第一个参数:type
它可以是三种类型:
- 字符串:普通的 HTML 标签名,'div'、'p'、'img'、'input'。
- 组件对象:Vue3 的组件,不管是通过 defineComponent 定义的,还是通过 script setup 自动导出的,都可以直接传进去。
- 函数式组件:一个纯函数,接受 props 和 context(包含 attrs、slots、emit 等),返回一个 VNode。
第二个参数:props
它是一个对象,包含了节点的属性、事件、样式、类名、ref 等,具体有这些内容:
- 普通 HTML 属性:id、class、style、src、href、disabled、checked 等,注意 class 和 style 可以是字符串、对象或者数组,和 template 里的用法一样。
- 组件 props:比如你自己封装的按钮组件有 type、size、loading 这些 props,直接传进去就行。
- 事件监听器:需要在事件名前面加上 'on',然后首字母大写,比如点击事件是 onClick,输入事件是 onInput,键盘按下事件是 onKeydown,和 template 里的 @click、@input、@keydown 是对应的。
- 自定义事件监听器:和普通事件监听器的用法一样,也是在事件名前面加上 'on',首字母大写,@submit 对应的是 onSubmit。
- DOM 属性:有些属性是 DOM 属性,不是 HTML 属性,innerHTML、value(对于某些元素来说),可以直接传进去,或者用 innerHTML 传 HTML 字符串,Vue3 会自动处理。
- ref:可以是一个 ref 对象,也可以是一个函数,和 template 里的 ref 用法一样。
- key:对于 v-for 渲染的列表节点,必须传 key,用来提高 Vue 的 diff 算法效率,和 template 里的 :key 是对应的。
- 插槽:如果是组件的话,可以传 slots 对象,里面包含了组件的所有插槽,插槽可以是一个 VNode,也可以是一个函数(如果是作用域插槽的话)。
这里要注意,props 是可选的,如果不需要传任何属性、事件等,可以直接传 null 或者 undefined。
第三个参数:children
它可以是五种类型:
- 字符串:普通的文本节点,'Hello Vue3'。
- VNode:通过 h 函数生成的单个虚拟节点。
- VNode 数组:多个虚拟节点组成的数组,相当于 template 里的多个子节点。
- 函数:如果是组件的默认插槽,而且没有作用域的话,可以传一个函数,返回一个 VNode 或者 VNode 数组。
- 对象:如果是组件的多个插槽,可以传一个 slots 对象,和 props 里的 slots 一样。
这里也要注意,children 也是可选的,如果不需要传任何子节点,可以不传。
咱们用 h 函数改写一下开头那个最简单的组件:
import { defineComponent, ref, h } from 'vue'
export default defineComponent({
name: 'HelloWorld',
setup() {
const msg = ref('Hello Vue3')
return () => h(
'div',
{ class: 'hello' },
msg.value
)
}
})
或者用 script setup 的写法:
<script setup>
import { ref, h } from 'vue'
const msg = ref('Hello Vue3')
// 直接导出 render 函数
const render = () => h(
'div',
{ class: 'hello' },
msg.value
)
defineExpose({ render })
</script>
你看,是不是很简单?和 template 里的代码是一一对应的,只是换了一种写法而已。
用 render 函数需要注意哪些坑?
虽然 render 函数很强大,但也有一些坑需要注意,不然很容易出问题:
坑一:忘记响应式依赖
在 setup 函数里写 render 函数的时候,render 函数本身就是一个响应式函数,它会自动收集依赖,当依赖的数据变化时,会自动重新渲染;但如果你在 render 函数外面定义了一些变量,然后在 render 函数里用这些变量,那这些变量就不会被收集为依赖,就算变量变化了,组件也不会重新渲染。 比如下面这个代码就有问题:
<script setup>
import { ref, h } from 'vue'
const msg = ref('Hello Vue3')
// 这里定义了一个普通变量,不是响应式的
let text = '初始文本'
const render = () => h(
'div',
[
h('div', { class: 'hello' }, msg.value),
h('div', { class: 'text' }, text),
h('button', {
onClick: () => {
// 点击按钮修改 text 的值
text = '修改后的文本'
// 同时修改 msg 的值,看看会不会触发重新渲染
msg.value = 'Hello Vue3 Updated'
}
}, '修改文本')
]
)
defineExpose({ render })
</script>
点击按钮后,你会发现 msg.value 对应的文本变了,但 text 对应的文本还是初始文本,因为 text 不是响应式的,render 函数没有收集它作为依赖,所以即使 text 变化了,组件也不会重新渲染。 要解决这个问题,很简单,把 text 改成响应式的 ref 或者 reactive 就行:
<script setup>
import { ref, h } from 'vue'
const msg = ref('Hello Vue3')
// 改成响应式的 ref
const text = ref('初始文本')
const render = () => h(
'div',
[
h('div', { class: 'hello' }, msg.value),
h('div', { class: 'text' }, text.value),
h('button', {
onClick: () => {
text.value = '修改后的文本'
msg.value = 'Hello Vue3 Updated'
}
}, '修改文本')
]
)
defineExpose({ render })
</script>
坑二:不注意 key 的使用
在 render 函数里渲染列表的时候,必须给每个列表节点传一个唯一的 key,不然 Vue 的 diff 算法效率会很低,甚至可能会出现渲染错误。 比如下面这个代码就没有传 key:
<script setup>
import { ref, h } from 'vue'
const list = ref([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' }
])
const render = () => h(
'ul',
list.value.map(item => h(
'li',
// 这里没有传 key
item.name
))
)
defineExpose({ render })
</script>
如果我们在列表中间插入一个新的节点,Vue 的 diff 算法可能会把后面所有的节点都重新渲染一遍,性能会很差;而且如果列表节点有表单控件,插入新节点后,表单控件的状态可能会错乱。 要解决这个问题,给每个列表节点传一个唯一的 key 就行,比如用 item.id:
<script setup>
import { ref, h } from 'vue'
const list = ref([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' }
])
const render = () => h(
'ul',
list.value.map(item => h(
'li',
// 传唯一的 key
{ key: item.id },
item.name
))
)
defineExpose({ render })
</script>
坑三:混淆普通 HTML 属性和 DOM 属性
有些属性是 HTML 属性,有些是 DOM 属性,它们的名字有时候不一样,HTML 里的 class 对应的 DOM 属性是 className,HTML 里的 for 对应的 DOM 属性是 htmlFor,HTML 里的 value 对于 input 既可以是 HTML 属性(初始值),也可以是 DOM 属性(当前值)。 在 Vue3 的 render 函数里,你可以直接传 HTML 属性名,也可以传 DOM 属性名,Vue3 会自动处理,但有时候会出现问题,比如你想传一个自定义的 HTML 属性,Vue3 把它当成了 DOM 属性,或者反过来。 如果遇到这种问题,你可以用 props 里的 attrs 属性来传自定义的 HTML 属性,或者用 domProps 属性来传 DOM 属性(Vue3 已经很少需要用 domProps 了)。
坑四:作用域插槽的写法
在 render 函数里写作用域插槽,稍微有一点点复杂,需要注意插槽函数的参数。 比如你有一个子组件,它有一个 default 作用域插槽,会把 item 和 index 传给父组件:
// ChildComponent.js
import { defineComponent, h } from 'vue'
export default defineComponent({
name: 'ChildComponent',
props: {
list: {
type: Array,
default: () => []
}
},
setup(props, { slots }) {
return () => h(
'ul',
props.list.map((item, index) => h(
'li',
{ key: item.id },
// 调用 default 作用域插槽,传 item 和 index
slots.default?.({ item, index })
))
)
}
})
在父组件里用 render 函数调用这个子组件,并且使用 default 作用域插槽,写法是这样的:
<script setup>
import { ref, h } from 'vue'
import ChildComponent from './ChildComponent.js'
const list = ref([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' }
])
const render = () => h(
ChildComponent,
{ list: list.value },
{
// default 作用域插槽,接受一个参数,里面包含 item 和 index
default: ({ item, index }) => h(
'span',
`${index + 1}. ${item.name}`
)
}
)
defineExpose({ render })
</script>
这里要注意,插槽函数必须写在 children 的第三个参数里,作为一个 slots 对象的属性;而且如果子组件只有一个 default 作用域插槽,也可以直接把插槽函数作为 children 的第三个参数,不用写成 slots 对象。
render 函数和 JSX 哪个更好用?
说到这里,可能有人会问:“既然 render 函数的写法有时候也比较啰嗦,那为什么不用 JSX 呢?” JSX 其实是 React 发明的一种语法糖,后来 Vue3 也支持了,它的写法和 template 有点像,但又可以直接写 JS,比如刚才那个作用域插槽的例子,用 JSX 写就是这样的:
<script setup lang="jsx">
import { ref } from 'vue'
import ChildComponent from './ChildComponent.js'
const list = ref([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' }
])
const render = () => (
<ChildComponent list={list.value}>
{({ item, index }) => <span>{index + 1}. {item.name}</span>}
</ChildComponent>
)
defineExpose({ render })
</script>
你看,是不是比纯 h 函数的写法好看多了?和 template 里的作用域插槽写法也很像,容易上手。 那 JSX 和纯 h 函数到底哪个更好用呢?其实这也是个“见仁见智”的问题:
- 如果你习惯了 React 的写法,或者需要在 JS 和 HTML 之间频繁切换,那 JSX 肯定是更好的选择,它的可读性和可维护性都比纯 h 函数高很多。
- 如果你不想引入额外的编译器配置(虽然现在的 Vite 和 Vue CLI 都默认支持 JSX 了,但还是需要一点点配置),或者想完全控制 VNode 的生成过程,那纯 h 函数可能更适合你。 不过对于大部分 Vue3 我还是推荐用 JSX 来写 render 函数,因为它兼顾了 template 的可读性和 render 函数的自由度,是一个非常好的折中方案。
template 和 render 函数怎么选?
说了这么多,最后咱们来总结一下,template 和 render 函数到底怎么选:
- 如果是做普通的业务组件,比如展示页面、表单页面、列表页面等:首选 template,因为它够直观、够容易上手,也能让非技术的设计师看懂并调整布局,维护起来也很方便。
- 如果是做通用的组件库组件,或者做特定场景的组件,比如动态表单生成器、虚拟列表、拖拽排序组件等:首选 render 函数(或者 JSX),因为它的自由度够高,扩展性够强,能实现 template 实现不了的功能,也能做到极致的性能优化。
- 如果你是刚入门 Vue3 的小白:先把 template 学透,搞懂 Vue 的响应式原理、指令、插槽、组件等核心概念,等这些都掌握了,再去学 render 函数和 JSX,循序渐进,不要急功近利。
最后我想说的是,不管是 template 还是 render 函数,它们都是 Vue3 的一部分,没有谁比谁高级,只有谁更适合当前的场景,作为一个 Vue3 开发者,你应该都掌握,这样才能在不同的场景下选择最合适的工具,写出更好的代码。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


