当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球

0 阅读5分钟

当 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-listextra-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-listdata-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 设计简直是天作之合。

五、 运行效果与总结

当代码运行起来后,你会看到:

  1. 列表顶部正确渲染了“自定义业务 Header 内容”。
  2. 每一项都正确渲染了基础数据(如 #0)和基础参数(如 基础参数)。
  3. 每一项的 Footer 都正确渲染了业务参数 这是通过透传传入的业务参数 customText

image.png

  1. 点击“触发业务事件 customEvent”按钮,外层的业务列表组件成功弹出了 Alert 提示框,拦截到了事件。

image-1.png

image-2.png

总结:用到的设计模式

通过这次重构,我们实际上是在前端工程中落地了以下几种经典的设计模式:

  1. 桥接模式 (Bridge Pattern):这是本文的核心架构。将“虚拟滚动容器(抽象层)”与“列表项渲染(实现层)”彻底解耦。通过 listComponent 这一桥梁连接,使得业务列表可以随意更换基础滚动机制,业务项也可以在不同的列表中复用,两者独立变化。
  2. 装饰器模式 (Decorator Pattern) / 高阶组件模式 (HOC):业务 Item 没有去修改基础 ItemBasic 的内部代码,而是通过包裹对其进行了增强(增加了业务插槽和事件),并通过 $attrs 将基础属性完美透传。
  3. 模板方法模式 (Template Method Pattern)VirtualScrollerBasic 定义了虚拟滚动的算法骨架(如何引入库、设定高度等),具体的 UI 表现通过插槽和动态组件延迟到了业务层去实现。

优雅的架构设计,往往不需要多么高深的语法,而是对框架特性(如 $attrs)的深刻理解,以及对“单一职责”原则的坚守。希望这篇文章能为你在处理复杂 Vue 组件封装时带来一些启发!