欢迎光临
我们一直在努力

插槽的作用域与分发:如何让组件更灵活、可定制?

插槽的作用域与分发:如何让组件更灵活、可定制?

  • 前言
  • 插槽设计的初衷
    • 组件的可定制性需求
    • 插槽解决了什么问题
  • 三种插槽的使用场景
    • 默认插槽:简单的内容占位
    • 具名插槽:多个位置的定制
    • 作用域插槽:让父组件访问子组件的数据
  • 作用域插槽的深入理解
    • 数据流向:子组件暴露数据,父组件决定渲染
    • 解构插槽 prop 的技巧
    • 渲染作用域:父组件模板只能访问父组件的数据
  • 高级技巧:动态插槽与递归组件
    • 动态插槽名的使用场景
    • 递归组件中插槽的处理
    • 渲染函数中的插槽使用
  • 性能考量
    • 插槽内容的重渲染机制
    • 使用 v-slot 的缓存优化
    • 避免不必要的插槽重绘
  • 插槽设计的最佳实践
    • 插槽使用决策树
    • 插槽设计的最佳实践清单
  • 结语

前言

在 Vue 组件设计中,我们经常面临这样一个困境:组件需要足够通用,但又要在不同场景下展现不同的内容。这种情况怎么处理呢?通过 Props 传递模板内容?那会让组件变得臃肿不堪。通过事件让父组件控制渲染?那又违背了组件的封装性原则。

插槽(Slots)的出现完美解决了这个问题。它让组件既能保持核心逻辑的内聚,又能将部分渲染的控制权交给使用者。本文将深入探讨插槽的三种类型、作用域插槽的数据传递机制,以及如何通过插槽构建高度可定制的组件。

插槽设计的初衷

组件的可定制性需求

想象一下我们正在开发一个卡片组件,不同的页面需要不同的卡片内容:

<!– 产品页面需要的卡片 –>
<Card>
<img :src="product.image" />
<h3>{{ product.name }}</h3>
<p>{{ product.price }}</p>
<button @click="addToCart">加入购物车</button>
</Card>

<!– 文章页面需要的卡片 –>
<Card>
<h2>{{ article.title }}</h2>
<p>{{ article.summary }}</p>
<div class="meta">
<span>{{ article.author }}</span>
<span>{{ article.date }}</span>
</div>
</Card>

在没有插槽的情况下,我们可能会设计出这样的组件:

<!– ❌ 反模式:通过 Props 控制内容 –>
<Card
:show-image="true"
:image-src="product.image"
:title="product.name"
:description="product.price"
:show-button="true"
button-text="加入购物车"
@button-click="addToCart"
/>

这种设计的问题显而易见:

  • Props 爆炸:为了覆盖所有场景,Props 会越来越多
  • 灵活性差:无法实现 Props 没覆盖到的布局
  • 逻辑复杂:组件内部需要大量条件判断

插槽解决了什么问题

  • 布局定制:父组件可以决定内容的布局和样式
  • 逻辑复用:组件保持核心逻辑,父组件控制展示
  • 解耦:组件和内容提供者之间没有强依赖
  • 组合性:可以嵌套使用,构建复杂 UI
  • 三种插槽的使用场景

    默认插槽:简单的内容占位

    默认插槽是最简单的形式,适合单一内容区域:

    <!– Button.vue – 一个可定制的按钮 –>
    <template>
    <button class="btn" :class="[`btn-${type}`, `btn-${size}`]">
    <slot>
    <!– 默认内容:如果没有提供插槽内容,显示这个 –>
    <span>按钮</span>
    </slot>
    </button>
    </template>

    <script setup>
    defineProps<{
    type?: 'primary' | 'secondary' | 'danger'
    size?: 'small' | 'medium' | 'large'
    }>()
    </script>

    <!– 使用 –>
    <Button type="primary" size="large">
    <Icon name="plus" />
    <span>创建新项目</span>
    </Button>

    <Button type="secondary">
    取消
    </Button>

    <Button>
    <!– 使用默认内容 –>
    </Button>

    适用场景:

    • 模态框的内容区域
    • 卡片的主要内容
    • 按钮的文本内容

    具名插槽:多个位置的定制

    当组件有多个需要定制的位置时,使用具名插槽:

    <!– Modal.vue – 一个灵活的模态框组件 –>
    <template>
    <div class="modal-overlay" @click.self="$emit('close')">
    <div class="modal">
    <!– 头部 –>
    <header class="modal-header">
    <slot name="header">
    <h3>默认标题</h3>
    </slot>
    <button class="close-btn" @click="$emit('close')">×</button>
    </header>

    <!– 主体 –>
    <main class="modal-body">
    <slot name="body">
    <p>默认内容</p>
    </slot>
    </main>

    <!– 底部 –>
    <footer class="modal-footer">
    <slot name="footer">
    <button @click="$emit('close')">关闭</button>
    </slot>
    </footer>
    </div>
    </div>
    </template>

    <script setup>
    defineEmits(['close'])
    </script>

    <!– 使用 –>
    <Modal @close="showModal = false">
    <template #header>
    <h2>确认删除</h2>
    <p class="warning">此操作不可恢复</p>
    </template>

    <template #body>
    <p>确定要删除 "{{ item.name }}" 吗?</p>
    </template>

    <template #footer>
    <button class="cancel" @click="showModal = false">取消</button>
    <button class="confirm" @click="handleDelete">确认删除</button>
    </template>
    </Modal>

    适用场景:

    • 模态框的头部/主体/底部
    • 页面的侧边栏/主内容/底部
    • 表格的操作列/状态列

    作用域插槽:让父组件访问子组件的数据

    这是插槽最强大的形式,它允许父组件访问子组件内部的数据:

    <!– List.vue – 一个可定制的列表组件 –>
    <template>
    <div class="list">
    <div v-if="loading" class="loading">
    <slot name="loading">
    <span>加载中…</span>
    </slot>
    </div>

    <div v-else-if="error" class="error">
    <slot name="error" :error="error">
    <span>出错了: {{ error.message }}</span>
    </slot>
    </div>

    <div v-else-if="items.length === 0" class="empty">
    <slot name="empty">
    <span>暂无数据</span>
    </slot>
    </div>

    <div v-else class="list-items">
    <div
    v-for="(item, index) in items"
    :key="item.id"
    class="list-item"
    >

    <!– 作用域插槽:将 item 数据暴露给父组件 –>
    <slot
    :item="item"
    :index="index"
    :is-first="index === 0"
    :is-last="index === items.length – 1"
    >

    <!– 默认渲染方式 –>
    <span>{{ item.name }}</span>
    </slot>
    </div>
    </div>
    </div>
    </template>

    <script setup lang="ts">
    interface Item {
    id: number
    name: string
    [key: string]: any
    }

    defineProps<{
    items: Item[]
    loading?: boolean
    error?: Error | null
    }>()
    </script>

    <!– 使用:完全自定义每个项的渲染方式 –>
    <template>
    <List :items="users" :loading="isLoading">
    <!– 自定义每个用户的显示方式 –>
    <template #default="{ item, index }">
    <div class="user-item" :class="{ 'is-even': index % 2 === 0 }">
    <img :src="item.avatar" class="avatar" />
    <div class="info">
    <h4>{{ item.name }}</h4>
    <p>{{ item.email }}</p>
    </div>
    <button @click="follow(item.id)">关注</button>
    </div>
    </template>

    <!– 自定义空状态 –>
    <template #empty>
    <div class="custom-empty">
    <EmptyIcon />
    <p>还没有用户,<a href="#">立即创建</a></p>
    </div>
    </template>

    <!– 自定义加载状态 –>
    <template #loading>
    <SkeletonLoader />
    </template>

    <!– 自定义错误状态,可以访问 error 对象 –>
    <template #error="{ error }">
    <div class="custom-error">
    <ErrorIcon />
    <p>{{ error.message }}</p>
    <button @click="retry">重试</button>
    </div>
    </template>
    </List>
    </template>

    适用场景:

    • 列表/表格的自定义行渲染
    • 下拉选项的自定义选项显示
    • 树形组件的节点渲染
    • 任何需要根据数据定制显示的场景

    作用域插槽的深入理解

    数据流向:子组件暴露数据,父组件决定渲染

    理解作用域插槽的关键是明白其数据流向:

    #mermaid-svg-Vdwlj91k04GjadNz{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Vdwlj91k04GjadNz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Vdwlj91k04GjadNz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Vdwlj91k04GjadNz .error-icon{fill:#552222;}#mermaid-svg-Vdwlj91k04GjadNz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Vdwlj91k04GjadNz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Vdwlj91k04GjadNz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Vdwlj91k04GjadNz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Vdwlj91k04GjadNz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Vdwlj91k04GjadNz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Vdwlj91k04GjadNz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Vdwlj91k04GjadNz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Vdwlj91k04GjadNz .marker.cross{stroke:#333333;}#mermaid-svg-Vdwlj91k04GjadNz svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Vdwlj91k04GjadNz p{margin:0;}#mermaid-svg-Vdwlj91k04GjadNz .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Vdwlj91k04GjadNz .cluster-label text{fill:#333;}#mermaid-svg-Vdwlj91k04GjadNz .cluster-label span{color:#333;}#mermaid-svg-Vdwlj91k04GjadNz .cluster-label span p{background-color:transparent;}#mermaid-svg-Vdwlj91k04GjadNz .label text,#mermaid-svg-Vdwlj91k04GjadNz span{fill:#333;color:#333;}#mermaid-svg-Vdwlj91k04GjadNz .node rect,#mermaid-svg-Vdwlj91k04GjadNz .node circle,#mermaid-svg-Vdwlj91k04GjadNz .node ellipse,#mermaid-svg-Vdwlj91k04GjadNz .node polygon,#mermaid-svg-Vdwlj91k04GjadNz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Vdwlj91k04GjadNz .rough-node .label text,#mermaid-svg-Vdwlj91k04GjadNz .node .label text,#mermaid-svg-Vdwlj91k04GjadNz .image-shape .label,#mermaid-svg-Vdwlj91k04GjadNz .icon-shape .label{text-anchor:middle;}#mermaid-svg-Vdwlj91k04GjadNz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Vdwlj91k04GjadNz .rough-node .label,#mermaid-svg-Vdwlj91k04GjadNz .node .label,#mermaid-svg-Vdwlj91k04GjadNz .image-shape .label,#mermaid-svg-Vdwlj91k04GjadNz .icon-shape .label{text-align:center;}#mermaid-svg-Vdwlj91k04GjadNz .node.clickable{cursor:pointer;}#mermaid-svg-Vdwlj91k04GjadNz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Vdwlj91k04GjadNz .arrowheadPath{fill:#333333;}#mermaid-svg-Vdwlj91k04GjadNz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Vdwlj91k04GjadNz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Vdwlj91k04GjadNz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vdwlj91k04GjadNz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Vdwlj91k04GjadNz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vdwlj91k04GjadNz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Vdwlj91k04GjadNz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Vdwlj91k04GjadNz .cluster text{fill:#333;}#mermaid-svg-Vdwlj91k04GjadNz .cluster span{color:#333;}#mermaid-svg-Vdwlj91k04GjadNz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Vdwlj91k04GjadNz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Vdwlj91k04GjadNz rect.text{fill:none;stroke-width:0;}#mermaid-svg-Vdwlj91k04GjadNz .icon-shape,#mermaid-svg-Vdwlj91k04GjadNz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vdwlj91k04GjadNz .icon-shape p,#mermaid-svg-Vdwlj91k04GjadNz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Vdwlj91k04GjadNz .icon-shape rect,#mermaid-svg-Vdwlj91k04GjadNz .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vdwlj91k04GjadNz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Vdwlj91k04GjadNz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Vdwlj91k04GjadNz :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    通过插槽 Props 暴露

    决定如何渲染

    渲染到

    子组件数据

    父组件

    插槽内容

    关键点:

    • 数据由子组件提供(暴露给父组件)
    • 渲染逻辑由父组件控制(如何使用这些数据)
    • 最终内容仍渲染在子组件内部(保持 DOM 结构)

    解构插槽 prop 的技巧

    作用域插槽的 props 可以使用解构,让代码更简洁:

    <template>
    <!– 基础用法 –>
    <List :items="products">
    <template #default="slotProps">
    <div>{{ slotProps.item.name }} – {{ slotProps.item.price }}</div>
    </template>
    </List>

    <!– ✅ 解构用法 –>
    <List :items="products">
    <template #default="{ item, index }">
    <div class="product-item">
    <span class="index">{{ index + 1 }}.</span>
    <span class="name">{{ item.name }}</span>
    <span class="price">¥{{ item.price }}</span>
    </div>
    </template>
    </List>

    <!– ✅ 重命名解构 –>
    <List :items="products">
    <template #default="{ item: product, index: position }">
    <div>{{ position }}: {{ product.name }}</div>
    </template>
    </List>

    <!– ✅ 设置默认值 –>
    <List :items="products">
    <template #default="{ item = {}, index = -1 }">
    <div>{{ item.name || '未知商品' }}</div>
    </template>
    </List>
    </template>

    渲染作用域:父组件模板只能访问父组件的数据

    这是一个容易混淆的概念:插槽内容在父组件中编写,所以只能访问父组件的作用域:

    <!– 父组件 –>
    <template>
    <ChildComponent>
    <template #default="{ childData }">
    <!– ✅ 可以访问父组件数据 –>
    <div>{{ parentData }}</div>

    <!– ✅ 可以访问插槽 prop –>
    <div>{{ childData }}</div>

    <!– ❌ 不能访问子组件的其他数据 –>
    <div>{{ someOtherChildData }}</div>
    </template>
    </ChildComponent>
    </template>

    <script setup>
    import { ref } from 'vue'

    const parentData = ref('这是父组件数据')
    </script>

    渲染作用域图解:

    #mermaid-svg-vdqoy8fkrs5D5Opi{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vdqoy8fkrs5D5Opi .error-icon{fill:#552222;}#mermaid-svg-vdqoy8fkrs5D5Opi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vdqoy8fkrs5D5Opi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vdqoy8fkrs5D5Opi .marker.cross{stroke:#333333;}#mermaid-svg-vdqoy8fkrs5D5Opi svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vdqoy8fkrs5D5Opi p{margin:0;}#mermaid-svg-vdqoy8fkrs5D5Opi .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster-label text{fill:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster-label span{color:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster-label span p{background-color:transparent;}#mermaid-svg-vdqoy8fkrs5D5Opi .label text,#mermaid-svg-vdqoy8fkrs5D5Opi span{fill:#333;color:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi .node rect,#mermaid-svg-vdqoy8fkrs5D5Opi .node circle,#mermaid-svg-vdqoy8fkrs5D5Opi .node ellipse,#mermaid-svg-vdqoy8fkrs5D5Opi .node polygon,#mermaid-svg-vdqoy8fkrs5D5Opi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vdqoy8fkrs5D5Opi .rough-node .label text,#mermaid-svg-vdqoy8fkrs5D5Opi .node .label text,#mermaid-svg-vdqoy8fkrs5D5Opi .image-shape .label,#mermaid-svg-vdqoy8fkrs5D5Opi .icon-shape .label{text-anchor:middle;}#mermaid-svg-vdqoy8fkrs5D5Opi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vdqoy8fkrs5D5Opi .rough-node .label,#mermaid-svg-vdqoy8fkrs5D5Opi .node .label,#mermaid-svg-vdqoy8fkrs5D5Opi .image-shape .label,#mermaid-svg-vdqoy8fkrs5D5Opi .icon-shape .label{text-align:center;}#mermaid-svg-vdqoy8fkrs5D5Opi .node.clickable{cursor:pointer;}#mermaid-svg-vdqoy8fkrs5D5Opi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vdqoy8fkrs5D5Opi .arrowheadPath{fill:#333333;}#mermaid-svg-vdqoy8fkrs5D5Opi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vdqoy8fkrs5D5Opi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vdqoy8fkrs5D5Opi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vdqoy8fkrs5D5Opi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vdqoy8fkrs5D5Opi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vdqoy8fkrs5D5Opi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster text{fill:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster span{color:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vdqoy8fkrs5D5Opi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi rect.text{fill:none;stroke-width:0;}#mermaid-svg-vdqoy8fkrs5D5Opi .icon-shape,#mermaid-svg-vdqoy8fkrs5D5Opi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vdqoy8fkrs5D5Opi .icon-shape p,#mermaid-svg-vdqoy8fkrs5D5Opi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vdqoy8fkrs5D5Opi .icon-shape rect,#mermaid-svg-vdqoy8fkrs5D5Opi .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vdqoy8fkrs5D5Opi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vdqoy8fkrs5D5Opi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vdqoy8fkrs5D5Opi :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    子组件

    父组件

    可以访问

    可以访问

    只能访问

    不能访问

    不能访问

    父组件数据

    父组件方法

    插槽模板

    子组件数据

    子组件方法

    插槽 Props

    高级技巧:动态插槽与递归组件

    动态插槽名的使用场景

    有时我们需要根据数据动态决定使用哪个插槽:

    <!– DynamicLayout.vue –>
    <template>
    <div class="dynamic-layout">
    <component
    :is="`h${level}`"
    v-if="$slots[`title-${level}`]"
    >

    <slot :name="`title-${level}`" />
    </component>

    <div
    v-for="section in sections"
    :key="section.name"
    class="section"
    >

    <!– 动态插槽名 –>
    <slot
    :name="`section-${section.type}`"
    :data="section.data"
    />

    </div>
    </div>
    </template>

    <script setup>
    defineProps<{
    level?: 1 | 2 | 3 | 4 | 5 | 6
    sections: Array<{ type: string; name: string; data: any }>
    }>()
    </script>

    <!– 使用 –>
    <template>
    <DynamicLayout :level="3" :sections="pageSections">
    <!– 动态匹配 title-3 插槽 –>
    <template #title-3>
    页面标题
    </template>

    <!– 根据 section.type 动态匹配插槽 –>
    <template #section-hero="{ data }">
    <HeroSection :data="data" />
    </template>

    <template #section-features="{ data }">
    <FeaturesGrid :items="data" />
    </template>

    <template #section-cta="{ data }">
    <CallToAction :data="data" />
    </template>
    </DynamicLayout>
    </template>

    递归组件中插槽的处理

    递归组件(如树形控件)需要特殊处理插槽:

    <!– Tree.vue – 递归树组件 –>
    <template>
    <div class="tree-node">
    <div class="node-content" @click="toggle">
    <slot
    name="node"
    :node="node"
    :level="level"
    :expanded="expanded"
    >

    <span class="default-node">
    <span class="toggle-icon">{{ expanded ? '▼' : '▶' }}</span>
    {{ node.label }}
    </span>
    </slot>
    </div>

    <div v-if="expanded && node.children" class="node-children">
    <Tree
    v-for="(child, index) in node.children"
    :key="index"
    :node="child"
    :level="level + 1"
    >

    <!– 传递插槽到子节点 –>
    <template #node="slotProps">
    <slot name="node" v-bind="slotProps" />
    </template>

    <template #leaf="slotProps">
    <slot name="leaf" v-bind="slotProps" />
    </template>
    </Tree>
    </div>
    </div>
    </template>

    <script setup>
    defineProps<{
    node: any
    level?: number
    }>()

    const expanded = ref(false)
    const toggle = () => expanded.value = !expanded.value
    </script>

    <!– 使用 –>
    <template>
    <Tree :node="treeData">
    <!– 自定义节点渲染 –>
    <template #node="{ node, level, expanded }">
    <div class="custom-node" :class="`level-${level}`">
    <FolderIcon v-if="node.children" :open="expanded" />
    <FileIcon v-else />
    <span class="label">{{ node.label }}</span>
    <span class="count" v-if="node.children">
    ({{ node.children.length }})
    </span>
    </div>
    </template>
    </Tree>
    </template>

    渲染函数中的插槽使用

    在渲染函数(TSX/JSX)中使用插槽:

    // Table.tsx
    import { defineComponent } from 'vue'

    export default defineComponent({
    props: {
    data: Array,
    columns: Array
    },

    setup(props, { slots }) {
    return () => (
    <table class="table">
    <thead>
    <tr>
    {props.columns.map(col => (
    <th key={col.key}>{col.title}</th>
    ))}
    </tr>
    </thead>
    <tbody>
    {props.data.map((row, rowIndex) => (
    <tr key={rowIndex}>
    {props.columns.map(col => (
    <td key={col.key}>
    {/* 使用作用域插槽 */}
    {slots[`column-${col.key}`]?.({
    value: row[col.key],
    row,
    column: col,
    index: rowIndex
    }) || row[col.key]}
    </td>
    ))}
    </tr>
    ))}
    </tbody>
    </table>
    )
    }
    })

    性能考量

    插槽内容的重渲染机制

    插槽内容的渲染遵循 Vue 的响应式更新机制:

    <template>
    <ChildComponent>
    <!– 这个内容会随着 parentData 变化而重新渲染 –>
    <div>{{ parentData }}</div>
    </ChildComponent>
    </template>

    关键点:

    • 插槽内容属于父组件,所以当父组件数据变化时,插槽内容会重新渲染
    • 插槽内容的重新渲染不会影响子组件的其他部分
    • 使用 v-memo 可以缓存插槽内容

    使用 v-slot 的缓存优化

    对于频繁切换的插槽内容,可以使用 v-memo 优化:

    <template>
    <ExpensiveList :items="items">
    <template #default="{ item }">
    <!– 这个内容只有在 item.id 变化时才重新渲染 –>
    <div v-memo="[item.id, item.updatedAt]">
    <h3>{{ item.title }}</h3>
    <p>{{ item.description }}</p>
    <img :src="item.image" loading="lazy" />
    </div>
    </template>
    </ExpensiveList>
    </template>

    避免不必要的插槽重绘

    <script setup>
    import { computed } from 'vue'

    // ❌ 反模式:每次渲染都创建新对象
    const listItems = computed(() =>
    items.value.map(item => ({
    item,
    timestamp: Date.now() // 每次都会变化
    }))
    )

    // ✅ 优化:只在实际变化时更新
    const listItems = computed(() => items.value)

    // ✅ 使用 shallowRef 避免深层响应
    import { shallowRef } from 'vue'
    const largeDataset = shallowRef([])
    </script>

    <template>
    <List :items="listItems">
    <template #default="{ item }">
    <!– 使用静态内容减少重绘 –>
    <ListItem
    :data="item"
    :key="item.id"
    />

    </template>
    </List>
    </template>

    插槽设计的最佳实践

    插槽使用决策树

    #mermaid-svg-JDnhuSKnvbmBfLDk{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JDnhuSKnvbmBfLDk .error-icon{fill:#552222;}#mermaid-svg-JDnhuSKnvbmBfLDk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JDnhuSKnvbmBfLDk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JDnhuSKnvbmBfLDk .marker.cross{stroke:#333333;}#mermaid-svg-JDnhuSKnvbmBfLDk svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JDnhuSKnvbmBfLDk p{margin:0;}#mermaid-svg-JDnhuSKnvbmBfLDk .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster-label text{fill:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster-label span{color:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster-label span p{background-color:transparent;}#mermaid-svg-JDnhuSKnvbmBfLDk .label text,#mermaid-svg-JDnhuSKnvbmBfLDk span{fill:#333;color:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk .node rect,#mermaid-svg-JDnhuSKnvbmBfLDk .node circle,#mermaid-svg-JDnhuSKnvbmBfLDk .node ellipse,#mermaid-svg-JDnhuSKnvbmBfLDk .node polygon,#mermaid-svg-JDnhuSKnvbmBfLDk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JDnhuSKnvbmBfLDk .rough-node .label text,#mermaid-svg-JDnhuSKnvbmBfLDk .node .label text,#mermaid-svg-JDnhuSKnvbmBfLDk .image-shape .label,#mermaid-svg-JDnhuSKnvbmBfLDk .icon-shape .label{text-anchor:middle;}#mermaid-svg-JDnhuSKnvbmBfLDk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JDnhuSKnvbmBfLDk .rough-node .label,#mermaid-svg-JDnhuSKnvbmBfLDk .node .label,#mermaid-svg-JDnhuSKnvbmBfLDk .image-shape .label,#mermaid-svg-JDnhuSKnvbmBfLDk .icon-shape .label{text-align:center;}#mermaid-svg-JDnhuSKnvbmBfLDk .node.clickable{cursor:pointer;}#mermaid-svg-JDnhuSKnvbmBfLDk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JDnhuSKnvbmBfLDk .arrowheadPath{fill:#333333;}#mermaid-svg-JDnhuSKnvbmBfLDk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JDnhuSKnvbmBfLDk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JDnhuSKnvbmBfLDk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JDnhuSKnvbmBfLDk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JDnhuSKnvbmBfLDk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JDnhuSKnvbmBfLDk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster text{fill:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster span{color:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JDnhuSKnvbmBfLDk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk rect.text{fill:none;stroke-width:0;}#mermaid-svg-JDnhuSKnvbmBfLDk .icon-shape,#mermaid-svg-JDnhuSKnvbmBfLDk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JDnhuSKnvbmBfLDk .icon-shape p,#mermaid-svg-JDnhuSKnvbmBfLDk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JDnhuSKnvbmBfLDk .icon-shape rect,#mermaid-svg-JDnhuSKnvbmBfLDk .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JDnhuSKnvbmBfLDk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JDnhuSKnvbmBfLDk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JDnhuSKnvbmBfLDk :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    一个位置

    多个位置

    需要让父组件定制内容

    有几个定制位置?

    使用默认插槽

    使用具名插槽

    需要访问子组件数据?

    使用作用域插槽

    使用普通插槽

    暴露最小必要数据

    提供合理的默认内容

    插槽设计的最佳实践清单

    • 为所有插槽提供默认内容,让组件在没有插槽时也能正常工作
    • 使用 TypeScript 定义插槽类型,提供更好的开发体验
    • 作用域插槽只暴露必要的数据,避免暴露整个组件实例
    • 插槽命名使用清晰的语义,如 header、item、actions
    • 考虑插槽的层级关系,使用 v-bind 传递多个 props
    • 使用动态插槽名处理不确定数量的插槽
    • 递归组件中正确传递插槽
    • 注意插槽内容的性能优化,使用 v-memo 等指令

    结语

    插槽设计的核心思想是 “控制反转”:组件负责核心逻辑和结构,父组件负责具体内容的渲染。这种设计带来了几个关键优势:

  • 单一职责:组件专注于核心功能,不关心具体内容
  • 开放封闭:对扩展开放(通过插槽),对修改封闭(不修改组件内部)
  • 组合优于继承:通过插槽组合不同内容,而不是通过继承创建变体
  • 对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

    赞(0)
    未经允许不得转载:171主机测评 » 插槽的作用域与分发:如何让组件更灵活、可定制?
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址