Vant 4 实战教程:Vue3 移动端后台管理系统从选型到开发|Vue生态精选

0 阅读9分钟

同学们好,我是 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 PlusVant
定位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 3Vant 4
Vue 版本Vue 3Vue 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:是否自动引入样式,默认 true
  • exclude:不自动引入的组件或 API
  • module'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>

说明要点:

  1. van-search@search@cancel 都需触发列表刷新
  2. van-dropdown-menu:筛选变更时重置 pagelistfinished
  3. van-list:通过 loadingfinished@load 控制上拉加载
  4. 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>

说明要点:

  1. van-dialog + before-close:点击确认时校验,返回 false 阻止关闭
  2. 底部操作栏:position: fixed,内容区加 padding-bottom 防遮挡
  3. 安全区: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 和移动端各自一套路由,可共用接口和业务逻辑
  • 通过 redirectbeforeEach 做设备自动跳转
  • 可用 m.xxx.com 单独部署 H5,或与 PC 同域用路径区分

七、日常开发中的常见坑与规范

7.1 列表重置时机

切换筛选项或搜索时,必须:

  1. page = 1
  2. list = []
  3. 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.2
  • env(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,你的电子学友,我们下一篇干货见~