移动端信息流页面几乎都长一个样:顶部一排标签,下面跟着可以左右滑动的内容区。Vant Tab 标签页与 Swipe 轮播看起来都能做「切换」,但职责完全不同——van-tabs 管的是标签导航与内容容器,van-swipe 管的是手势滚动的视图容器。本文基于 Vant 4,把两者的 v-model:active 与 swipeTo() 打通,做出点击标签与手指滑动双向同步的页面,并把吸顶、懒加载和官方明确警告的那几个坑讲清楚。

先分清三种「切换」

新手最容易把三个属性混成一团:swipeable、animated 和独立的 Swipe 组件。它们分别解决不同问题,选错会直接导致后面吸顶布局崩掉。

方案作用范围状态载体适用场景
van-tabs swipeableTabs 自带的子内容区v-model:active每个 Tab 内是普通列表,想顺手支持手势
van-tabs animated同上,只加横向位移动画v-model:active切换时要有滑动过渡
van-swipe + van-tabs独立的轮播容器,Tabs 只当导航条swipeTo() + change内容区本身是图片轮播、卡片流,或需要自定义手势逻辑
⚠️
官方 FAQ 的硬性限制
开启 swipeable 或 animated 后,内容区会被包进带 transform 的元素;此时内容区内部的 van-sticky 虽然仍生效,但 fixed 会相对这个 transform 元素计算,显示位置会错乱。需要吸顶时,吸顶的是 Tabs 自己(sticky),不要在 Tab 内容里再放 Sticky。

基础:active 既可以存索引,也可以存名字

v-model:active 的类型是 number | string,默认值为 0。给 van-tab 设置 name 后,active 就该绑定同名字符串——用名字比用下标稳,调整标签顺序时不会把状态带偏。

html 基础标签页
<van-tabs v-model:active="activeName" @change="onChange">
  <van-tab title="推荐" name="recommend" :badge="3">推荐内容</van-tab>
  <van-tab title="关注" name="follow">关注内容</van-tab>
  <van-tab title="热榜" name="hot" dot>热榜内容</van-tab>
</van-tabs>
js setup 部分
import { ref } from 'vue';

const activeName = ref('recommend');
// change 的参数是 (name, title)
const onChange = (name, title) => console.log(name, title);

其它几个常用属性:type="card" 切换卡片风格,shrink 让标签左对齐不撑满,swipe-threshold(默认 5)控制超过几个标签才开始横向滚动,color / line-width / title-active-color 负责视觉。

实战:Tabs 与 Swipe 双向联动

思路很简单,两条单向数据流拼成闭环:点击标签时由 watch(active) 调用 Swipe 的 swipeTo(index);手指滑动时由 Swipe 的 change 事件把索引写回 active。因为 Vue 的 ref 赋相同值不会再次触发 watch,这个闭环不会死循环。

双向联动数据流
点击 Tab → v-model:active 变化
↓
watch(active) → swipeRef.swipeTo(index)
↓
Swipe change 事件 → 写回 active(同值不触发)
vue FeedTabs.vue
<script setup>
import { ref, watch } from 'vue';
import type { SwipeInstance } from 'vant';

const tabs = [
  { title: '推荐', name: 'recommend' },
  { title: '关注', name: 'follow' },
  { title: '热榜', name: 'hot' },
];

const activeName = ref('recommend');
const swipeRef = ref<SwipeInstance>();
const indexOf = (name: string) => tabs.findIndex((t) => t.name === name);

// 点击标签 / 代码改 active → 驱动 Swipe
watch(activeName, (name) => {
  swipeRef.value?.swipeTo(indexOf(name));
});

// 手势滑动 → 驱动 active
const onSwipeChange = (index: number) => {
  activeName.value = tabs[index].name;
};
</script>

<template>
  <van-tabs v-model:active="activeName" sticky offset-top="46px" shrink>
    <van-tab v-for="t in tabs" :key="t.name" :title="t.title" :name="t.name" />
  </van-tabs>

  <van-swipe
    ref="swipeRef"
    :autoplay="0"
    :loop="false"
    :show-indicators="false"
    :stop-propagation="false"
    @change="onSwipeChange"
  >
    <van-swipe-item v-for="t in tabs" :key="t.name">
      <div class="feed-panel">
        <h3>{{ t.title }}</h3>
        <p>这里是 {{ t.name }} 频道的内容区域</p>
      </div>
    </van-swipe-item>
  </van-swipe>
</template>

<style scoped>
.feed-panel { min-height: 60vh; padding: 16px; }
</style>

几个必须注意的细节:

  • loop 要关掉。循环模式下 change 的索引会被克隆节点干扰,与标签下标对不上。
  • show-indicators 关掉,导航交给 Tabs,别让页面出现两套指示器。
  • swipeTo(index, { immediate: true }) 可以跳过动画,适合从 URL 参数恢复频道时避免闪一下。
  • 如果内容区有纵向滚动,Swipe 默认会处理 touchmove;遇到嵌套滚动冲突再考虑 stop-propagation。
💡 小技巧:把 activeName 同步到路由 query(如 ?tab=hot),刷新页面后先用 indexOf 算出索引,再给 Swipe 设 :initial-swipe="index",就能保持频道与视图一致。

吸顶:让 Tabs 自己粘住

页面顶部常有固定的 van-nav-bar(高度 46px),此时 Tabs 吸顶要留偏移,否则会被导航盖住。offset-top 支持 px / vw / vh / rem,默认按 px 解析。

html 带导航的吸顶布局
<van-nav-bar title="发现" fixed placeholder />

<van-tabs v-model:active="activeName" sticky offset-top="46px" @scroll="onScroll">
  <van-tab title="推荐" name="recommend">...</van-tab>
  <van-tab title="关注" name="follow">...</van-tab>
</van-tabs>

<!-- scroll 事件参数:{ scrollTop, isFixed } -->

如果做的是「左侧目录 + 右侧长内容」那种点击定位,不要用 Swipe 联动,直接上 scrollspy 模式:内容会被平铺展示,配合 tabsRef.scrollTo(name) 即可滚动到指定区块。

懒加载:别让 8 个频道同时发请求

lazy-render 默认是 true,即只有首次激活的 Tab 才渲染内容。要精确控制「首次进入该频道才拉接口」,用 rendered 事件最省事;子组件里则可以用官方 FAQ 提供的 useTabStatus 判断自己所在的 Tab 是否激活。

vue 按频道懒请求
<script setup>
import { ref } from 'vue';

const loaded = ref<Record<string, boolean>>({});
const activeName = ref('recommend');

// rendered 只在内容首次渲染时触发一次
const onRendered = (name: string) => {
  if (!loaded.value[name]) {
    loaded.value[name] = true;
    fetchFeed(name);
  }
};

const fetchFeed = (name: string) => {
  console.log('load feed:', name);
};
</script>

<template>
  <van-tabs v-model:active="activeName" @rendered="onRendered">
    <van-tab title="推荐" name="recommend"><FeedList name="recommend" /></van-tab>
    <van-tab title="关注" name="follow"><FeedList name="follow" /></van-tab>
  </van-tabs>
</template>
js 子组件内判断激活状态
import { watch } from 'vue';
import { useTabStatus } from 'vant';

// 返回 Ref<boolean | null>;不在 Tab 内时为 null
const isActive = useTabStatus();

watch(isActive, (active) => {
  if (active) startPolling();
  else stopPolling();
});
ℹ️
嵌套标签页
嵌套场景下 useTabStatus 只判断最近一层父 Tab;要确认所有祖先 Tab 都激活,用 useAllTabStatus。视频播放、轮播自动播放这类副作用尤其需要它,否则隐藏的 Tab 也会在后台跑。

切换前拦截与常见坑速查

表单未保存时想拦住切换,用 before-change:返回 false 或 resolve(false) 的 Promise 即阻止。但官方明确说明手势滑动不会触发它,所以 swipeable 模式下不能依赖这个回调。

js before-change 拦截
import { showConfirmDialog } from 'vant';

const beforeChange = async (name: string | number) => {
  if (!dirty.value) return true;
  try {
    await showConfirmDialog({ title: '提示', message: '内容未保存,确定离开?' });
    return true;
  } catch {
    return false;
  }
};
现象原因处理
Tab 里的 Sticky 吸顶位置错乱开了 swipeable/animated,transform 改变了 fixed 的包含块吸顶交给 van-tabs sticky,内容里不放 Sticky
滑动后标签高亮没跟上没监听 Swipe 的 change,或开了 loop回写 active,并关掉 loop
点击标签内容不动只改了 active,没调 swipeTo()watch(active) 中调用实例方法
标签太多但没横向滚动数量未超过 swipe-threshold(默认 5)或开了 shrink调小阈值,或去掉 shrink
所有频道首屏一起请求把请求写在了父组件的 onMounted用 rendered 事件分频道触发

总结

把导航与手势拆开,是这类页面最好维护的结构:Tabs 只负责 active 与吸顶,Swipe 只负责滚动与 change,中间用一个 watch 和一个事件回调连起来。再叠加 lazy-render、rendered 与 useTabStatus,性能和副作用都能收住。

状态只有一个来源,切换只有两条通路:点击改状态,滑动回写状态。
—— 写联动组件时值得贴在显示器上的一句话