在 Vue 3 的组合式 API(Composition API)时代,虽然官方更推荐使用 Composables(组合函数) 来复用逻辑,但理解 高阶组件(Higher-Order Component, HOC) 和 Renderless 组件(无渲染组件) 仍然具有重要价值。它们不仅是 React 生态中的经典模式,在 Vue 中也有其适用场景,尤其在需要封装复杂状态逻辑并以组件形式暴露时。
本文将深入讲解如何在 Vue 3 中实现这两种模式,并通过实际案例展示其用法、优势与注意事项。
一、概念澄清
1. 高阶组件(HOC)
接收一个组件作为参数,返回一个新组件的函数。
javascript
const withLoading = (WrappedComponent) => {
return {
setup(props, { slots }) {
// 添加 loading 逻辑
const loading = ref(true);
onMounted(() => {
setTimeout(() => loading.value = false, 1000);
});
return () => h(WrappedComponent, {
...props,
loading: loading.value
});
}
};
};
2. Renderless 组件(无渲染组件)
不包含任何 DOM 结构,只提供逻辑和数据,通过作用域插槽(scoped slot)将状态传递给子组件。
ini
<template>
<slot
:loading="loading"
:startLoading="startLoading"
/>
</template>
<script setup>
import { ref } from 'vue';
const loading = ref(false);
const startLoading = () => {
loading.value = true;
setTimeout(() => loading.value = false, 1000);
};
</script>
✅ 关键区别:HOC:包装现有组件,注入 props,Renderless:自身不渲染 UI,通过
<slot>暴露逻辑
二、实战:开发一个通用数据加载 HOC
场景
为任意组件添加自动数据加载能力,无需重复编写 loading、error、data 状态管理。
步骤 1:定义 HOC 函数
ini
// hoc/withAsyncData.js
import { defineComponent, ref, onMounted, h } from 'vue';
/**
* 高阶组件:为组件注入异步数据加载能力
* @param {Function} fetchFn - 数据获取函数 (返回 Promise)
* @param {Object} options - 配置项
* @returns {Component} 新组件
*/
export function withAsyncData(fetchFn, options = {}) {
const {
loadingProp = 'loading',
dataProp = 'data',
errorProp = 'error',
autoLoad = true
} = options;
return (WrappedComponent) => {
return defineComponent({
name: `WithAsyncData(${WrappedComponent.name || 'Anonymous'})`,
props: WrappedComponent.props ? { ...WrappedComponent.props } : {},
setup(props, { attrs, slots }) {
const loading = ref(false);
const data = ref(null);
const error = ref(null);
const loadData = async () => {
loading.value = true;
error.value = null;
try {
const result = await fetchFn();
data.value = result;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
};
if (autoLoad) {
onMounted(loadData);
}
// 将状态作为 props 注入 WrappedComponent
const injectedProps = {
[loadingProp]: loading.value,
[dataProp]: data.value,
[errorProp]: error.value,
// 提供重新加载方法
reload: loadData
};
return () => h(
WrappedComponent,
{
...props,
...attrs,
...injectedProps
},
slots
);
}
});
};
}
步骤 2:使用 HOC
xml
<!-- UserList.vue -->
<template>
<div>
<div v-if="loading">加载中...</div>
<div v-else-if="error">错误: {{ error.message }}</div>
<ul v-else>
<li v-for="user in data" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="reload">刷新</button>
</div>
</template>
<script>
import { defineComponent } from 'vue';
export default defineComponent({
name: 'UserList',
props: ['loading', 'data', 'error', 'reload'] // 接收 HOC 注入的 props
});
</script>
xml
<!-- App.vue -->
<template>
<UserListWithAsyncData />
</template>
<script>
import UserList from './UserList.vue';
import { withAsyncData } from './hoc/withAsyncData';
// 创建增强后的组件
const UserListWithAsyncData = withAsyncData(
() => fetch('/api/users').then(res => res.json()),
{ autoLoad: true }
)(UserList);
export default {
components: {
UserListWithAsyncData
}
};
</script>
✅ 优势: 逻辑复用:任何列表组件都可快速获得加载能力; 类型安全:通过 props 明确接口; 可配置:支持自定义 prop 名称
三、实战:开发 Renderless 组件
场景
创建一个通用的计数器逻辑组件,不关心 UI 如何展示。
步骤 1:创建 Renderless 组件
xml
<!-- renderless/CounterProvider.vue -->
<template>
<!-- 无任何 DOM,只暴露逻辑 -->
<slot
:count="count"
:increment="increment"
:decrement="decrement"
:reset="reset"
:isEven="isEven"
/>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
initialCount: {
type: Number,
default: 0
},
min: Number,
max: Number
});
const count = ref(props.initialCount);
const increment = () => {
if (props.max === undefined || count.value < props.max) {
count.value++;
}
};
const decrement = () => {
if (props.min === undefined || count.value > props.min) {
count.value--;
}
};
const reset = () => {
count.value = props.initialCount;
};
const isEven = computed(() => count.value % 2 === 0);
</script>
步骤 2:使用 Renderless 组件
xml
<!-- App.vue -->
<template>
<div>
<!-- 方式1:基础用法 -->
<CounterProvider v-slot="{ count, increment, decrement }">
<p>当前计数: {{ count }}</p>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
</CounterProvider>
<!-- 方式2:高级用法(带限制) -->
<CounterProvider
:initial-count="10"
:min="0"
:max="20"
v-slot="{ count, increment, decrement, isEven }"
>
<div :class="{ even: isEven }">
<h3>受限计数器 (0~20)</h3>
<p>{{ count }} {{ isEven ? '(偶数)' : '(奇数)' }}</p>
<button @click="increment" :disabled="count >= 20">+1</button>
<button @click="decrement" :disabled="count <= 0">-1</button>
</div>
</CounterProvider>
</div>
</template>
<script setup>
import CounterProvider from './renderless/CounterProvider.vue';
</script>
<style scoped>
.even { color: green; }
</style>
✅ 优势: 完全解耦逻辑与 UI; 灵活组合:同一个逻辑可适配多种 UI; 类型推导:IDE 可自动提示 slot 属性;
四、HOC vs Renderless vs Composables 对比
| 特性 | HOC | Renderless 组件 | Composables |
|---|---|---|---|
| 复用方式 | 包装组件 | 作用域插槽 | 函数调用 |
| 模板侵入性 | 低(使用者无感知) | 中(需写 ) | 无 |
| 逻辑复杂度 | 适合简单 props 注入 | 适合状态+方法暴露 | 最灵活 |
| TypeScript 支持 | 需手动处理类型 | 自动推导 slot 类型 | 最佳 |
| Vue 3 推荐度 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
📌 建议:
Vue 3 中优先使用 Composables,仅在以下情况考虑 HOC/Renderless: 需要以组件形式分发(如 UI 库); 与第三方组件集成(无法修改其内部逻辑); 团队习惯类 React 的开发模式;
五、Composables 替代方案(推荐)
上述功能用 Composables 实现更简洁:
ini
// composables/useCounter.js
import { ref, computed, watch } from 'vue';
export function useCounter(initialValue = 0, { min, max } = {}) {
const count = ref(initialValue);
const increment = () => {
if (max === undefined || count.value < max) count.value++;
};
const decrement = () => {
if (min === undefined || count.value > min) count.value--;
};
const reset = () => count.value = initialValue;
const isEven = computed(() => count.value % 2 === 0);
// 监听 initialValue 变化
watch(() => initialValue, (newVal) => {
count.value = newVal;
});
return {
count,
increment,
decrement,
reset,
isEven
};
}
xml
<!-- 使用 Composables -->
<script setup>
import { useCounter } from './composables/useCounter';
const { count, increment, decrement } = useCounter(0, { min: 0, max: 10 });
</script>
<template>
<p>{{ count }}</p>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
</template>
六、最佳实践与注意事项
1. HOC 注意事项
- 透传 Props/Attrs/Slots:确保包装组件的行为与原组件一致
- 命名规范:使用
WithXxx前缀(如WithLoading) - 避免嵌套过深:HOC 嵌套会导致调试困难
2. Renderless 组件注意事项
- 明确 Slot 接口:使用 TypeScript 定义 slot props 类型
- 避免过度设计:简单逻辑直接用 Composables
- 文档说明:清晰标注暴露的 slot 属性
3. 性能优化
- 缓存计算属性:使用
computed而非方法 - 按需响应:只暴露必要的状态
- 清理副作用:在
onUnmounted中清理定时器等
结语
虽然 Vue 3 的 Composition API 使得 Composables 成为逻辑复用的首选,但理解 HOC 和 Renderless 组件仍有其价值:
- HOC 适合对现有组件进行"装饰",尤其在无法修改组件源码时
- Renderless 组件 在构建 UI 库时非常有用,允许用户完全控制渲染