移动端信息流页面几乎都长一个样:顶部一排标签,下面跟着可以左右滑动的内容区。Vant Tab 标签页与 Swipe 轮播看起来都能做「切换」,但职责完全不同——van-tabs 管的是标签导航与内容容器,van-swipe 管的是手势滚动的视图容器。本文基于 Vant 4,把两者的 v-model:active 与 swipeTo() 打通,做出点击标签与手指滑动双向同步的页面,并把吸顶、懒加载和官方明确警告的那几个坑讲清楚。
先分清三种「切换」
新手最容易把三个属性混成一团:swipeable、animated 和独立的 Swipe 组件。它们分别解决不同问题,选错会直接导致后面吸顶布局崩掉。
| 方案 | 作用范围 | 状态载体 | 适用场景 |
|---|---|---|---|
van-tabs swipeable | Tabs 自带的子内容区 | v-model:active | 每个 Tab 内是普通列表,想顺手支持手势 |
van-tabs animated | 同上,只加横向位移动画 | v-model:active | 切换时要有滑动过渡 |
van-swipe + van-tabs | 独立的轮播容器,Tabs 只当导航条 | swipeTo() + change | 内容区本身是图片轮播、卡片流,或需要自定义手势逻辑 |
swipeable 或 animated 后,内容区会被包进带 transform 的元素;此时内容区内部的 van-sticky 虽然仍生效,但 fixed 会相对这个 transform 元素计算,显示位置会错乱。需要吸顶时,吸顶的是 Tabs 自己(sticky),不要在 Tab 内容里再放 Sticky。基础:active 既可以存索引,也可以存名字
v-model:active 的类型是 number | string,默认值为 0。给 van-tab 设置 name 后,active 就该绑定同名字符串——用名字比用下标稳,调整标签顺序时不会把状态带偏。
<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>
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,这个闭环不会死循环。
<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 解析。
<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 是否激活。
<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>
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 模式下不能依赖这个回调。
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,性能和副作用都能收住。
状态只有一个来源,切换只有两条通路:点击改状态,滑动回写状态。
—— 写联动组件时值得贴在显示器上的一句话