当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球
摘要:本文结合 Vue3
$attrs特性与桥接模式,详细解析如何优雅解耦虚拟滚动容器与复杂业务组件。通过拆分抽象与实现,实现属性事件无缝透传,告别臃肿代码。
在企业级前端开发中,长列表渲染是一个永远绕不开的性能瓶颈。在 Vue 技术栈中,我们经常会使用 vue3-virtual-scroll-list 这样的第三方库来实现虚拟滚动,从而保证页面在面对万条甚至十万条数据时依然如丝般顺滑。
但是,随着业务复杂度的提升,一个棘手的设计问题往往会浮出水面:如何在使用第三方虚拟滚动库时,优雅地实现基础组件与业务组件的解耦与隔离?
今天,我们就来详细拆解这个场景,并探讨在 Vue 3 下利用 $attrs 透传机制实现完美隔离的设计思路。
一、 场景痛点与需求分析
想象一下这样一个典型的开发场景:
你正在负责一个大型后台管理系统。项目中有多处需要用到虚拟滚动列表:有的是简单的文本日志列表,有的是复杂的商品卡片列表,还有的是带有各种交互按钮(点赞、删除、编辑)的用户评论列表。
为了复用代码,你决定封装一个基础虚拟滚动组件(VirtualScrollerBasic),它负责引入第三方库,设定预估高度。同时,你还需要一个基础列表项组件(ItemBasic),它负责最基本的数据渲染和样式布局。
但是,业务部门的需求是千变万化的:
- 场景 A 的商品列表需要传入一个特殊的业务参数
customText来显示促销信息。 - 场景 B 的评论列表需要在点击时触发一个专属的业务事件
@customEvent。 - 场景 C 的日志列表需要在每一项的底部插入一段自定义的 DOM 结构(使用插槽)。
如果直接在基础组件里把这些业务参数和事件全部写死,基础组件就会变得无比臃肿,甚至最终沦为一个不可维护的“大泥球”。
我们的核心诉求是:基础列表和基础 Item 只关心自己该关心的事情(比如基础的布局、基础的数据 source),而业务列表和业务 Item 可以自由地增加属性、监听事件、甚至传递插槽,且这一切对基础组件来说必须是“无感”的。
二、 方案设计思路:桥接模式与职责分离
为了解决上述痛点,我们需要引入**桥接模式(Bridge Pattern)**的思想。
桥接模式的核心是“将抽象部分与实现部分分离,使它们都可以独立地变化”。在虚拟滚动的场景中:
- 抽象部分(Abstraction):是列表的容器(如
VirtualScrollerBasic),负责虚拟滚动的核心机制、数据调度和预估高度计算。 - 实现部分(Implementor):是具体的列表项渲染器接口,负责单条数据的 UI 展示和交互。
这两部分通过一个“桥梁”(即动态传入的 listComponent 属性)连接起来。在此基础上,业务组件只需要处理自己的业务逻辑,剩下的不属于自己范围的基础属性和事件,通过 Vue 3 的 $attrs(在组合式 API 中通过 useAttrs() 获取)完美透传给基础组件。
Vue 3 的 $attrs 有一个非常棒的特性:它不仅包含了外部传入的非 Props 属性,还包含了绑定的事件(自动转化为 onXxx 形式)。这为我们实现属性和事件的跨层透传提供了天然的便利。
设计架构图
classDiagram
class VirtualScrollerBasic {
+items: Array
+listComponent: Component
+basicText: String
+render()
}
class VirtualScrollerList {
+customText: String
+handleCustomEvent()
+render()
}
class ItemBasic {
+index: Number
+basicText: String
+render()
}
class Item {
+customText: String
+handleEvent()
+render()
}
VirtualScrollerBasic o-- ItemBasic : Bridge (通过 listComponent 桥接)
VirtualScrollerBasic <|-- VirtualScrollerList : 扩展 (组合包裹)
ItemBasic <|-- Item : 扩展 (组合包裹)
三、 Vue 3 下的代码实现
让我们来看看这套设计模式在 Vue 3 中是如何落地的。
1. 基础列表组件 (VirtualScrollerBasic.vue)
基础列表组件的职责是封装第三方库 vue3-virtual-scroll-list,并且负责将外部传入的 $attrs 整合后向下透传。
<template>
<div class="virtual-scroller-container">
<virtual-list
class="virtual-list"
:data-key="'id'"
:data-sources="items"
:data-component="listComponent"
:estimate-size="50"
:extra-props="{
// 关键点1:合并透传所有外部传入的业务属性和业务事件(onXxx)
...attrs,
// 关键点2:基础层私有的参数和事件,互不干扰
basicText: '这是基础层参数',
onBasicEvent: handleBasicEvent,
}"
>
<template #header>
<slot name="header"></slot>
</template>
</virtual-list>
</div>
</template>
<script setup>
import { ref, useAttrs } from "vue";
import VirtualList from "vue3-virtual-scroll-list";
import ItemBasic from "./ItemBasic.vue";
const attrs = useAttrs();
const props = defineProps({
listComponent: {
type: Object,
default: () => ItemBasic,
},
});
// 关键点3:阻止属性直接绑定到根节点 div 上,防止 DOM 污染和事件重复触发
defineOptions({
inheritAttrs: false,
});
const items = ref([
/* 模拟数据 */
]);
const handleBasicEvent = (source) => {
console.log("基础事件触发");
};
</script>
2. 基础 Item 组件 (ItemBasic.vue)
基础 Item 组件负责渲染列表项的最基本信息。它只关心基础的 UI 和数据结构,不知道任何关于业务层的特殊参数。
<template>
<div class="basic-item">
<div class="basic-content">
<span>#{{ index }} - ID: {{ source.id }}</span>
<span v-if="basicText" class="basic-text">({{ basicText }})</span>
<button @click="handleClick">触发基础事件</button>
</div>
<!-- 留出插槽供业务层扩展 -->
<slot name="footer"></slot>
</div>
</template>
<script setup>
const props = defineProps({
source: {
type: Object,
required: true,
},
index: {
type: Number,
default: 0,
},
basicText: {
type: String,
default: "",
},
});
const emit = defineEmits(["basicEvent"]);
const handleClick = () => {
emit("basicEvent", props.source);
};
</script>
<style scoped>
.basic-item {
padding: 10px;
border-bottom: 1px solid #eee;
}
.basic-text {
color: #888;
margin-left: 10px;
}
</style>
3. 业务 Item 组件 (Item.vue)
业务 Item 组件的职责是拦截并消费属于业务层的属性(customText)和事件(customEvent),并将剩下的属性通过 v-bind 透传给基础 Item 组件。
<template>
<div class="custom-item">
<!-- 关键点1:v-bind="attrs" 将没被当前组件消费的属性和事件透传给基础组件 -->
<item-basic v-bind="attrs" :source="source">
<template #footer>
<div class="custom-footer">
自定义footer <span v-if="customText"> - {{ customText }}</span>
<el-button type="primary" @click="handleEvent(source)">
触发业务事件 customEvent
</el-button>
</div>
</template>
</item-basic>
</div>
</template>
<script setup>
import { useAttrs } from "vue";
import ItemBasic from "./ItemBasic.vue";
const attrs = useAttrs();
// 关键点2:只声明业务层自己需要消费的属性。
// 如果业务层确实需要用到基础层的参数,就需要手动传给基础组件
const props = defineProps({
source: { type: Object, required: true }, // 点击事件需使用,所以保留
customText: { type: String, default: "" }, // 业务专属属性
});
const emit = defineEmits(["customEvent"]);
// 关键点3:同样需要阻止属性绑定到根节点
defineOptions({
inheritAttrs: false,
});
const handleEvent = (source) => {
// 触发业务层专属事件
emit("customEvent", source);
};
</script>
4. 业务列表组件 (VirtualScrollerList.vue)
在最外层的业务列表中,我们就可以像使用普通组件一样,随心所欲地传递业务参数和监听业务事件了,底层的一切复杂透传对它来说都是透明的。
<template>
<div class="virtual-scroller-list-wrapper">
<virtual-scroller-basic
:list-component="Item"
:customText="'这是通过透传传入的业务参数 customText'"
@customEvent="handleCustomEvent"
>
<template #header>
<div class="custom-header">自定义业务 Header 内容</div>
</template>
</virtual-scroller-basic>
</div>
</template>
<script setup>
import VirtualScrollerBasic from "./VirtualScrollerBasic.vue";
import Item from "./Item.vue";
const handleCustomEvent = (source) => {
alert(`业务层成功拦截 customEvent,Item ID: ${source.id}`);
};
</script>
四、 Vue 3 与 Vue 2 的实现区别解析
如果你还在使用 Vue 2,或者刚从 Vue 2 迁移过来,可能会对上面的实现感到一些疑惑。这里有必要重点强调一下 Vue 3 和 Vue 2 在透传机制上的巨大差异。
Vue 2:属性与事件是分离的
在 Vue 2 中,组件的“属性”和“事件”是严格区分开的:
- 传递的数据和非 Props 属性会被收集到
$attrs中。 - 通过
@或v-on绑定的事件会被收集到$listeners中。
所以在 Vue 2 中,如果你想把业务组件绑定的 @customEvent 透传给底层的 vue-virtual-scroll-list 的 extra-props,你必须手动去遍历 $listeners,把它们转换成 onXxx 格式的函数,然后再和 $attrs 合并:
// Vue 2 下的 Hack 写法
computed: {
mergedExtraProps() {
const listenersAsProps = {};
for (const eventName in this.$listeners) {
const propName = 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
listenersAsProps[propName] = this.$listeners[eventName];
}
return { ...this.$attrs, ...listenersAsProps };
}
}
不仅如此,在 Vue 2 中,由于底层组件接收到的 extra-props 只能以 Props 的形式被子组件接收,为了让子组件能像普通组件一样响应 @事件,我们往往还需要引入一个中间包装组件(Wrapper),利用函数式组件将 onXxx 的 Props 重新还原为真正的 $listeners 并绑定到实际渲染的组件上。
例如,我们需要定义一个 VirtualScrollerItemWrapper.vue:
<script>
// Vue 2 函数式组件 Wrapper
export default {
name: "VirtualScrollerItemWrapper",
functional: true,
render(h, context) {
const { props, data } = context;
const originalComponent = props.originalComponent; // 真实的业务组件
const attrs = {};
const on = {};
// 遍历 props,将 onXxx 还原为事件监听器
for (const key in props) {
if (key === "originalComponent") continue;
if (key.startsWith("on") && typeof props[key] === "function") {
const eventName = key.charAt(2).toLowerCase() + key.slice(3);
on[eventName] = props[key];
} else {
attrs[key] = props[key];
}
}
return h(originalComponent, {
attrs,
on, // 重新绑定事件
scopedSlots: data.scopedSlots,
});
},
};
</script>
然后在基础滚动组件中,我们不能直接渲染业务组件,而是必须把这个 Wrapper 传给 vue-virtual-scroll-list 的 data-component 属性,并将实际的业务组件通过 extra-props 传进去:
<template>
<virtual-list
:data-key="'id'"
:data-sources="items"
:data-component="VirtualScrollerItemWrapper" <!-- 使用包装组件 -->
:estimate-size="50"
:extra-props="{
...mergedExtraProps,
originalComponent: listComponent // 将真实的渲染组件传给包装器
}"
/>
</template>
可以看到,在 Vue 2 中为了实现这一套隔离与透传机制,代码非常冗长且绕脑。
Vue 3:大一统的 $attrs
Vue 3 进行了一次非常优雅的底层重构。它移除了 $listeners 对象,将所有通过 @event 绑定的事件,在编译时自动转换成了以 onXxx 开头的属性名(例如 @custom-event 变成了 onCustomEvent),并且统一收集到了 $attrs 中。
正因为 Vue 3 的这个特性,我们在 VirtualScrollerBasic 中只需要写一句 ...attrs,就同时完成了属性和事件的透传!这与 vue3-virtual-scroll-list 要求的 extra-props 接收对象的 API 设计简直是天作之合。
五、 运行效果与总结
当代码运行起来后,你会看到:
- 列表顶部正确渲染了“自定义业务 Header 内容”。
- 每一项都正确渲染了基础数据(如
#0)和基础参数(如基础参数)。 - 每一项的 Footer 都正确渲染了业务参数
这是通过透传传入的业务参数 customText。
- 点击“触发业务事件 customEvent”按钮,外层的业务列表组件成功弹出了 Alert 提示框,拦截到了事件。
总结:用到的设计模式
通过这次重构,我们实际上是在前端工程中落地了以下几种经典的设计模式:
- 桥接模式 (Bridge Pattern):这是本文的核心架构。将“虚拟滚动容器(抽象层)”与“列表项渲染(实现层)”彻底解耦。通过
listComponent这一桥梁连接,使得业务列表可以随意更换基础滚动机制,业务项也可以在不同的列表中复用,两者独立变化。 - 装饰器模式 (Decorator Pattern) / 高阶组件模式 (HOC):业务
Item没有去修改基础ItemBasic的内部代码,而是通过包裹对其进行了增强(增加了业务插槽和事件),并通过$attrs将基础属性完美透传。 - 模板方法模式 (Template Method Pattern):
VirtualScrollerBasic定义了虚拟滚动的算法骨架(如何引入库、设定高度等),具体的 UI 表现通过插槽和动态组件延迟到了业务层去实现。
优雅的架构设计,往往不需要多么高深的语法,而是对框架特性(如 $attrs)的深刻理解,以及对“单一职责”原则的坚守。希望这篇文章能为你在处理复杂 Vue 组件封装时带来一些启发!