Code前端首页关于Code前端联系我们

Vue3 setup里插槽总玩不明白?useSlots到底怎么用才不踩坑?

terry 17小时前 阅读数 281 #Vue

别慌!先搞懂useSlots是个啥东西

很多从Vue2转来的人,一开始碰setup里的插槽都会懵——原来组件里用<slot name="xxx"></slot>,父组件传<template v-slot:xxx="scope"></template>给数据不就完了?为什么还要单独整个useSlots钩子?

先回忆下Vue2到Vue3的组件架构变化:Vue2组件有选项式API,你可以在render函数或者生命周期钩子(比如mounted)里通过this.$slots拿到父组件传的静态插槽,this.$scopedSlots拿作用域插槽;但Vue3的setup是在组件实例创建之前执行的,根本访问不到this,总不能为了拿个插槽又绕回选项式吧?

useSlots就是Vue3官方给setup准备的“钩子替身”——它本质上是一个返回当前组件所有插槽对象的函数,但这个对象的结构和Vue2完全不一样:Vue3把静态、作用域、甚至动态插槽都统一放在了一个普通对象里,每个插槽对应的不是值,是一个函数!调用这个函数,才能拿到真实渲染的VNode数组。

举个最直观的对比例子:假设你父组件传了一个静态的默认插槽和作用域插槽user-card,给user-card传了name: '张三'age: 28。 Vue2选项式API里,this.$slots.default直接是VNode数组,this.$scopedSlots['user-card']({ name: '自定义覆盖' })才是覆盖数据后的VNode; Vue3 setup里,useSlots()返回的slots对象中,slots.default()是默认静态插槽的VNode数组,slots['user-card']({ name: '自定义覆盖' })是覆盖后的作用域VNode,连默认没传的插槽调用都会返回undefined,比Vue2的null或空数组更严谨。

什么时候必须用useSlots?别瞎用在普通组件里

很多人刚学完useSlots,恨不得每个组件都塞一个——其实大可不必,普通静态展示、简单父子传值带插槽的组件,直接用<slot>标签就够了,用useSlots反而多写代码、增加复杂度,那useSlots的核心应用场景到底有哪些?

场景1:编写可复用的函数式组件

Vue3的函数式组件(也就是没有<script setup>或者单文件里只有setup函数+返回VNode的组件),以前Vue2的functional: true写法在3里虽然还能用,但官方推荐用<script setup>defineProps+defineEmits+useSlots的组合,或者直接写纯函数组件。 比如你要写一个通用的“动态卡片容器”组件,卡片的头部、内容、尾部都要完全由父组件决定,甚至卡片要不要渲染头部尾部也要根据父组件有没有传对应插槽来判断——这时候用useSlots就比标签式灵活多了:

// 通用动态卡片组件 CardBox.vue
<script setup>
import { useSlots, h } from 'vue'
// 父组件可以传一个border-width来控制卡片边框
const props = defineProps(['borderWidth'])
const slots = useSlots()
// 这里用h函数渲染(或者你可以用JSX,但JSX需要额外配置)
// 只有父组件传了header才渲染
const headerVNode = slots.header?.()必须渲染,不传默认用空div占位置
const contentVNode = slots.default?.() || h('div', { style: { padding: '16px' } }, '暂无内容')
// 只有传了footer才渲染
const footerVNode = slots.footer?.()
return () => h('div', {
  style: {
    border: `${props.borderWidth || 1}px solid #eee`,
    borderRadius: '8px',
    overflow: 'hidden'
  }
}, [
  headerVNode && h('div', { style: { padding: '12px 16px', borderBottom: '1px solid #eee', fontWeight: 'bold' } }, headerVNode),
  contentVNode,
  footerVNode && h('div', { style: { padding: '12px 16px', borderTop: '1px solid #eee', textAlign: 'right' } }, footerVNode)
])
</script>

父组件用的时候,完全可以根据数据有无灵活传插槽,比如有简介才传header传头像和姓名,有操作按钮才传footer:

<script setup>
import CardBox from './CardBox.vue'
const user = {
  name: '李四',
  avatar: 'https://picsum.photos/200/200',
  intro: '前端开发工程师,擅长Vue3和TypeScript',
  hasEditPermission: true
}
</script>
<template>
  <CardBox :border-width="2">
    <template #header>
      <div style="display: flex; gap: 12px; align-items: center;">
        <img :src="user.avatar" style="width: 40px; height: 40px; border-radius: 50%;">
        <span>{{ user.name }}</span>
      </div>
    </template>
    <template #default>
      <p>{{ user.intro }}</p>
    </template>
    <template #footer v-if="user.hasEditPermission">
      <button style="margin-left: 8px; padding: 4px 12px; border: none; border-radius: 4px; background: #409eff; color: white;">编辑</button>
      <button style="margin-left: 8px; padding: 4px 12px; border: none; border-radius: 4px; background: #f56c6c; color: white;">删除</button>
    </template>
  </CardBox>
</template>

场景2:对插槽内容做处理

标签式插槽只能把父组件传的内容直接“插进去”,但如果你想对插槽内容做二次修改、过滤、排序、添加属性/事件,就必须用useSlots拿到VNode数组,然后遍历修改或者用h函数包裹。 比如你要写一个“关键词高亮的文本组件”,组件里有个默认插槽接收文本,还有个highlight属性传要高亮的关键词——这时候标签式插槽根本做不到“遍历文本VNode替换高亮关键词”,因为标签式插槽传进来的内容可能不是纯文本,甚至可能是嵌套的组件。 用useSlots就可以解决这个问题:拿到默认插槽的VNode数组,递归遍历每个VNode,如果是文本节点,就把关键词替换成带高亮样式的<span>标签;如果是元素节点或组件节点,就继续遍历它的子节点(不过注意组件节点的子节点是在它的内部管理的,不能直接修改,除非组件本身也支持插槽处理)。

场景3:动态渲染多个插槽

比如你要写一个“动态表单容器”组件,父组件传一个fields数组,数组里每个元素是字段配置,比如{ type: 'input', label: '姓名', slotName: 'custom-name-input' }——如果字段配置里有slotName,就渲染父组件传的对应插槽,否则渲染组件内置的输入框。 这时候用标签式插槽的话,你得写一堆<template v-if="fields.some(f => f.slotName === 'xxx')"><slot name="xxx"></slot></template>,非常臃肿;用useSlots的话,就可以遍历fields数组,判断当前字段的slotName在不在slots对象里,在的话就调用,不在的话就渲染内置组件。

useSlots的3个高频踩坑点,看完别再犯

虽然useSlots的逻辑看起来简单,但实际开发中很多人都会踩坑,我整理了3个最常见的:

踩坑1:直接访问slots.default,而不是调用slots.default()

刚才说过,Vue3的插槽对象里每个属性都是函数,必须调用才能拿到VNode数组,很多Vue2转来的人一开始会习惯性写if (slots.default),其实应该写if (slots.default?.())——因为如果父组件没传默认插槽,slots.default本身是不存在的,用可选链可以避免报错;如果父组件传了默认插槽但里面是空的(比如<template #default></template>),调用slots.default()会返回空数组,所以有时候还要加if (slots.default?.().length > 0)

踩坑2:在setup的顶层直接调用useSlots

虽然useSlots是个钩子,但它是和组件实例绑定的——如果在setup的顶层直接调用useSlots,然后存下来再在异步函数里用,可能会出问题吗?其实不会,Vue3官方文档里说useSlots可以在setup的任何地方调用,包括异步函数和事件处理器里,但最佳实践是在setup的顶层调用一次,然后存下来用,不要在每次需要的时候都调用,虽然不会报错,但会增加一点点性能开销(虽然可以忽略不计,但养成好习惯总是好的)。

踩坑3:修改VNode数组本身

Vue3的VNode是不可变的,如果你直接修改slots.default()返回的VNode数组里的元素的属性或子节点,Vue不会重新渲染,而且可能会导致奇怪的bug,正确的做法是创建新的VNode数组,比如用map遍历原VNode数组,用h函数包裹原VNode或者创建新的VNode。

还有个小细节:useSlots和defineSlots的配合

Vue3.3+新增了defineSlots宏,用来给插槽做类型定义和参数约束——如果你用TypeScript开发,强烈建议同时用defineSlotsuseSlots,这样可以获得更好的类型提示。 比如刚才的动态卡片组件,可以给它的插槽做类型定义:

// CardBox.vue (TypeScript版本)
<script setup lang="ts">
import { useSlots, h, type SlotsType } from 'vue'
const props = defineProps<{
  borderWidth?: number
}>()
// 定义插槽类型:header和footer可以没有参数,default可以有一个参数{ emptyText?: string }
const slots = defineSlots<SlotsType<{
  header?: () => any
  default?: (scope: { emptyText?: string }) => any
  footer?: () => any
}>()
// 这里可以传emptyText给默认插槽,父组件用的时候如果在#default里加了scope,就可以拿到
const contentVNode = slots.default?.({ emptyText: '自定义空文本' }) || h('div', { style: { padding: '16px' } }, '暂无内容')
// 剩下的代码和之前一样
</script>

父组件用TypeScript的话,编辑器就会提示你#default的scope里有emptyText属性,#header#footer没有参数,非常方便。

useSlots是Vue3 setup里操作插槽的“神器”,但不要滥用——普通组件用标签式插槽就够了,只有在写可复用的函数式组件、对插槽内容做处理、动态渲染多个插槽的时候才用,记住那3个高频踩坑点,配合Vue3.3+的defineSlots,你就能在setup里把插槽玩得明明白白。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门