Skip to content

Vue 插槽与作用域插槽深度解析

一、 问题背景:封装异步请求逻辑

在组件开发中,经常会遇到需要处理异步数据请求的场景。一个典型的异步流程包含三种状态:

  1. 加载中 (Loading):数据正在请求,尚未返回。
  2. 成功 (Success):数据成功获取,需要渲染到页面。
  3. 失败 (Error):请求过程中发生错误,需要展示错误信息。

这种模式在多个组件中重复出现,导致了代码冗余。

1. 传统解决方案:Mixins

我们可以使用 mixin 来抽离公共的 data (如 isLoading, data, error) 和 methods

  • 优点:封装了组件的 JS 逻辑,避免了重复的状态管理代码。
  • 缺点:无法封装模板 (Template) 部分。每个使用该 mixin 的组件仍然需要编写一套重复的 v-if / v-else-if / v-else 来控制三种状态的显示,导致模板代码冗余。

2. 更优解:利用插槽封装通用组件

为了同时封装逻辑和视图,我们可以创建一个通用的异步内容展示组件 (AsyncComponent)。

设计思路:

  • 该组件接收一个 promise 对象作为 prop
  • 组件内部管理 loading, data, error 三种状态。
  • 组件通过插槽 (Slot) 将不同状态下的 UI 渲染权交还给父组件。

期望的父组件用法:

html
<template>
  <AsyncComponent :contentPromise="fetchProducts()">

    <template v-slot:loading>
      <p>正在加载中...</p>
    </template>

    <template v-slot:default>
      </template>

    <template v-slot:error>
      </template>

  </AsyncComponent>
</template>

<script>
import { getProducts } from './api';

export default {
  methods: {
    fetchProducts() {
      // 这个函数返回一个 Promise
      return getProducts();
    }
  }
}
</script>

二、 AsyncComponent 的实现与演进

1. 模拟 API 函数

首先,我们模拟一个异步获取数据的函数,它会随机成功或失败。

javascript
// api.js
function getProducts() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() < 0.5) {
        resolve([
          { id: 1, name: '商品A', stock: 10 },
          { id: 2, name: '商品B', stock: 20 },
        ]);
      } else {
        reject(new Error('Not Found'));
      }
    }, 1000);
  });
}

2. AsyncComponent 初步实现

组件内部接收 promise,管理状态,并使用 v-if 控制不同插槽的显示。

vue
<template>
  <div>
    <div v-if="isLoading">
      <slot name="loading">
        <p>默认加载中效果...</p> </slot>
    </div>

    <div v-else-if="error">
      <slot name="error">
        <p style="color: red;">默认错误显示</p> </slot>
    </div>

    <div v-else>
      <slot name="default">
        <p>默认数据</p> </slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    contentPromise: {
      type: Promise,
      required: true,
    }
  },
  data() {
    return {
      isLoading: true, // 是否加载中
      content: null,   // 成功后的数据
      error: null,     // 失败后的错误信息
    };
  },
  async created() {
    try {
      const data = await this.contentPromise;
      this.content = data;
      this.error = null;
    } catch (e) {
      this.content = null;
      // 注意:直接赋值Error对象可能导致响应式问题
      // 最好将其包装在普通对象中
      this.error = { originalError: e, message: e.message };
    } finally {
      this.isLoading = false;
    }
  }
}
</script>

3. 新问题:父组件无法获取子组件的数据

上述实现中,父组件虽然可以自定义三个状态的 UI,但无法访问 AsyncComponent 内部的 content (成功的数据) 和 error (错误信息)。

例如,在父组件的默认插槽中,我们无法这样做,因为 content 不存在于父组件的上下文中:

html
<template v-slot:default>
  <ul>
    <li v-for="item in content" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

三、 作用域插槽 (Scoped Slots)

作用域插槽就是为了解决上述问题而生的。它允许 子组件在渲染插槽时,将自身的数据传递给父组件的插槽

1. 子组件:通过 v-bind 传递数据

在子组件 (AsyncComponent) 的 <slot> 标签上,使用 v-bind (简写 :) 将数据绑定到插槽上。

vue
<template>
  <div>
    <div v-if="isLoading">
      <slot name="loading">...</slot>
    </div>

    <div v-else-if="error">
      <slot name="error" :error="error"></slot>
    </div>

    <div v-else>
      <slot name="default" :content="content"></slot>
    </div>
  </div>
</template>

另一种绑定方式

如果想传递多个数据,或者确保传递的是一个普通对象(避免类似 Error 对象的响应式问题),可以这样写:

html
<slot name="default" v-bind="{ content: content }"></slot>
<slot name="default" :content="content"></slot>

2. 父组件:通过 v-slot 接收数据

在父组件中,使用 v-slot 指令来接收子组件传递的数据。

html
<template>
  <AsyncComponent :contentPromise="fetchProducts()">

    <template v-slot:loading>
      <p>正在加载中...</p>
    </template>

    <template v-slot:default="slotProps">
      <ul>
        <li v-for="item in slotProps.content" :key="item.id">
          {{ item.name }} - 库存: {{ item.stock }}
        </li>
      </ul>
    </template>

    <template v-slot:error="slotProps">
      <p style="color: red;">
        出错了: {{ slotProps.error.message }}
      </p>
    </template>

  </AsyncComponent>
</template>

3. v-slot 的解构语法

为了代码更简洁,可以直接在 v-slot 中使用 ES6 的解构语法来获取数据。

html
<template>
  <AsyncComponent :contentPromise="fetchProducts()">

    <template v-slot:default="{ content }">
      <ul>
        <li v-for="item in content" :key="item.id">
          {{ item.name }} - 库存: {{ item.stock }}
        </li>
      </ul>
    </template>

    <template v-slot:error="{ error }">
      <p style="color: red;">
        出错了: {{ error.message }}
      </p>
    </template>

  </AsyncComponent>
</template>

4. 作用域插槽原理

作用域插槽的本质是 "将插槽内容编译成一个函数,并传递给子组件"。

  1. 父组件:将 <template v-slot:default="{ content }">...</template> 这部分内容编译成一个函数,类似:
    javascript
    // 伪代码
    function(scope) {
      const { content } = scope;
      // 返回 VNode 节点
      return [ ... ];
    }
  2. 子组件:在渲染时,子组件调用这个从父组件接收到的函数,并把自己的数据(如 { content: [...] })作为参数传入。
  3. 结果:函数执行后返回渲染所需的 VNode,从而实现了数据的传递和UI的动态构建。

四、 内部属性 $slots$scopedSlots

在组件实例内部,可以通过 this.$slotsthis.$scopedSlots 访问插槽信息,这在开发高级组件时非常有用。

  • this.$slots
    • 类型:{ [name: string]: ?Array<VNode> }
    • 包含所有非作用域插槽的 VNode 数组。
  • this.$scopedSlots (Vue 2.6+)
    • 类型:{ [name: string]: (props: any) => Array<VNode> | undefined }
    • 包含所有插槽,每个插槽都是一个返回 VNode 数组的函数。
    • 普通插槽也被包装成一个不接收参数的函数。
    • 这是理解作用域插槽工作原理的关键。
javascript
// 在 AsyncComponent.vue 的 created 钩子中打印
export default {
  // ...
  created() {
    console.log('普通插槽 VNodes:', this.$slots);
    // { loading: [VNode], ... }

    console.log('所有插槽的渲染函数:', this.$scopedSlots);
    // {
    //   loading: () => [VNode],
    //   default: (props) => [VNode],
    //   error: (props) => [VNode]
    // }
  }
}