Vue3怎么监听插槽内容变化?watch和watchEffect还能用吗?
插槽的本质在Vue3里变了?先搞懂这个再谈监听
很多人一开始在Vue3里直接用watch绑props.slots.default这种东西,结果要么完全没反应,要么触发时机奇奇怪怪,核心原因大概率是没搞懂Vue3插槽和Vue2的核心区别。
Vue2里的插槽,不管是普通插槽还是作用域插槽,本质上都是父组件先执行完渲染逻辑,生成VNode之后才传给子组件的,子组件拿到的是已经“定死”形态的VNode树片段——除非父组件强制重新渲染或者依赖的响应式数据变了导致插槽部分重生成,不然这段VNode是不会变的,那时候监听props.slots,其实是监听父组件是否触发了包含插槽在内的整个组件重渲染。
但Vue3不一样了,它把普通插槽和作用域插槽统一成了“函数式插槽”,父组件传递给子组件的,不再是现成的VNode,而是一个能生成VNode的纯函数!这里的关键点有两个:第一,子组件拿到的是函数引用,只要父组件没有重新生成这个函数(比如没有用箭头函数直接写在template里的静态作用域插槽,或者父组件没重新挂载),props.slots.default的引用本身是不会变的;第二,这个函数什么时候执行、生成什么样的VNode,完全由子组件内部决定——但生成VNode时依赖的响应式数据,默认绑定的是父组件的响应式系统,子组件自己的watch/watchEffect如果直接盯着函数的返回值,根本不会自动收集依赖,自然也就监听不到变化。
直接用watch绑props.slots.default?大概率踩这两个坑
先举个最常见的踩坑例子:父组件传了个普通插槽,内容里绑定了自己的一个count变量,子组件想监听这个count导致的插槽内容变化,直接写了watch(() => props.slots.default?.(), (newVal) => { console.log('插槽变了', newVal) })。
第一个坑是什么?watch的第一个参数如果是个返回普通值的函数,Vue3会深度对比这个值的变化(除非你设置了deep:false),但VNode是对象,而且即使count变了导致VNode的children或者text变了,很多时候VNode的type、key这些关键属性是不变的,Vue的diff算法本来就是复用不变的VNode节点的,直接比较整个VNode数组的话,Vue可能会因为复用了大部分节点,认为整体没变化,从而不触发回调——哪怕deep设为true,也有可能因为VNode内部结构太复杂,对比效率极低,而且还会误判一些无关紧要的内部属性变化。
第二个坑更严重:刚才说了,函数式插槽生成VNode时依赖的是父组件的响应式数据,那子组件里手动执行props.slots.default()这一下,依赖是收集到哪里了?答案是——收集到了子组件执行这个函数时的当前渲染上下文里,也就是子组件的watchEffect或者render函数的上下文里,如果是在setup的顶层直接手动执行一次(比如用来初始化一些内容),那依赖会绑定到setup的隐式渲染上下文?不对,setup本身不是响应式渲染上下文,setup里的普通手动执行不会收集任何父组件的依赖,如果是在子组件的render函数里执行(这也是插槽的正常用法),依赖会收集到子组件的render effect里,这样父组件的count变了,会触发子组件的重新渲染,但不会触发你单独写的watch的回调——因为你单独写的watch的回调上下文是独立的,和render effect的上下文没关系,手动在watch的getter里执行一次props.slots.default(),只会收集这一次执行时的依赖,而且下次getter再执行的时候才会重新收集,但watch的getter默认只有在它自身的依赖变化时才会执行,这就陷入死循环了:父组件的count变了,render effect重新生成VNode,但render effect的依赖变化不会触发watch的getter,watch的getter不重新执行,就不会发现父组件的count变了,自然不会触发回调。
用watchEffect配合子组件的渲染上下文,精准监听
刚才提到,只有在子组件的render effect或者属于渲染上下文的副作用里执行插槽函数,才会自动收集父组件的依赖,那怎么把插槽函数的执行和独立的副作用结合起来呢?可以用Vue3提供的h函数配合watchEffect,但更简单的是,用子组件自身的一个响应式引用来“承接”插槽函数的执行结果,然后在watchEffect里监听这个引用,或者直接在watchEffect里处理插槽变化的逻辑。
等等,刚才不是说直接执行插槽函数收集不到依赖吗?那是因为在setup的顶层或者普通watch的getter里执行,如果在一个会触发重新收集依赖的地方,比如watchEffect的回调函数里,每次父组件的依赖变化导致回调重新执行时,都会再一次执行插槽函数,重新收集依赖,这样就没问题了。
举个具体的例子:做一个带自动展开收起功能的卡片组件,卡片的标题区域是父组件传的插槽,我们需要监听标题插槽的文字长度变化,如果超过20个字就显示展开按钮,否则隐藏。
子组件的setup里要创建一个响应式引用,用来存储标题插槽的纯文字内容:
import { ref, watchEffect, nextTick } from 'vue'
export default {
name: 'AutoExpandCard',
props: {
// 可以加个最大长度的props,更灵活
maxTitleLength: {
type: Number,
default: 20
}
},
setup(props, { slots }) {
// 存储标题的纯文字
const titleText = ref('')
// 存储是否需要展开按钮
const needExpand = ref(false)
// 核心逻辑:用watchEffect执行插槽函数,提取文字
watchEffect(() => {
// 先检查有没有传title插槽
if (!slots.title) {
titleText.value = ''
needExpand.value = false
return
}
// 执行插槽函数,拿到VNode数组
const titleVNodes = slots.title()
// 提取纯文字的函数,要递归处理,因为VNode可能有嵌套(lt;span>或者<i>标签)
function extractText(nodes) {
let text = ''
nodes.forEach(node => {
if (typeof node === 'string' || typeof node === 'number') {
text += node
} else if (node.children) {
// 注意处理数组类型的children,也处理文本类型的children(比如文本节点)
text += extractText(Array.isArray(node.children)? node.children : [node.children])
}
})
return text
}
// 这里要注意!VNode是父组件传过来的,虽然我们能拿到,但不能直接修改
// 提取文字后赋值给响应式引用
titleText.value = extractText(titleVNodes)
})
// 然后用普通watch监听titleText的变化,判断是否需要展开
watch(titleText, (newText) => {
needExpand.value = newText.length > props.maxTitleLength
}, { immediate: true })
return {
titleText,
needExpand
}
}
}
然后子组件的template可以这么写:
<template>
<div class="auto-expand-card">
<div class="card-header">
<slot name="title"></slot>
<button v-if="needExpand" class="expand-btn">
{{ titleText.length > maxTitleLength? '展开' : '收起' }}
</button>
</div>
<div class="card-body">
<slot></slot>
</div>
</div>
</template>
这个方案的优点是什么?第一,它能精准监听父组件导致的插槽内容变化,不管是文字变了,还是嵌套了带响应式数据的标签(比如<span>{{ parentCount }}</span>),只要父组件的响应式数据变了,watchEffect的回调就会重新执行,提取新的文字;第二,它提取的是纯文字内容,比较的时候效率很高,不会像比较整个VNode数组那样有性能问题;第三,它没有修改父组件传过来的VNode,符合Vue的单向数据流原则。
给插槽传一个作用域变量,让父组件通知子组件变化
如果插槽里的内容变化不是由父组件的响应式数据自动触发的,而是由父组件里的某个方法手动触发的(比如父组件里有个按钮,点击后修改了插槽里的某个非响应式数据?不过非响应式数据的话本来就不会触发变化),或者你不想在子组件里写那么复杂的提取逻辑,也可以用作用域插槽的方式,给父组件传一个“通知子组件刷新”的方法,让父组件在插槽内容可能变化的时候手动调用这个方法。
比如还是刚才的自动展开卡片组件,但这次父组件的标题插槽里有个输入框,用户输入的时候我们需要实时更新标题文字长度:
子组件的setup可以这么改(保留刚才的titleText和needExpand,但去掉watchEffect,换成一个通知方法):
import { ref, watch } from 'vue'
export default {
name: 'AutoExpandCard',
props: {
maxTitleLength: {
type: Number,
default: 20
}
},
setup(props, { slots }) {
const titleText = ref('')
const needExpand = ref(false)
// 给父组件传的通知方法,需要父组件传当前的标题文字
function refreshTitle(newText) {
titleText.value = newText
}
watch(titleText, (newText) => {
needExpand.value = newText.length > props.maxTitleLength
}, { immediate: true })
return {
titleText,
needExpand,
refreshTitle
}
}
}
子组件的template里的title插槽要改成作用域插槽,传refreshTitle方法:
<template>
<div class="auto-expand-card">
<div class="card-header">
<slot name="title" :refresh-title="refreshTitle"></slot>
<button v-if="needExpand" class="expand-btn">
{{ titleText.length > maxTitleLength? '展开' : '收起' }}
</button>
</div>
<div class="card-body">
<slot></slot>
</div>
</div>
</template>
然后父组件的template里这么用:
<template>
<div class="parent">
<auto-expand-card :max-title-length="10">
<template #title="{ refreshTitle }">
<input
type="text"
v-model="inputTitle"
@input="refreshTitle(inputTitle)"
placeholder="请输入卡片标题"
>
</template>
<p>这是卡片的内容区域</p>
</auto-expand-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import AutoExpandCard from './AutoExpandCard.vue'
const inputTitle = ref('')
</script>
这个方案的优点是更灵活,子组件不需要关心插槽里的内容是什么结构,只需要父组件在内容变化的时候主动通知就行;缺点是需要父组件配合,如果父组件忘了调用refreshTitle方法,子组件就监听不到变化了,而且如果插槽里的内容变化是由父组件的多个响应式数据触发的,父组件需要在每个数据变化的地方都调用refreshTitle,比较麻烦。
用key强制重新渲染插槽函数的引用,配合普通watch
刚才提到,Vue3里的函数式插槽,props.slots.default的引用本身是不会变的,除非父组件重新生成了这个函数,那怎么让父组件重新生成这个函数呢?可以给子组件传一个key,当key变化的时候,父组件会强制销毁并重新创建子组件?不对,销毁重新创建太暴力了,性能不好,有没有更温和的方法?可以给插槽本身传一个key?不,插槽本身没有key属性,哦对了,可以在父组件的template里,用一个动态的key包裹住插槽所在的子组件的那一行?不对,还是销毁重新创建,等等,另一种方法:如果父组件的普通插槽是用<template #default>...</template>写的,那这个模板会被编译成一个函数,放在父组件的渲染函数里;如果父组件的作用域插槽是用箭头函数写在setup里然后传给子组件的(比如<Child :slots="{ default: () => h('div', count) }" />),那这个箭头函数的引用会随着count的变化而变化吗?不会,除非你把箭头函数写在count的watch里,每次count变了都重新生成一个箭头函数。
哦对了,还有一种场景:如果子组件需要监听的是父组件是否重新渲染了包含插槽在内的那一部分内容,而不是插槽内部的具体内容变化,那可以给子组件传一个响应式的key,这个key的依赖就是父组件里所有可能导致插槽变化的响应式数据,这样当这些数据变化的时候,key会变,子组件的props.slots.default的引用虽然不会变,但你可以用watch监听这个key的变化,然后手动执行插槽函数来更新子组件的状态。
比如还是刚才的自动展开卡片组件,但这次父组件的标题插槽里有多个响应式数据:
父组件的template:
<template>
<div class="parent">
<auto-expand-card
:max-title-length="10"
:slot-key="slotKey"
>
<template #title>
<span>{{ user.name }}</span>
<span>{{ user.age }}岁</span>
</template>
<p>这是卡片的内容区域</p>
</auto-expand-card>
<button @click="updateUser">更新用户信息</button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import AutoExpandCard from './AutoExpandCard.vue'
const user = ref({ name: '张三', age: 25 })
// slotKey依赖user的name和age,当这两个属性变化时,slotKey会变
const slotKey = computed(() => `${user.name}-${user.age}`)
function updateUser() {
user.value.name = '李四'
user.value.age = 30
}
</script>
子组件的setup:
import { ref, watch, nextTick } from 'vue'
export default {
name: 'AutoExpandCard',
props: {
maxTitleLength: {
type: Number,
default: 20
},
// 接收父组件传的slotKey
slotKey: {
type: [String, Number, Symbol],
default: ''
}
},
setup(props, { slots }) {
const titleText = ref('')
const needExpand = ref(false)
// 提取纯文字的函数
function extractText(nodes) {
let text = ''
nodes.forEach(node => {
if (typeof node === 'string' || typeof node === 'number') {
text += node
} else if (node.children) {
text += extractText(Array.isArray(node.children)? node.children : [node.children])
}
})
return text
}
// 初始化或者slotKey变化时,提取文字
function updateTitleText() {
if (!slots.title) {
titleText.value = ''
needExpand.value = false
return
}
// 注意这里!要等nextTick之后再执行插槽函数,因为父组件的响应式数据变化后,
// 子组件的props更新可能还没完成,或者插槽函数的执行上下文还没准备好
nextTick(() => {
const titleVNodes = slots.title()
titleText.value = extractText(titleVNodes)
})
}
// 初始化时执行一次
updateTitleText()
// 监听slotKey的变化,变化时再执行一次
watch(() => props.slotKey, updateTitleText)
// 监听titleText的变化
watch(titleText, (newText) => {
needExpand.value = newText.length > props.maxTitleLength
}, { immediate: true })
return {
titleText,
needExpand
}
}
}
这个方案的优点是性能比销毁重新创建子组件好,而且子组件不需要一直用watchEffect监听,只有slotKey变化的时候才会更新;缺点是需要父组件配合传slotKey,而且父组件要把所有可能导致插槽变化的响应式数据都放到slotKey的依赖里,如果漏了一个,子组件就监听不到变化了。
三种方案分别适用什么场景?
第一种方案,用watchEffect配合提取纯文字(或者其他需要的内容),适用的场景是:插槽里的内容变化是由父组件的响应式数据自动触发的,而且子组件需要提取插槽里的具体内容(比如文字长度、图片数量、特定标签的属性等)来做一些逻辑处理,这种方案是最常用、最省心的,不需要父组件配合。
第二种方案,用作用域插槽传通知方法,适用的场景是:插槽里的内容变化不是由父组件的响应式数据自动触发的,或者子组件不需要关心插槽里的具体内容,只需要知道内容变化了就行,这种方案最灵活,但需要父组件高度配合。
第三种方案,用slotKey配合watch,适用的场景是:插槽里的内容变化是由父组件的少数几个明确的响应式数据触发的,而且子组件不需要一直监听,只需要在这些数据变化的时候更新就行,这种方案性能比第一种好,但需要父组件配合传slotKey。
最后还要提醒大家一点:不管用哪种方案,都不要直接修改父组件传过来的VNode,因为Vue的VNode是只读的,修改VNode可能会导致 unexpected behavior(不可预期的行为),比如diff算法出错、组件重渲染异常等,如果需要修改插槽里的内容,应该用作用域插槽的方式,让父组件传需要修改的数据,然后子组件在自己的render函数里用h函数重新生成VNode,或者直接在父组件里修改数据。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


