Vue 插槽与作用域插槽深度解析
一、 问题背景:封装异步请求逻辑
在组件开发中,经常会遇到需要处理异步数据请求的场景。一个典型的异步流程包含三种状态:
- 加载中 (Loading):数据正在请求,尚未返回。
- 成功 (Success):数据成功获取,需要渲染到页面。
- 失败 (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. 作用域插槽原理
作用域插槽的本质是 "将插槽内容编译成一个函数,并传递给子组件"。
- 父组件:将
<template v-slot:default="{ content }">...</template>这部分内容编译成一个函数,类似:javascript// 伪代码 function(scope) { const { content } = scope; // 返回 VNode 节点 return [ ... ]; } - 子组件:在渲染时,子组件调用这个从父组件接收到的函数,并把自己的数据(如
{ content: [...] })作为参数传入。 - 结果:函数执行后返回渲染所需的 VNode,从而实现了数据的传递和UI的动态构建。
四、 内部属性 $slots 和 $scopedSlots
在组件实例内部,可以通过 this.$slots 和 this.$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]
// }
}
}