手摸手实现高性能Tab切换组件的实战技巧

芳妤 Dev 前端 阅读 1,845
赞 19 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接手了一个后台管理系统的重构任务,其中有个页面是用户数据的多维度展示,需要在同一个视图里切换不同的统计维度:比如按日、按周、按月看数据。本来以为就是个简单的 Tab 切换,直接用现成 UI 框架的组件拖进来完事,结果后面踩了一堆坑。

手摸手实现高性能Tab切换组件的实战技巧

我们项目用的是 Vue 3 + TypeScript,UI 方面一开始打算用 Element Plus 的 Tabs 组件。但问题来了——这个页面的数据加载特别重,每个 Tab 都要请求不同的 API,而且带图表渲染。如果默认所有 Tab 的内容都挂载了,哪怕没点进去,也会触发接口请求,页面一打开就疯狂发六个请求,用户体验直接拉垮。

所以最后决定:不用现成组件了,自己手撸一个轻量级的 Tab 控件,完全控制渲染时机和生命周期。

核心代码就这几行

其实 Tab 切换逻辑本身不复杂,关键是“懒加载”得做对。我最后用了一个非常朴素的方式:通过当前激活的 tabKey 来判断是否渲染对应的内容区域。

<template>
  <div class="tab-container">
    <!-- Tab 标签栏 -->
    <div class="tab-header">
      <button
        v-for="tab in tabs"
        :key="tab.key"
        :class="['tab-item', { active: activeTab === tab.key }]"
        @click="handleTabClick(tab.key)"
      >
        {{ tab.label }}
      </button>
    </div>

    <!-- 内容区(关键:v-if 控制渲染) -->
    <div class="tab-content">
      <div v-if="activeTab === 'daily'" class="tab-pane">
        <DailyReport />
      </div>
      <div v-else-if="activeTab === 'weekly'" class="tab-pane">
        <WeeklyReport />
      </div>
      <div v-else-if="activeTab === 'monthly'" class="tab-pane">
        <MonthlyReport />
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import DailyReport from './components/DailyReport.vue';
import WeeklyReport from './components/WeeklyReport.vue';
import MonthlyReport from './components/MonthlyReport.vue';

const tabs = [
  { key: 'daily', label: '按日统计' },
  { key: 'weekly', label: '按周统计' },
  { key: 'monthly', label: '按月统计' },
];

const activeTab = ref('daily');

const handleTabClick = (key: string) => {
  activeTab.value = key;
};
</script>
<style scoped>
.tab-container {
  width: 100%;
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
}

.tab-header {
  display: flex;
  background: #f5f5f5;
  border-bottom: 1px solid #ddd;
}

.tab-item {
  padding: 12px 24px;
  cursor: pointer;
  border: none;
  background: none;
  font-size: 14px;
}

.tab-item.active {
  font-weight: 600;
  color: #1890ff;
  position: relative;
}

.tab-item.active::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 60%;
  height: 3px;
  background: #1890ff;
}

.tab-content {
  padding: 20px;
  min-height: 400px;
}
</style>

看起来很简单对吧?但真正的麻烦是从这里开始的。

最大的坑:性能问题

你以为用了 v-if 就万事大吉了?错。虽然组件确实没一开始就渲染,但每次切换 Tab 的时候,之前加载过的数据全丢了。比如我点了“按周”,等了几秒加载完图表,再切到“按月”,然后再切回来——又得重新请求接口、重新渲染。

用户反馈说:“我刚看的那个图怎么又在转圈?” 我心想这体验也太差了。

后来想到可以用 <KeepAlive> 缓存组件实例。改了一下:

<KeepAlive>
  <div v-if="activeTab === 'daily'" class="tab-pane">
    <DailyReport />
  </div>
  <div v-else-if="activeTab === 'weekly'" class="tab-pane">
    <WeeklyReport />
  </div>
  <div v-else-if="activeTab === 'monthly'" class="tab-pane">
    <MonthlyReport />
  </div>
</KeepAlive>

这么一改,确实不重复加载了。但新问题来了:内存占用上去了。特别是某些报表页还用了 ECharts,缓存多了之后滚动都卡。而且我发现 activated 钩子会频繁触发,有些定时器没清理干净还会叠加执行,导致内存泄漏风险。

折腾了半天发现,不能无脑缓存所有 Tab,得做个策略:只缓存最近两次访问的 Tab,其他的自动卸载。但 Vue 的 include 是字符串或正则,动态控制很麻烦。最终妥协方案是:手动维护一个缓存 map,配合 key 强制刷新组件。

又踩坑了:keep-alive 和 key 的博弈

后来改成了这样:每个 Tab 内容组件加一个唯一的 key,基于 tabKey + 时间戳(首次加载时打标),然后在外层不用 KeepAlive,而是靠组件内部的状态保持。

更实际的做法是在子组件里做数据缓存。比如在 DailyReport.vue 里:

<script setup lang="ts">
import { ref, onMounted } from 'vue';

const data = ref<any>(null);
const isLoading = ref(false);

// 模拟缓存
const cache = new Map<string, any>();

const fetchData = async () => {
  if (cache.has('daily')) {
    data.value = cache.get('daily');
    return;
  }

  isLoading.value = true;
  try {
    const res = await fetch('https://jztheme.com/api/report/daily');
    const result = await res.json();
    data.value = result;
    cache.set('daily', result);
  } catch (err) {
    console.error(err);
  } finally {
    isLoading.value = false;
  }
};

onMounted(() => {
  fetchData();
});
</script>

这样即使父组件销毁重建,下次进来也能从缓存读。虽然不是完美的解决方案,但平衡了内存和体验。

谁更灵活?谁更省事?

回过头看,如果当初坚持用 Element Plus 的 Tabs,它自带 lazy 属性,可以实现懒加载,但依然无法解决缓存粒度的问题。而且一旦定制样式或者交互逻辑复杂起来,反而更难改。

手写的好处是完全可控。比如现在我们可以在切换 Tab 前加个 loading 动画,甚至阻止未保存的操作被丢弃。这些扩展功能加起来才几十行代码,要是套在第三方组件上,可能得 fork 一份源码来改。

当然缺点也很明显:基础功能都得自己兜底,比如键盘导航、无障碍支持这些,项目紧就没做,算是技术债吧。

回顾与反思

这个 Tab 看似简单,实际上牵扯到状态管理、性能优化、用户体验多个层面。最开始没想到一个切换标签能搞这么久,前后改了三四版才稳定下来。

做得好的地方是:终于实现了按需加载 + 合理缓存的平衡,接口请求次数减少了 70% 以上,首屏速度提升明显。

还能优化的点:

  • 增加预加载机制:鼠标 hover 到某个 Tab 时提前触发数据请求
  • 统一缓存管理层,避免每个子组件各自为政
  • 支持 URL hash 同步,方便分享特定 Tab 页面

最后一个没解决的小问题是:当网络异常时切换 Tab,错误状态会残留。本想用 provide/inject 传一个全局 reload 方法,但觉得太重就没搞。目前是让用户手动刷新页面,影响不大,先放着了。

以上是我的项目经验,希望对你有帮助

这个技巧的拓展用法还有很多,比如结合路由实现多页签标签页、动态增减 Tab 之类的,后续可能会继续分享。

以上是我个人对这个 Tab 切换实现的完整讲解,有更优的实现方式欢迎评论区交流。毕竟前端这东西,没有银弹,只有权衡。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论