插槽 slot:组件版的模板方法模式

为什么需要插槽

组件用 Props 传数据很顺手,但 Props 只能传"值"。想传一段 HTML / 组件 / 复杂结构怎么办?

举个例子:写一个通用的 <Card> 组件。卡片长一样(圆角、阴影、内边距),但内容每次都不一样——首页卡片放笔记摘要、详情页卡片放上一篇下一篇、用户中心卡片放头像和姓名。

如果用 Props:

<!-- ❌ props 传 HTML 字符串,要用 v-html,XSS 风险 -->
<Card content="<h3>标题</h3><p>摘要</p>" />

如果每种内容写一个 Card 变种(NoteCard / UserCard / NavCard)又失去了"Card"的抽象意义。

插槽就是解决这个问题的——Card 负责"壳子",内容留一个"洞",让调用者填。

Java 对照:模板方法模式

public abstract class Card {
    public final void render() {
        renderHeader();
        renderContent();   // 留给子类实现的"洞"
        renderFooter();
    }
    protected abstract void renderContent();
}

class NoteCard extends Card {
    @Override
    protected void renderContent() { /* 渲染笔记 */ }
}

Card.render() 是骨架,renderContent() 是插槽。Vue 的插槽就是这个模式,但用组合代替继承


1. 默认插槽:最简单的"洞"

<!-- 子组件 Card.vue -->
<template>
  <div class="card">
    <div class="card-body">
      <slot />  <!-- 留一个洞 -->
    </div>
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 16px;
  box-shadow: 0 2px 4px rgba(0,0,0,.05);
}
</style>

调用方:

<Card>
  <h3>ES6 核心语法</h3>
  <p>从 Java 视角快速过一遍 ES6 的新特性。</p>
</Card>

<Card>
  <img src="/avatar.png" />
  <p>张三</p>
</Card>

内容不同、外壳一致——这就是插槽的价值。

现场跑:一个 Card 外壳 + 三种内容

改改 Card 的内容区,外壳的样式一行都没碰:

<script setup>
</script>

<template>
  <div class="page">
    <Card>
      <h3>ES6 核心语法</h3>
      <p>从 Java 视角快速过一遍 ES6。</p>
    </Card>
    <Card>
      <img src="https://avatars.githubusercontent.com/u/499550?v=4" width="48" />
      <p>尤雨溪 · Vue 作者</p>
    </Card>
    <Card>
      <button @click="$emit || undefined">我也能放交互</button>
    </Card>
  </div>
</template>

<script>
import { defineComponent } from 'vue'
export default defineComponent({
  components: {
    Card: {
      template: `
        <div class="card">
          <div class="body"><slot>暂无内容</slot></div>
        </div>
      `,
    },
  },
})
</script>

<style scoped>
.page { padding: 20px; font-family: system-ui; display: grid; gap: 12px; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.05); background: white; }
.body { padding: 14px 18px; }
.card img { border-radius: 50%; vertical-align: middle; margin-right: 8px; }
button { padding: 4px 12px; border: 1px solid #8b5cf6; background: #8b5cf6; color: white; border-radius: 4px; cursor: pointer; }
</style>

默认内容(fallback)

<!-- Card.vue -->
<slot>暂无内容</slot>

调用时不填内容:<Card /> → 显示 "暂无内容"。填了就显示填的。

Java 对照

protected void renderContent() {
    // 子类没覆盖时的默认实现
    System.out.println("暂无内容");
}

2. 具名插槽:多个"洞"

一个组件常常需要多个位置让外部填。比如布局组件:

<!-- DefaultLayout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header">默认顶栏</slot>
    </header>
    <main>
      <slot />  <!-- 默认插槽,相当于 name="default" -->
    </main>
    <footer>
      <slot name="footer">默认底栏</slot>
    </footer>
  </div>
</template>

调用方用 <template v-slot:xxx>#xxx 简写:

<DefaultLayout>
  <template #header>
    <h1>in4vue</h1>
    <nav>...</nav>
  </template>

  <!-- 默认插槽:不用 template 包裹也行 -->
  <p>这是主体内容</p>

  <template #footer>
    <small>© 2026 yingjf</small>
  </template>
</DefaultLayout>

项目里的例子src/layouts/DefaultLayout.vue 就用了默认插槽(只留一个 <slot /> 给页面填内容)。如果以后首页要自定义顶栏右侧的搜索框,就能加一个 <slot name="header-right" /> 具名插槽。


3. 作用域插槽:子组件把数据"递给"父组件填

这是插槽里最强大的一招。子组件持有数据,但让父组件决定怎么渲染

典型场景:通用列表组件

<!-- DataList.vue -->
<script setup lang="ts">
defineProps<{ items: unknown[] }>()
</script>

<template>
  <ul class="data-list">
    <li v-for="(item, index) in items" :key="index">
      <!-- 把 item 和 index 通过插槽 props 传给父组件 -->
      <slot :item="item" :index="index" />
    </li>
  </ul>
</template>

调用方:

<DataList :items="notes">
  <template #default="{ item, index }">
    <!-- 解构拿到子组件传出来的 item / index -->
    <span>{{ index + 1 }}.</span>
    <strong>{{ item.title }}</strong>
    <span>{{ item.category }}</span>
  </template>
</DataList>

<DataList :items="users">
  <template #default="{ item }">
    <img :src="item.avatar" />
    <span>{{ item.name }}</span>
  </template>
</DataList>

亮点

Java 对照:回调 + 数据

// 策略模式的变体:把"怎么渲染一行"作为函数参数传进来
list.forEach(item -> System.out.println(item.title()));
// 作用域插槽 ≈ list.forEach 里的 lambda

Element Plus 的 <el-table> 大量用作用域插槽:

<el-table :data="notes">
  <el-table-column label="标题">
    <template #default="{ row }">
      <a :href="`/notes/${row.slug}`">{{ row.title }}</a>
    </template>
  </el-table-column>
</el-table>

row 就是子组件(el-table-column)传出来的当前行数据。不会用作用域插槽,就用不好 Element Plus 的表格


4. 三种插槽速查表

类型 子组件写法 父组件写法 用途
默认插槽 <slot /> <Child>...</Child> 一个洞
具名插槽 <slot name="x" /> <template #x>...</template> 多个洞
作用域插槽 <slot :foo="bar" /> <template #default="{ foo }">...</template> 子数据传给父渲染

语法记忆点


5. 新手常见误区

误区 1:想从父组件访问子组件作用域

<Card>
  <!-- ❌ card 里的 internalData 在父作用域访问不到 -->
  <p>{{ internalData }}</p>
</Card>

规则:父组件模板里写的内容,作用域属于父组件。子组件的内部变量想暴露给父,必须通过 slot :xxx="internalData" 作用域插槽的形式。

误区 2:一个作用域插槽传太多东西

<!-- 可以,但不推荐 -->
<slot :a="1" :b="2" :c="3" :d="4" :e="5" />

建议:把多个值打包成一个对象:

<slot :ctx="{ item, index, selected, disabled }" />

<!-- 父组件 -->
<template #default="{ ctx }">
  {{ ctx.item.title }}
</template>

调用方可以只解构需要的字段:{ ctx: { item } }

误区 3:把"应该用组件"的东西塞进插槽

插槽适合内容不固定但骨架固定的场景。如果每次"填"的内容都非常不同,可能本身就该写多个独立组件,而不是硬用一个带插槽的超级组件。

判断标准:看"骨架"是否有复用价值。Card 的骨架(圆角、阴影)复用性高 → 用插槽。要是每个 Card 连边框样式都不一样,那就别勉强。


6. 项目里的落地思考

src/layouts/DefaultLayout.vue 现在只有默认插槽。随着页面变多,可能会演化出:

<template>
  <div class="layout">
    <header>
      <Logo />
      <slot name="header-extra" />   <!-- 各页面自定义顶栏操作区 -->
    </header>
    <main>
      <slot />
    </main>
    <footer>
      <slot name="footer">
        <!-- 默认底栏 -->
      </slot>
    </footer>
  </div>
</template>

这就是模板方法模式在 Vue 里的落地——骨架固定,扩展点标准化。


7. 原理深挖:插槽编译后是函数,不是 HTML

模板里看着是"在标签里塞 HTML",编译后插槽内容变成函数

<!-- 父组件 -->
<Card>
  <h3>{{ title }}</h3>
  <p>{{ summary }}</p>
</Card>

编译后(简化):

createVNode(Card, null, {
  default: () => [          // ★ 不是 vnode 数组,是返回 vnode 数组的函数
    createVNode('h3', null, _ctx.title),
    createVNode('p', null, _ctx.summary)
  ],
  _: 1                       // 编译标记:稳定插槽
})

为什么是函数

  1. 延迟求值——子组件决定什么时候渲染、渲染几次。v-for 在子组件里循环 slot,每次迭代时调一次函数,每次拿到一份新 vnode
  2. 作用域捕获——函数闭包捕获了父组件_ctx,所以 {{ title }} 永远是父组件的 title,不是子组件的
  3. 响应式跟踪——函数被组件 render effect 调用时,里面访问的响应式变量自动收依赖

子组件的 <slot /> 编译后大概长这样:

renderSlot(_ctx.$slots, 'default', { /* slot props */ })
// 内部基本就是 _ctx.$slots.default?.(slotProps)

关键推论


8. 原理深挖:作用域插槽 = 反向调用

普通 props 是父→子传值,作用域插槽是子→父传值——但实现机制完全不一样:

普通 props:
  父组件 createVNode(Child, { foo: bar })
    → 子实例的 setup(props)
    → props.foo 在子组件里能用

作用域插槽:
  父组件给子传一个函数 (slotProps) => vnode[]
  子组件 render 时调用这个函数 fn({ item, index })
    → 函数返回的 vnode 用了父组件的 _ctx + 子传过来的参数

两个方向同时存在

父 _ctx       传 slot fn 给       子 _ctx
   │            ──────────►          │
   │                                 │
   │       子调 fn(slotProps)        │
   │       ◄─────────────────────    │
   ▼                                 ▼
  vnode 里既有父的变量也有子传出来的 slotProps

为什么 Element Plus 的 table 必须用作用域插槽

<el-table :data="notes">
  <el-table-column label="标题">
    <template #default="{ row }">       <!-- row 是子传给父的 -->
      <a :href="`/notes/${row.slug}`">{{ row.title }}</a>
    </template>
  </el-table-column>
</el-table>

table 子组件循环 data 时,每行调用 slot 函数 slot({ row: notes[i], $index: i })外部不传作用域插槽,table 不知道每行的内容是什么——这把"骨架(循环、布局、虚拟滚动)"和"每行长什么样"彻底分开。

Java 对照:作用域插槽相当于把 Function<Item, ReactNode> 当参数传进来——但 Vue 用模板表达更直观。


9. 原理深挖:动态插槽 + 编译期标记

<template>
  <Card>
    <template #[dynamicName]>动态名字的插槽</template>
  </Card>
</template>

编译器无法静态确定 dynamicName 的值,所以:

createVNode(Card, null, {
  [_ctx.dynamicName]: () => [...],
  _: 2                                // 标记:动态插槽
})

_slotFlag,三个值:

含义 影响
1 STABLE 静态插槽(编译期已知所有 name) 子组件可以缓存 slot fn 引用
2 DYNAMIC 动态插槽(有 #[xxx] 每次重渲染都重建
3 FORWARDED 这个插槽是从父继承下来的 转发场景

实战影响:动态插槽性能比静态插槽差——编译器没法做静态分析、子组件不能缓存。除非真有动态需求,优先静态命名


10. 原理深挖:$slots 是个对象,里面全是函数

getCurrentInstance() 拿到的实例上有个 slots 字段:

<script setup>
import { useSlots } from 'vue'
const slots = useSlots()

console.log(slots.default)   // ƒ() { ... }  插槽内容的渲染函数
console.log(slots.header)    // ƒ() { ... }
console.log(typeof slots.default)   // 'function'

// 用代码判断有没有内容
if (slots.default) {
  // 有默认插槽
}
</script>

实用场景——条件渲染插槽容器:

<!-- 子组件:只有当真的传了 footer 插槽时才渲染外层 div -->
<template>
  <div class="card">
    <slot />
    <div v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </div>
  </div>
</template>

不写 v-if="$slots.footer" 的话,没传插槽时也会渲染一个空 card-footer 容器——浪费 DOM 还可能有边距问题。

手写渲染函数(render function)里用插槽

import { h, useSlots } from 'vue'

export default {
  setup() {
    const slots = useSlots()
    return () => h('div', [
      slots.default?.(),                    // 调函数拿 vnode
      slots.footer?.({ time: Date.now() })  // 作用域插槽:传 slotProps
    ])
  }
}

?.() 是因为没传插槽时 slots.xxx 是 undefined。


11. 原理深挖:插槽和组件树的关系

一个常见疑惑:插槽内容是父组件的还是子组件的?

<!-- 父 -->
<Card>
  <Counter />        <!-- Counter 组件实例属于谁? -->
</Card>

答案:Counter 是父组件的子不是 Card 的子——尽管 DOM 上它在 Card 的 div 里。

组件树(逻辑)          DOM 树(物理)
父                      父
├─ Card                 ├─ <div class="card">
│                       │   ├─ <div class="card-body">
└─ Counter              │   │   └─ <button>...</button>   ← Counter 渲染的 DOM
                        │   └─ </div>
                        └─ </div>

关键推论

  1. provide/inject 走组件树,不走 DOM 树——父在 Card 外面 provide 的值,Counter 也能 inject 到(因为 Counter 是父的子)
  2. errorCaptured 也走组件树——Counter 抛错先冒到父,再冒到 Card 的话父级都抓不到(Card 不是 Counter 的祖先)
  3. Counter 的 onMounted 时机——和它在父组件里直接渲染时一样,不受 Card 影响

Java 对照:类似 Spring 的 Bean 容器图——Bean 的依赖关系不一定和文件目录结构一致。Vue 的组件树是调用关系图,DOM 树是渲染结果图


12. 原理深挖:为什么 v-for 里塞 slot 行得通

<!-- 子组件 DataList -->
<template>
  <ul>
    <li v-for="(item, i) in items" :key="i">
      <slot :item="item" :index="i" />
    </li>
  </ul>
</template>

这里 <slot>v-for 里——同一个 slot 函数被调用了多次

// 编译后大概
items.value.map((item, i) =>
  h('li', { key: i }, [
    slots.default?.({ item, index: i })   // ★ 每次迭代调一次
  ])
)

这就是作用域插槽强大的本质——slot 是函数,可以被调用任意多次,每次传不同的参数,于是父组件提供的渲染逻辑可以被批量复用。

对比错误的"想当然"

// ❌ 错误的心智模型:以为 slot 是 vnode 数组
<ul>
  <li v-for="item in items">
    {{ slot 内容 }}        // 只写一次,怎么循环?
  </li>
</ul>

slot 是函数 → 可以被循环调用 → 每次返回一份独立的 vnode → 树不会"共享"。


小练习:给 Card 做个抽屉式组件

  1. 新建 src/components/ContentCard.vue,提供三个插槽:#header, default, #footer
  2. 在首页试着把某张笔记卡片换成:
    <ContentCard>
      <template #header>
        <span class="tag">{{ note.category }}</span>
      </template>
      <h3>{{ note.title }}</h3>
      <p>{{ note.summary }}</p>
      <template #footer>
        <span>{{ note.date }}</span>
      </template>
    </ContentCard>
    
  3. 体会:Card 的边框样式改一次,所有调用方自动跟上

延伸阅读