同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,
咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。
一、为什么要单独聊 Vant 在「后台」里的使用?
一说后台,大家会想到 Element Plus、Ant Design Vue。但在很多企业里,还有这种需求:
- 领导出差时用手机审批单据
- 运维在机房用手机查看监控、处理告警
- 门店店员用手机完成盘点、退货等操作
这些本质上都是后台逻辑,只是终端是手机 / 平板。Element 这种 PC 端 UI 库在小屏上体验不好,所以需要移动端组件库,而 Vant 就是 Vue 生态里最常用的一个。
一句话:Vant = 面向移动端的 Vue 组件库,适合移动管理页、H5 审批页等「轻量后台」场景。
二、概念扫盲:Vant 是什么,和 Element 有啥区别?
2.1 快速对比
| 维度 | Element Plus | Vant |
|---|---|---|
| 定位 | PC 端管理后台 | 移动端 / H5 |
| 适配 | 大屏 | 小屏、触摸 |
| 典型场景 | 复杂表格、多级菜单 | 列表、表单、弹窗、上拉加载 |
2.2 常见误解
误解 1:Vant 只能做 C 端页面?
不是。审批流、数据查询、简单增删改查,都可以用 Vant,只是交互要按移动端习惯来设计。
误解 2:后台必须全用 Element?
不一定。PC 用 Element,移动端用 Vant,可以并存,路由上区分即可。
误解 3:Vant 只能做 H5,不能做 App?
Vant 本身是 Web 组件,用 UniApp、Taro 等可以打包成 App,但本文只讨论「H5 管理页」这种典型场景。
三、Vant 4 vs Vant 3:如何选择?
3.1 核心区别
| 维度 | Vant 3 | Vant 4 |
|---|---|---|
| Vue 版本 | Vue 3 | Vue 3 |
| 包体积 | 约 7MB 安装体积 | 约 5MB,减少约 30% |
| 深色模式 | 需自行实现 | 原生支持 |
| Nuxt / SSR | 支持一般 | 对 Nuxt 3 支持更好 |
| 新组件 | - | Skeleton 子组件、PickerGroup、DatePicker、TimePicker、BackTop 等 |
| 主题定制 | 灵活 | 700+ 主题变量,主色统一为 #1989fa |
| TypeScript | 支持 | 类型更完整 |
3.2 选择建议
- 新项目:建议直接用 Vant 4,体积更小,功能更全。
- 老项目:Vant 3 稳定运行的情况下不必强迁,可按需求迭代;若用到深色模式、Nuxt 3 等,再考虑升级。
- Vant 3 → 4:官方有升级指南,整体迁移路径较清晰。
四、实战场景一:简单的移动端管理页
4.1 需求描述
- 一个「设备管理」H5 页
- 顶部搜索 + 筛选
- 列表展示设备,支持上拉加载
- 点击某行进入详情
4.2 项目初始化
# 创建 Vue 3 项目
npm create vue@latest vant-admin-h5
# 进入项目后安装 Vant
npm i vant
4.3 按需引入:unplugin-vue-components 配置(推荐)
生产项目建议用 @vant/auto-import-resolver + unplugin-vue-components,自动按需引入组件和样式,减小包体积。
安装依赖:
npm i @vant/auto-import-resolver unplugin-vue-components unplugin-auto-import -D
Vite 配置示例:
// vite.config.js 或 vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from '@vant/auto-import-resolver'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [VantResolver()],
dts: 'src/auto-imports.d.ts', // 生成类型声明
}),
Components({
resolvers: [
VantResolver({
importStyle: true, // 自动引入样式,默认 true
// exclude: ['Button'], // 排除某些组件
}),
],
dts: 'src/components.d.ts', // 生成组件类型声明
}),
],
})
可选配置:
importStyle:是否自动引入样式,默认trueexclude:不自动引入的组件或 APImodule:'esm'或'cjs',默认'esm'
配置完成后,模板中可直接使用 Vant 组件,无需手动 import:
<template>
<van-button type="primary">按钮</van-button>
</template>
<!-- 无需在 script 中 import -->
函数式 API 也会自动引入:
showToast('操作成功')
await showConfirmDialog({ title: '确认', message: '确定删除?' })
Vue CLI 项目示例:
// vue.config.js
const AutoImport = require('unplugin-auto-import/webpack')
const Components = require('unplugin-vue-components/webpack')
const { VantResolver } = require('@vant/auto-import-resolver')
module.exports = {
configureWebpack: {
plugins: [
AutoImport({
resolvers: [VantResolver()],
}),
Components({
resolvers: [VantResolver()],
}),
],
},
}
4.4 手动按需引入(不装插件时)
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { Search, Field, Cell, CellGroup, List, Empty, Toast, showToast } from 'vant'
import 'vant/lib/index.css'
const app = createApp(App)
app.use(Search).use(Field).use(Cell).use(CellGroup).use(List).use(Empty).use(Toast)
app.mount('#app')
4.5 完整页面示例
<template>
<div class="device-list-page">
<van-search
v-model="keyword"
placeholder="请输入设备名称或编号"
show-action
@search="onSearch"
@cancel="onCancel"
/>
<van-dropdown-menu>
<van-dropdown-item v-model="status" :options="statusOptions" @change="onFilterChange" />
</van-dropdown-menu>
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell
v-for="item in list"
:key="item.id"
:title="item.name"
:label="item.code"
is-link
@click="goDetail(item.id)"
/>
<van-empty v-if="!loading && list.length === 0" description="暂无设备" />
</van-list>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
const router = useRouter()
const keyword = ref('')
const status = ref('')
const list = ref([])
const loading = ref(false)
const finished = ref(false)
const page = ref(1)
const pageSize = 10
const statusOptions = [
{ text: '全部状态', value: '' },
{ text: '正常', value: 'normal' },
{ text: '维修中', value: 'repair' },
{ text: '停用', value: 'disabled' },
]
const fetchList = async () => {
const res = await fetch(
`/api/devices?keyword=${keyword.value}&status=${status.value}&page=${page.value}&pageSize=${pageSize}`
)
return res.json()
}
const onLoad = async () => {
loading.value = true
try {
const { data } = await fetchList()
list.value = page.value === 1 ? data : [...list.value, ...data]
if (data.length < pageSize) finished.value = true
page.value++
} catch (e) {
showToast('加载失败')
finished.value = true
} finally {
loading.value = false
}
}
const onSearch = () => {
page.value = 1
list.value = []
finished.value = false
onLoad()
}
const onCancel = () => {
keyword.value = ''
onSearch()
}
const onFilterChange = () => {
page.value = 1
list.value = []
finished.value = false
onLoad()
}
const goDetail = (id) => {
router.push({ name: 'DeviceDetail', params: { id } })
}
</script>
<style scoped>
.device-list-page {
padding-bottom: 50px;
}
</style>
说明要点:
van-search:@search、@cancel都需触发列表刷新van-dropdown-menu:筛选变更时重置page、list、finishedvan-list:通过loading、finished、@load控制上拉加载van-empty:无数据时展示,避免空白
五、实战场景二:审批流页面
5.1 需求描述
- 待审批列表
- 进入详情可查看单据信息、附件
- 操作:通过 / 驳回,驳回需填写原因
5.2 审批详情页示例
<template>
<div class="approval-detail-page">
<van-cell-group inset>
<van-cell title="申请单号" :value="detail.sn" />
<van-cell title="申请人" :value="detail.applicant" />
<van-cell title="申请时间" :value="detail.createTime" />
<van-cell title="申请事由" :value="detail.reason" />
</van-cell-group>
<van-cell title="附件" v-if="detail.files?.length">
<template #right-icon>
<van-button size="small" type="primary" plain @click="previewFiles">查看</van-button>
</template>
</van-cell>
<div class="action-bar">
<van-button block type="danger" @click="showRejectDialog = true">驳回</van-button>
<van-button block type="primary" @click="approve">通过</van-button>
</div>
<van-dialog
v-model:show="showRejectDialog"
title="驳回原因"
show-cancel-button
:before-close="beforeRejectClose"
>
<van-field
v-model="rejectReason"
type="textarea"
rows="4"
placeholder="请输入驳回原因(必填)"
/>
</van-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
const route = useRoute()
const id = route.params.id
const detail = ref({})
const showRejectDialog = ref(false)
const rejectReason = ref('')
const fetchDetail = async () => {
const res = await fetch(`/api/approvals/${id}`)
detail.value = await res.json()
}
const approve = async () => {
try {
await fetch(`/api/approvals/${id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'approve' }),
})
showSuccessToast('审批通过')
setTimeout(() => history.back(), 1000)
} catch (e) {
showToast('操作失败')
}
}
const beforeRejectClose = (action) => {
if (action === 'confirm') {
if (!rejectReason.value.trim()) {
showToast('请填写驳回原因')
return false
}
doReject()
}
return true
}
const doReject = async () => {
try {
await fetch(`/api/approvals/${id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'reject',
reason: rejectReason.value.trim(),
}),
})
showSuccessToast('已驳回')
setTimeout(() => history.back(), 1000)
} catch (e) {
showToast('操作失败')
}
}
const previewFiles = () => {
showToast('附件预览功能')
}
onMounted(fetchDetail)
</script>
<style scoped>
.approval-detail-page {
padding-bottom: 70px;
}
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 12px;
padding: 12px 16px;
background: #fff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.08);
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
</style>
说明要点:
van-dialog+before-close:点击确认时校验,返回false阻止关闭- 底部操作栏:
position: fixed,内容区加padding-bottom防遮挡 - 安全区:
padding-bottom: env(safe-area-inset-bottom)
六、移动端 + PC 双端路由区分实战
6.1 思路
- 用
User-Agent区分手机/平板和 PC - 为 PC 和移动端分别建路由表
- 根路径或指定路径根据设备重定向到对应端
6.2 设备检测工具
// utils/device.js
/**
* 判断是否为移动端(含平板)
*/
export function isMobile() {
const ua = navigator.userAgent
return /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i.test(ua)
}
/**
* 获取终端类型
*/
export function getTerminalType() {
return isMobile() ? 'mobile' : 'pc'
}
6.3 路由结构
src/
├── router/
│ └── index.js
├── views/
│ ├── pc/ # PC 端页面(Element)
│ │ ├── Home.vue
│ │ ├── DeviceList.vue
│ │ └── ApprovalList.vue
│ └── mobile/ # 移动端页面(Vant)
│ ├── Home.vue
│ ├── DeviceList.vue
│ └── ApprovalDetail.vue
6.4 路由配置示例
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { isMobile } from '@/utils/device'
// PC 端布局(带侧边栏等)
const PCLayout = () => import('@/layouts/PCLayout.vue')
// 移动端布局(简单导航)
const MobileLayout = () => import('@/layouts/MobileLayout.vue')
// PC 端路由
const pcRoutes = [
{
path: '/pc',
component: PCLayout,
children: [
{ path: '', name: 'PCHome', component: () => import('@/views/pc/Home.vue') },
{ path: 'devices', name: 'PCDeviceList', component: () => import('@/views/pc/DeviceList.vue') },
{ path: 'approvals', name: 'PCApprovalList', component: () => import('@/views/pc/ApprovalList.vue') },
],
},
]
// 移动端路由
const mobileRoutes = [
{
path: '/mobile',
component: MobileLayout,
children: [
{ path: '', name: 'MobileHome', component: () => import('@/views/mobile/Home.vue') },
{ path: 'devices', name: 'MobileDeviceList', component: () => import('@/views/mobile/DeviceList.vue') },
{ path: 'approvals/:id', name: 'MobileApprovalDetail', component: () => import('@/views/mobile/ApprovalDetail.vue') },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: () => (isMobile() ? '/mobile' : '/pc') },
...pcRoutes,
...mobileRoutes,
],
})
// 路由守卫:防止跨端访问
router.beforeEach((to, from, next) => {
const mobile = isMobile()
// 根路径已在 routes 里做 redirect
if (to.path === '/') return next()
const toPC = to.path.startsWith('/pc')
const toMobile = to.path.startsWith('/mobile')
if (mobile && toPC) {
// 手机访问 PC 路由 → 重定向到移动端对应页
const map = { '/pc': '/mobile', '/pc/devices': '/mobile/devices', '/pc/approvals': '/mobile' }
next(map[to.path] || '/mobile')
} else if (!mobile && toMobile) {
// PC 访问移动端路由 → 重定向到 PC 对应页
const map = { '/mobile': '/pc', '/mobile/devices': '/pc/devices', '/mobile/approvals': '/pc/approvals' }
next(map[to.path] || '/pc')
} else {
next()
}
})
export default router
6.5 简要说明
- PC 和移动端各自一套路由,可共用接口和业务逻辑
- 通过
redirect和beforeEach做设备自动跳转 - 可用
m.xxx.com单独部署 H5,或与 PC 同域用路径区分
七、日常开发中的常见坑与规范
7.1 列表重置时机
切换筛选项或搜索时,必须:
page = 1list = []finished = false
否则会新旧数据混合,或 van-list 不再触发 @load。
7.2 Toast / Dialog 使用方式
import { showToast, showConfirmDialog } from 'vant'
showToast('操作成功')
await showConfirmDialog({ title: '确认', message: '确定删除?' })
优先用函数式调用,而不是在 template 里用 <van-toast /> 手动控制。
7.3 安全区适配
有底部固定栏时建议加安全区:
.action-bar {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
constant(safe-area-inset-bottom):兼容 iOS 11.0–11.2env(safe-area-inset-bottom):兼容 iOS 11.3+ 及现代浏览器
7.4 与 PC 后台共存
- PC 用 Element,移动端用 Vant
- 通过
User-Agent或独立域名(如m.xxx.com)区分 - 共用接口和业务逻辑
7.5 按需引入 vs 全量引入
- 生产环境:优先用
unplugin-vue-components+@vant/auto-import-resolver按需引入 - 学习/演示:可全量引入,但正式项目不推荐
八、小结
| 场景 | 常用组件 | 注意点 |
|---|---|---|
| 移动端管理列表 | Search、DropdownMenu、List、Cell、Empty | 搜索/筛选时重置分页和列表 |
| 审批流 | CellGroup、Dialog、Field、Button | 驳回前校验必填,底部栏留安全区 |
| 双端路由 | - | User-Agent 检测 + 路由守卫重定向 |
Vant 在后台里的角色很清晰:不是替代 Element,而是负责移动端部分。选好 Vant 版本、按需引入、理清双端路由,就能更顺畅地做移动端管理页和审批页。
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~