第一次接触Vant UI是在去年做一个电商项目的时候,当时团队需要快速搭建一个移动端商品列表页面。面对市面上众多的UI框架,我们最终选择了Vant UI,原因很简单——它专为移动端优化,组件丰富且文档清晰。经过几个项目的实战验证,我发现Tab标签页、List列表和PullRefresh下拉刷新这三个组件的组合,几乎可以解决90%的移动端列表页面的需求。
Vant UI是基于Vue.js的移动端组件库,由有赞团队开源维护。它最大的特点是"轻量"和"高性能"——打包后的gzip体积只有60kb左右,却能提供40+个高质量的组件。对于中小型项目来说,这个体积完全可以接受,而且组件都经过移动端真机测试,滑动流畅度不输原生应用。
在实际开发中,我遇到过不少坑。比如早期版本的下拉刷新在iOS上会有卡顿,后来发现是缺少了-webkit-overflow-scrolling: touch这个CSS属性。再比如List组件在快速滑动时偶尔会出现白屏,需要合理设置防抖参数。这些问题在后续版本中都得到了优化,现在的Vant UI 4.x版本已经非常稳定。
Tab组件是移动端最常见的导航方式之一。在商品列表场景中,我们通常需要按分类展示不同商品,比如"热门推荐"、"新品上市"、"促销特价"等。Vant的Tab组件实现起来非常简单:
javascript复制<van-tabs v-model="activeTab" swipeable>
<van-tab v-for="tab in tabs" :key="tab.id" :title="tab.name">
<!-- 对应标签页的内容 -->
</van-tab>
</van-tabs>
这里有几个关键参数需要注意:
v-model绑定的是当前激活标签的索引值,从0开始计数swipeable属性启用手势滑动切换,大幅提升移动端体验我建议在data中这样定义tabs数据:
javascript复制data() {
return {
activeTab: 0,
tabs: [
{ id: 1, name: '热门推荐' },
{ id: 2, name: '新品上市' },
{ id: 3, name: '促销特价' }
]
}
}
在实际项目中,我们往往需要更复杂的Tab交互。比如结合keep-alive实现标签页缓存:
javascript复制<van-tabs v-model="activeTab">
<van-tab v-for="tab in tabs" :key="tab.id">
<template #title>
<van-icon :name="tab.icon" />{{tab.name}}
</template>
<keep-alive>
<component :is="tab.component" />
</keep-alive>
</van-tab>
</van-tabs>
对于内容较多的标签页,可以启用lazy-render属性实现懒渲染:
javascript复制<van-tabs v-model="activeTab" lazy-render>
<!-- tab内容 -->
</van-tabs>
性能优化小技巧:当标签页内容包含大量图片时,建议配合vant-lazyload插件使用,可以显著提升页面滚动性能。我在一个商品列表项目中实测,启用懒加载后FPS从30提升到了55+。
List组件是处理长列表的利器,它基于Intersection Observer API实现滚动监听,比传统的scroll事件性能更好。基本用法如下:
javascript复制<van-list
v-model="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell v-for="item in list" :key="item.id" :title="item.name" />
</van-list>
对应的业务逻辑:
javascript复制data() {
return {
list: [],
loading: false,
finished: false,
page: 1,
pageSize: 10
}
},
methods: {
async loadMore() {
this.loading = true;
const { data } = await api.getList({
page: this.page,
size: this.pageSize
});
this.list = [...this.list, ...data.items];
this.loading = false;
if (data.items.length < this.pageSize) {
this.finished = true;
} else {
this.page++;
}
}
}
在实际使用中,我遇到过几个典型问题:
javascript复制let loadingLock = false;
async loadMore() {
if (loadingLock) return;
loadingLock = true;
// ...加载逻辑
loadingLock = false;
}
javascript复制watch: {
activeTab() {
this.list = [];
this.page = 1;
this.finished = false;
this.loadMore();
}
}
javascript复制<van-list>
<template #empty>
<van-empty description="暂无数据" />
</template>
</van-list>
性能优化方面,对于超长列表(1000+项),建议使用虚拟滚动方案。虽然Vant List本身不提供虚拟滚动,但可以配合vue-virtual-scroller使用。
下拉刷新是现代移动应用的标配功能,Vant的PullRefresh组件使用起来非常简单:
javascript复制<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 这里放置List组件 -->
</van-pull-refresh>
业务逻辑实现:
javascript复制data() {
return {
refreshing: false
}
},
methods: {
async onRefresh() {
try {
const { data } = await api.getNewData();
this.list = data.items;
this.page = 1;
this.finished = false;
} finally {
this.refreshing = false;
}
}
}
PullRefresh组件提供了丰富的自定义选项:
javascript复制<van-pull-refresh
v-model="refreshing"
:head-height="80"
pulling-text="下拉即可刷新..."
loosing-text="释放立即刷新..."
loading-text="正在加载..."
success-text="刷新成功"
@refresh="onRefresh"
>
我在实际项目中发现几个提升用户体验的技巧:
javascript复制async onRefresh() {
// ...获取数据
this.$toast.success('已更新10条新内容');
}
javascript复制<template #pulling="props">
<img
class="doge"
src="https://img.yzcdn.cn/vant/doge.png"
:style="{ transform: `scale(${props.distance / 80})` }"
/>
</template>
javascript复制let lastRefreshTime = 0;
async onRefresh() {
const now = Date.now();
if (now - lastRefreshTime < 2000) {
this.refreshing = false;
return;
}
lastRefreshTime = now;
// ...刷新逻辑
}
现在我们把三个组件组合起来,实现一个完整的商品列表页:
javascript复制<template>
<div class="product-list">
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-tabs v-model="activeTab" @change="onTabChange">
<van-tab v-for="tab in tabs" :key="tab.id" :title="tab.name">
<van-list
v-model="loading"
:finished="finished"
@load="loadMore"
>
<product-item v-for="item in list" :key="item.id" :data="item" />
<template #finished>
<div class="list-finished">已加载全部商品</div>
</template>
</van-list>
</van-tab>
</van-tabs>
</van-pull-refresh>
</div>
</template>
对应的业务逻辑:
javascript复制data() {
return {
activeTab: 0,
tabs: [...],
list: [],
loading: false,
finished: false,
refreshing: false,
page: 1,
pageSize: 10
}
},
methods: {
async loadData(reset = false) {
if (reset) {
this.page = 1;
this.finished = false;
}
const { data } = await api.getProducts({
category: this.tabs[this.activeTab].id,
page: this.page,
size: this.pageSize
});
if (reset) {
this.list = data.items;
} else {
this.list = [...this.list, ...data.items];
}
if (data.items.length < this.pageSize) {
this.finished = true;
}
return data.items;
},
async onRefresh() {
const items = await this.loadData(true);
this.refreshing = false;
this.$toast.success(`已更新${items.length}条商品`);
},
async loadMore() {
const items = await this.loadData();
this.loading = false;
if (items.length > 0) {
this.page++;
}
},
onTabChange() {
this.list = [];
this.loadData(true);
}
}
在复杂场景下,我们需要特别注意状态管理:
javascript复制// store.js
state: {
tabData: {}
},
actions: {
async fetchProducts({ state }, { tabId, page }) {
if (state.tabData[tabId]?.[page]) {
return state.tabData[tabId][page];
}
const data = await api.getProducts({ tabId, page });
state.tabData = {
...state.tabData,
[tabId]: {
...(state.tabData[tabId] || {}),
[page]: data
}
};
return data;
}
}
javascript复制// ProductItem.vue
mounted() {
this.$el.querySelectorAll('img').forEach(img => {
img.dataset.src = img.src;
img.src = '';
});
this.io = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
this.io.unobserve(img);
}
});
});
this.$el.querySelectorAll('img').forEach(img => {
this.io.observe(img);
});
},
beforeDestroy() {
this.io.disconnect();
}
javascript复制data() {
return {
scrollTops: {}
}
},
methods: {
onTabChange(index) {
// 记录当前Tab的滚动位置
this.scrollTops[this.activeTab] = this.$refs.list?.scrollTop || 0;
// 切换Tab
this.activeTab = index;
// 恢复滚动位置
this.$nextTick(() => {
if (this.$refs.list) {
this.$refs.list.scrollTop = this.scrollTops[index] || 0;
}
});
}
}