人人都会AI编程

10.1 插槽系统

更新时间:2026-07-11

组件最大的价值是复用,但复用就面临一个矛盾:组件的大部分结构是固定的,可总有一小部分内容需要由父组件来决定。比如一个弹窗组件,标题和按钮文字可能每次都不同,甚至整个内容区域都需要自定义。写死肯定不行,全用 Props 传 HTML 字符串又太僵硬。Vue 的插槽系统就是用来解决这个问题的——它让你在组件内部开几个“洞”,由父组件在使用时填入具体内容。

默认插槽:一个最基础的“内容入口”

如果组件只需要一个可替换的区域,就用默认插槽。子组件用 <slot> 标签定义占位,父组件直接在标签内写内容。

<!-- MyDialog.vue - 子组件 -->
<template>
  <div class="dialog">
    <slot></slot>  <!-- 这个位置的 HTML 由父组件提供 -->
  </div>
</template>
<!-- 父组件 -->
<MyDialog>
  <p>确认要删除这条记录吗?</p>
</MyDialog>

渲染结果就是 <p>确认要删除这条记录吗?</p> 出现在 <div class="dialog"> 里面。父组件写什么,<slot> 就换成什么。如果父组件没传内容,也可以给 <slot> 加一个后备内容:

<slot>这是默认文案,父组件没传内容时会显示</slot>

默认插槽在封装布局类组件时尤其方便,比如页面容器 <Card>,外面是边框和阴影,里面插内容。

具名插槽:多个区域的精确分发

很多时候一个组件不止一个可变区域。一个典型的布局组件可能有头部、主体、底部三个插槽。这时候就需要给每个插槽起个名字。

<!-- LayoutPanel.vue -->
<template>
  <div class="panel">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>  <!-- 没有 name 的插槽默认名为 "default" -->
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

父组件使用 v-slot 指令(可以简写为 #)来指定内容要放到哪个插槽:

<LayoutPanel>
  <template #header>
    <h1>页面标题</h1>
  </template>

  <p>这里是主体内容,自动放入默认插槽</p>

  <template #footer>
    <button>确定</button>
  </template>
</LayoutPanel>

v-slot 只能用在 <template> 标签上(或者组件上,后面作用域插槽会涉及),而默认插槽的内容如果不包装 template,直接放在标签内部就行。

作用域插槽:从子组件向父组件“回传数据”

前面两种插槽是父组件“塞内容”给子组件,数据流向单一。但有时父组件需要根据子组件内部的数据来动态渲染内容。比如一个列表组件 <TodoList>,它掌握了“待办事项数组”这个数据,但每一项的 UI 样式希望由父组件来决定——有的页面是文字加删除按钮,有的页面可能带颜色标签。

这时候就不能让子组件把数据直接渲染成死 HTML,而要“把数据暴露给父组件,让父组件决定怎么渲染”。这就是作用域插槽

子组件在 <slot> 上绑定属性,父组件通过 v-slot 接收一个对象。

<!-- TodoList.vue -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot :item="item" :index="index"></slot>
    </li>
  </ul>
</template>

<script setup>
defineProps(['items'])
</script>

父组件使用:

<TodoList :items="list">
  <template v-slot="{ item, index }">
    <span>{{ index + 1 }}. {{ item.text }}</span>
    <button @click="remove(item.id)">删除</button>
  </template>
</TodoList>

每次循环,子组件把当前的 itemindex 传给插槽,父组件拿到后自由渲染。这种机制让逻辑封装在子组件里,而显示控制权交给父组件,非常灵活。

如果只有默认插槽,v-slot 可以直接写在组件标签上,省略 <template>

<TodoList :items="list" v-slot="{ item }">
  <span>{{ item.text }}</span>
</TodoList>

插槽的底层原理与渲染时机

理解插槽怎么工作的,能帮你少踩坑。

插槽内容的作用域属于父组件。即使你把模板写在子组件标签内部,里面的变量、方法都是父组件的。比如:

<MyDialog>
  <p>{{ parentMessage }}</p>  <!-- parentMessage 来自父组件 -->
</MyDialog>

这点很重要,因为有些开发者误以为插槽内容是在子组件里执行的,试图访问子组件的数据,结果发现拿不到。

底层过程可以简化为:

  1. 父组件编译时,遇到子组件标签内的内容,会生成对应的 VNode 树(插槽函数)。
  2. 子组件渲染时,遇到 <slot> 标记,就调用父组件传入的插槽函数,得到 VNode 并放入该位置。
  3. 对于作用域插槽,子组件在调用插槽函数时会传入数据对象,父组件通过解构拿到。

因此,插槽的本质是父组件定义了一组“待渲染的虚拟节点”,由子组件决定何时、放在哪个位置执行渲染。这解释了为什么作用域插槽的数据必须由子组件显式暴露:父组件的插槽函数本身是父组件作用域的,子组件的数据对它不可见,只有子组件主动通过参数传递才能拿到。

实际应用中的几个常见模式

  • 表格的自定义列:表格组件循环展示数据,但某列需要渲染成状态标签或操作按钮,就暴出每行的数据让父组件自定义。
  • 表单的二级封装:封装一个 <FormItem> 验证容器,中间留插槽放具体输入控件,把 error 信息通过作用域插槽回传,让父组件决定错误提示的样式。
  • 布局与页面骨架<Layout> 提供 header、sidebar、content 的具名插槽,不同页面直接复用。

插槽系统的设计非常务实:它不搞出一套全新概念,而是类似原生 HTML 的“内容嵌套”,但加入了命名分组和数据回传,让组件复用的边界清晰、灵活且不牺牲可维护性。