插槽 {#slots}

阅读此章节时,我们假设你已经读过组件基础,若你对组件还完全不了解,请先阅读它。

插槽内容与插口 {#slot-content-and-outlet}

我们已经学习过组件能够接收任意类型的 JavaScript 值作为 props,但组件要如何接收模板内容呢?在某些场景中,我们可能想要为子组件传递一些模板片段,让子组件在它们的组件中渲染这些片段。

举个例子,这里有一个 <FancyButton> 组件,可以像这样使用:

  1. <FancyButton>
  2. Click me! <!-- 插槽内容 -->
  3. </FancyButton>

<FancyButton> 的模板是这样的:

  1. <button class="fancy-btn">
  2. <slot></slot> <!-- 插槽插口 -->
  3. </button>

<slot> 元素是一个插槽的插口,标示了父元素提供的插槽内容将在哪里被渲染。

插槽图示

最终渲染出的 DOM 是这样:

  1. <button class="fancy-btn">
  2. Click me!
  3. </button>

通过使用插槽,让 <FancyButton> 仅负责渲染外层的 <button> (以及相应的样式),而内部的内容由父组件提供。

通过和下面的 JavaScript 函数作对比,来以另一种方式理解插槽:

  1. // 父元素传入插槽内容
  2. FancyButton('Click me!')
  3. // FancyButton 在自己的模板中渲染插槽内容
  4. function FancyButton(slotContent) {
  5. return (
  6. `<button class="fancy-btn">
  7. ${slotContent}
  8. </button>`
  9. )
  10. }

除了文本以外,插槽内容还可以是任意合法的模板内容。例如我们可以传入多个元素,甚至是组件:

  1. <FancyButton>
  2. <span style="color:red">Click me!</span>
  3. <AwesomeIcon name="plus" />
  4. </FancyButton>

通过使用插槽,<FancyButton> 组件更加灵活和具有可复用性。现在组件可以用在不同的地方渲染各异的内容,但同时还保证都具有相同的样式。

Vue 组件的插槽机制是受原生 Web Component <slot> 元素的启发而诞生,同时还做了一些功能拓展,这些拓展的功能我们后面会学习到。

渲染作用域 {#render-scope}

插槽内容可以访问到父组件的数据作用域,因为插槽内容本身是在父组件模板中定义的。举个例子:

  1. <span>{{ message }}</span>
  2. <FancyButton>{{ message }}</FancyButton>

这里的两个 {{ message }} 插值表达式渲染的内容都是一样的。

插槽内容无法访问子组件的数据,请牢记一条规则:

任何父组件模板中的东西都只被编译到父组件的作用域中;而任何子组件模板中的东西都只被编译到子组件的作用域中。

默认内容 {#fallback-content}

在外部没有提供任何内容的情况下,为插槽指定默认内容用于渲染是很有用的。比如在 <SubmitButton> 组件中:

  1. <button type="submit">
  2. <slot></slot>
  3. </button>

如果我们想在父组件没有提供任何插槽内容时,把“Submit”文本渲染到 <button> 内。需要将“Submit”写在 <slot> 标签之间,使其成为默认内容:

  1. <button type="submit">
  2. <slot>
  3. Submit <!-- 默认内容 -->
  4. </slot>
  5. </button>

当我们在父组件中使用 <SubmitButton> 但不提供任何插槽内容:

  1. <SubmitButton />

那么将渲染默认的“Submit”单词:

  1. <button type="submit">Submit</button>

但如果我们提供了别的内容:

  1. <SubmitButton>Save</SubmitButton>

那么将渲染提供的内容:

  1. <button type="submit">Save</button>

具名插槽 {#named-slots}

有时在一个组件中包含多个插槽的插口是很有用的。举个例子,在一个 <BaseLayout> 组件中,有如下这样的模板:

  1. <div class="container">
  2. <header>
  3. <!-- 标题内容放这里 -->
  4. </header>
  5. <main>
  6. <!-- 主要内容放这里 -->
  7. </main>
  8. <footer>
  9. <!-- 底部内容放这里 -->
  10. </footer>
  11. </div>

对于这种场景,<slot> 元素可以有一个特殊的 attribute name,用来给各个插槽分配唯一的 ID,以确定每一处要渲染的内容:

  1. <div class="container">
  2. <header>
  3. <slot name="header"></slot>
  4. </header>
  5. <main>
  6. <slot></slot>
  7. </main>
  8. <footer>
  9. <slot name="footer"></slot>
  10. </footer>
  11. </div>

没有提供 name<slot> 插口会隐式地命名为“default”。

在父组件中使用 <BaseLayout> 时,我们需要一种方式将多个插槽内容传入到各自目标插槽的插口。此时就需要用到具名插槽了:

要为具名插槽传入内容,我们需要使用一个含 v-slot 指令的 <template> 元素,并将目标插槽的名字传给该指令:

  1. <BaseLayout>
  2. <template v-slot:header>
  3. <!-- header 插槽的内容放这里 -->
  4. </template>
  5. </BaseLayout>

v-slot 有对应的简写 #,因此 <template v-slot:header> 可以简写为 <template #header>。其意思就是“将这部分模板片段传入子组件的 header 插槽中”。

具名插槽图示

下面我们给出完整的、向 <BaseLayout> 传递插槽内容的代码,指令均使用的是缩写形式:

  1. <BaseLayout>
  2. <template #header>
  3. <h1>Here might be a page title</h1>
  4. </template>
  5. <template #default>
  6. <p>A paragraph for the main content.</p>
  7. <p>And another one.</p>
  8. </template>
  9. <template #footer>
  10. <p>Here's some contact info</p>
  11. </template>
  12. </BaseLayout>

当一个组件同时接收默认插槽和具名插槽时,所有位于顶级的非 <template> 节点都被隐式地视为默认插槽的内容。所以上面也可以写成:

  1. <BaseLayout>
  2. <template #header>
  3. <h1>Here might be a page title</h1>
  4. </template>
  5. <!-- 隐式的默认插槽 -->
  6. <p>A paragraph for the main content.</p>
  7. <p>And another one.</p>
  8. <template #footer>
  9. <p>Here's some contact info</p>
  10. </template>
  11. </BaseLayout>

现在 <template> 元素中的所有内容都将被传递到相应的插槽。最终渲染出的 HTML 如下:

  1. <div class="container">
  2. <header>
  3. <h1>Here might be a page title</h1>
  4. </header>
  5. <main>
  6. <p>A paragraph for the main content.</p>
  7. <p>And another one.</p>
  8. </main>
  9. <footer>
  10. <p>Here's some contact info</p>
  11. </footer>
  12. </div>

使用 JavaScript 函数可能更有助于你来理解具名插槽:

  1. // 传入不同的内容给不同名字的插槽
  2. BaseLayout({
  3. header: `...`,
  4. default: `...`,
  5. footer: `...`
  6. })
  7. // <BaseLayout> 渲染插槽内容到对应位置
  8. function BaseLayout(slots) {
  9. return (
  10. `<div class="container">
  11. <header>${slots.header}</header>
  12. <main>${slots.default}</main>
  13. <footer>${slots.footer}</footer>
  14. </div>`
  15. )
  16. }

动态插槽名 {#dynamic-slot-names}

动态指令参数v-slot 上也是有效的,即可以定义下面这样的动态插槽名:

  1. <base-layout>
  2. <template v-slot:[dynamicSlotName]>
  3. ...
  4. </template>
  5. <!-- 缩写为 -->
  6. <template #[dynamicSlotName]>
  7. ...
  8. </template>
  9. </base-layout>

注意这里的表达式和动态指令参数受相同的语法限制

作用域插槽 {#scoped-slots}

在上面的渲染作用域中我们讨论到,插槽的内容无法访问到子组件的状态。

然而在某些场景下插槽的内容可能想要同时使用父组件域内和子组件域内的数据。要做到这一点,我们需要一种方法来让子组件在渲染时将一部分数据提供给插槽。

我们也确实有办法这么做!可以像对组件传递 prop 那样,向一个插槽的插口上传递 attribute:

  1. <!-- <MyComponent> 的模板 -->
  2. <div>
  3. <slot :text="greetingMessage" :count="1"></slot>
  4. </div>

当需要接收插槽 prop 时,默认插槽和具名插槽的使用方式有一些小区别。下面我们将先展示默认插槽如何接受 prop,通过子组件标签上的 v-slot 指令,直接接收到了一个插槽 prop 对象:

  1. <MyComponent v-slot="slotProps">
  2. {{ slotProps.text }} {{ slotProps.count }}
  3. </MyComponent>

scoped slots diagram

子组件传入插槽的 props 作为了 v-slot 指令的值,可以在插槽内的表达式中访问。

你可以将作用域插槽类比为一个传入子组件的函数。子组件会将相应的 prop 作为参数去调用它:

  1. MyComponent({
  2. // 类比默认插槽,将其想成一个函数
  3. default: (slotProps) => {
  4. return `${slotProps.text} ${slotProps.count}`
  5. }
  6. })
  7. function MyComponent(slots) {
  8. const greetingMessage = 'hello'
  9. return (
  10. `<div>${
  11. // 在插槽函数调用时传入 props
  12. slots.default({ text: greetingMessage, count: 1 })
  13. }</div>`
  14. )
  15. }

实际上,这已经和作用域插槽的最终代码编译结果、以及手动编写渲染函数时使用作用域插槽的方式非常类似了。

v-slot="slotProps" 可以类比这里的函数签名,和函数的参数类似,我们也可以在 v-slot 中使用解构:

  1. <MyComponent v-slot="{ text, count }">
  2. {{ text }} {{ count }}
  3. </MyComponent>

具名作用域插槽 {#named-scoped-slots}

具名作用域插槽的工作方式也是类似的,插槽 props 可以作为 v-slot 指令的值被访问到:v-slot:name="slotProps"。当使用缩写时是这样:

  1. <MyComponent>
  2. <template #header="headerProps">
  3. {{ headerProps }}
  4. </template>
  5. <template #default="defaultProps">
  6. {{ defaultProps }}
  7. </template>
  8. <template #footer="footerProps">
  9. {{ footerProps }}
  10. </template>
  11. </MyComponent>

向具名插槽中传入 props:

  1. <slot name="header" message="hello"></slot>

注意插槽上的 name 是由 Vue 保留的,不会作为 props 传递给插槽。因此最终 headerProps 的结果是 { message: 'hello' }

一个漂亮的列表示例 {#fancy-list-example}

想要了解作用域插槽怎么样使用更加优雅吗?不妨看看这个 <FancyList> 组件的例子,它会渲染一个列表,其中会封装一些加载远端数据的逻辑、以及使用此数据来做列表渲染,或者是像分页、无限滚动这样更进阶的功能。然而我们希望它能够灵活处理每一项的外观,并将对每一项样式的控制权留给使用它的父组件。我们期望的用法可能是这样的:

  1. <FancyList :api-url="url" :per-page="10">
  2. <template #item="{ body, username, likes }">
  3. <div class="item">
  4. <p>{{ body }}</p>
  5. <p>by {{ username }} | {{ likes }} likes</p>
  6. </div>
  7. </template>
  8. </FancyList>

<FancyList> 之中,我们可以多次渲染 <slot> 并每次都提供不同的数据 (注意我们这里使用了 v-bind 来传递插槽的 props):

  1. <ul>
  2. <li v-for="item in items">
  3. <slot name="item" v-bind="item"></slot>
  4. </li>
  5. </ul>

无渲染组件 {#renderless-components}

上面的 <FancyList> 案例同时封装了可重用的逻辑 (数据获取、分页等) 和视图输出,但也将部分视图输出通过作用域插槽交给了消费者组件来管理。

如果我们将这个概念拓展一下,可以想象的是,一些组件可能只包括了逻辑而不需要自己渲染内容,视图输出通过作用域插槽全权交给了消费者组件。我们将这种类型的组件称为无渲染组件

这里有一个无渲染组件的例子,一个封装了追踪当前鼠标位置逻辑的组件:

  1. <MouseTracker v-slot="{ x, y }">
  2. Mouse is at: {{ x }}, {{ y }}
  3. </MouseTracker>

虽然这个模式很有趣,但大部分能用无渲染组件实现的功能都可以通过组合式 API 以另一种更高效的方式实现,并且还不会带来额外组件嵌套的开销。之后我们会在组合式函数一章中介绍如何更高效地实现追踪鼠标位置的功能。

尽管如此,作用域插槽在需要同时封装逻辑、组合视图界面时还是很有用,就像上面的 <FancyList> 组件那样。