今天我要和你讨论一下渲染函数。这是成为vue高手的必经之路,也是至关重要的一环。
在文章开始之前,我先抛出一个问题。想必大家都碰到过这种情况吧?比如你想将一个详情展示页面配置化:
vue
<script setup>
defineProps<{
title: string
date: string
feature: string
}>()
</script>
<template>
<div>
<h3>
这里是标题
{{ title }}
</h3>
这里是日期
{{ date }}
这里是功能展示
{{ feature}}
</div>
</template>
这样使用时,可以直接自动生成:
vue
<script setup>
const data = getData()
</script>
<template>
<Details v-for="{ id, title, date, feature } in data" :key="id" :title :date :feature />
</template>
可是问题来了,某一天,你希望给标题加点功能。某些页面的标题是上文中简单一行字,某些页面的标题却可能是大写的。你可能会考虑多传递一个参数:
html
<h3 :class="titleClass">
这里是标题
{{ title }}
</h3>
又某一天,你希望标题某几个字变色。又又某一天,希望标题旁边加个小标题...这样的需求无休无尽,不断地加入配置项,原来简洁的页面变得臃肿不堪,而大部分页面却只用到了其中极少数参数...除非...
除非能直接将组件作为配置项,随写随传,随时拿来就用就好了
而接下来,我们将会解决这个问题。
首先,想必大家都听说过虚拟节点了,如果我要问你什么是虚拟节点,你可能张口就能来一段长篇大论。可是你在代码中使用过虚拟节点吗?要想在代码中使用虚拟节点,只需要使用h
函数:
ts
import { h } from 'vue'
// 1
h('div')
// 2
h('div', 'hello')
// 3
h('div', { class: 'c-pink' })
// 4
h('div', { class: 'c-pink' }, 'hello')
// 5
h('div', { class: 'c-pink' }, h('p', 'hello'))
// 6
h('div', { class: 'c-pink' }, [h('p', 'hello'), h('p', class: 'c-blue' ,'world')])
上述代码按顺序等价于:
html
<!-- 1 -->
<div />
<!-- 2 -->
<div>
hello
</div>
<!-- 3 -->
<div class="c-pink" />
<!-- 4 -->
<div class="c-pink">
hello
</div>
<!-- 5 -->
<div class="c-pink">
<p>hello</p>
</div>
<!-- 6 -->
<div class="c-pink">
<p>hello</p>
<p class="c-blue">world</p>
</div>
那么虚拟节点如何使用呢?在回答这个问题之前,我想再问一个问题: 什么是组件?你可能说你这不废话吗,我可是要做vue高手的男人,怎么可能还不知道啥是组件?请看代码:
ts
function Comp() {
return h('div')
}
这个Comp是组件吗?没错,上面代码展示的就是组件的最小单位,渲染函数。所谓渲染函数,就是一个返回虚拟节点的函数。
- 你可以把他导出后作为组件使用
ts
// Comp.ts
export default function () {
return h('div')
}
vue
<script setup>
import Comp from './Comp.ts'
</script>
<template>
<Comp />
<component :is="Comp" />
</template>
- 或者直接在组件中声明
vue
<script setup>
function Comp() {
return h('div')
}
</script>
<template>
<Comp />
<component :is="Comp" />
</template>
在解释这是如何运作的之前,让我们先看下官方推荐的方式:
ts
const Comp = defineComponent((props: { msg: string }) => {
return () => h('div', props.msg)
}, {
props: ['msg']
})
这里的Comp也是组件,这是使用defineComponent
声明的组件,很容易注意到,这个函数的第一个参数是一个返回渲染函数的函数,换言之这就是setup:
ts
defineComponent(() => {
// 这里是setup
return () => h('div')
})
也就是:
vue
<script setup>
// 这里是setup
</script>
很容易观察出来,实际上setup就是一个返回渲染函数的函数。
这也回头解释了本系列第一期,为什么setup存在上下文,那是因为setup本身就是个函数,而函数体的大括号拥有一个作用域,这个作用域成为了上下文。
同时这也解释了为什么生命周期钩子、watchEffect等等,那么多组合式函数为什么都要在setup上下文中运行。因为此时作为组件最小单位的渲染函数还没有返回,换言之就是对组件而言一切都还没发生,只有在此时才能最明确的控制组件生成后的每一个阶段。
接下来,我们做个实验,尝试这样的代码:
ts
let data = 0
setInterval(() => data++, 1000)
function getData() {
return data
}
setInterval(() => console.log(getData()), 1000)
这段代码非常简单,首先设定了一个持续变化的变量data,然后写了一个返回这个变量的函数,每隔一段时间运行这个函数。很显然,你会得到一个持续不断更新的结果。是不是感觉很熟悉?没错,这就是响应式系统作用的方式!
ts
defineComponent(() => {
const data = ref(0)
return () => h('div', data.value)
})
意识到了吗?其实vue的页面之所以保持响应,是因为作为组件最小单元的渲染函数在不断被执行,从而保持数据更新。这就是响应式系统作用的方式。而响应式系统原理提供了这种作用的动力,在上面的实验中,我们使用setInterval
来保持更新,显然这是粗糙的。而响应式系统原理指出了应该在什么时候进行更新以获得最新的数据。
回到最初的问题。如何设计从而使这个详情展示页最灵活?答案是传入组件:
html
<script setup>
defineProps<{
title: string | Component
}>()
</script>
<template>
<div>
<h3>
这里是标题
<component :is="typeof title === 'string' ? (() => title) : title" />
</h3>
</div>
</template>
渲染函数可以返回字符串,这相当于直接在页面上渲染一段文字。由于<component>
的is
本身是可以接受字符串作为标签名使用的,所以这里需要手动处理一样,如果是字符串则给一个直接返回字符串的渲染函数,用于直接渲染字符串。否则就结束一个组件渲染,使用起来像这样:
html
<script setup>
import SpecialTitle from './SpecialTitle.vue'
const data = ref([
{ id: 1, title: '你好世界' },
{ id: 2, title: () => h('div', { class: 'c-pink' }, '粉色世界') }, // 粉色字体
{ id: 4, title: SpecialTitle }, // 直接传入一个组件
{
id: 3,
title: defineComponent(() => { useXXX(); return () => h(SpecialTitle)})
}, // 一个使用了组合式函数与外部组件的复杂组件
])
</script>
<template>
<Details v-for="{ id, title } in data" :key="id" :title />
</template>
总结一下,其实 Vue 的 渲染函数 就是我们平时写的 <template>
的底层形态:一个返回虚拟节点的函数,而 defineComponent
中返回渲染函数的函数就是<script setup>
阶段。理解这一点后,很多"黑盒"般的机制就会变得清晰------为什么 setup 有上下文、为什么响应式更新能生效、为什么 <component :is="...">
能随意切换组件。更重要的是,掌握渲染函数让我们不再被模板语法的限制所困,模板语法为vue提供了下限,而精通渲染函数机制为vue提供了上限。