第5章 5.2 UI 库:Vant / NutUI(移动端)
🎯 开场 3 分钟:为什么移动端组件库非学不可?
上一章我们折腾完了 Element Plus,在 PC 端页面上画按钮、摆表格,心里美滋滋的。但当你掏出手机想做个移动端页面时,问题来了——Element Plus 那套在手机上根本不好使,字太小、按钮太宽、布局也不适配。
痛点来了:
- 你做的页面在 iPhone 上显示正常,到安卓机上字体大了一倍
- PC 端组件往手机上一放,密密麻麻挤成一团,根本点不到
- 想做个移动端特有的「下拉刷新」「左滑删除」,从零写要命
这就是为什么 Vant 和 NutUI 诞生了——专门为移动端量身定制的 UI 组件库。学完这一章,你就能用 Vue3 快速撸出一个体验流畅的手机端页面,而且适配各种屏幕。
🧱 基础 25 分钟:Vant / NutUI 核心概念
5.2.1 先搞清楚:什么是移动端组件库?
类比时间:想象你要装修一套小户型房子(手机屏幕)。Element Plus 就像\n\n
\n\n
\n\n给大平层设计的豪华家具套餐,沙发是 3 米宽的、皮质的那种——往小户型一放,门都关不上。而 Vant/NutUI 呢?它们是专门给小空间设计的简约家具——折叠桌、壁挂柜、变形沙发,样样都考虑到空间利用率。
移动端组件库就是针对手机屏幕特性优化过的 UI 零件箱:
- 触控友好的大按钮(44px 最小触控区域)
- 自动适配不同屏幕的响应式布局
- 手机专属交互:下拉刷新、上拉加载、滑动操作
- 更轻量的包体积
5.2.2 Vant 4.x 初体验:安装与按需引入
生活类比:你去自助餐厅,Element Plus 是「全自助」——所有菜都摆在那,想吃啥自己拿(全部引入)。Vant 更像「点餐制」——你点宫保鸡丁就只给你上宫保鸡丁,按需加载,省时省力。
Vant 4.x 推荐使用按需引入,这样打包体积最小:
# 先装 Vant
npm install vant@4
# 装一个工具,用来自动按需引入(你跟服务员说「我要点菜」,它帮你安排好)
npm install unplugin-vue-components -D
安装完了,还需要在项目里配置一下。打开 vite.config.ts(或者 vue.config.js,看你用啥构建工具):
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [VantResolver()], // 引入 VantResolver,自动按需导入
}),
],
})
这行在干嘛:告诉构建工具「用 VantResolver 这个翻译官」,它会自动把你用到的 Vant 组件翻译成精确的引入语句,打包时只打包你用到的那些组件。
配置好后,在 main.ts 引入 Vant 的样式文件(这一步不能省,否则组件会裸奔):
import { createApp } from 'vue'
import App from './App.vue'
import 'vant/lib/index.css' // 引入 Vant 全局样式
import './style.css'
const app = createApp(App)
app.mount('#app')
5.2.3 NutUI 入门:另一个移动端选手
NutUI 是京东出品的移动端组件库,跟 Vant 算是一对「老对手」。它的特点是:
- 组件更丰富(内置了筛选、区域选择等电商相关组件)
- 支持 Vue 2 和 Vue 3 双版本
- 主题定制能力更强
选型建议:
- 如果你做的是电商/商城类项目 → 优先考虑 NutUI(京东出品,血统纯正)
- 如果你做的是工具类/社交类项目 → Vant 更轻量简洁
NutUI 安装也很简单:
npm install @nutui/nutui@4
按需引入方式和 Vant 类似,都靠 unplugin-vue-components。
5.2.4 第一个 Vant 组件:Button 按钮
说了半天,来点真的!写一个最简单的 Vant 按钮:
<template>
<div class="demo">
<van-button type="primary">主要按钮</van-button>
<van-button type="success">成功按钮</van-button>
<van-button type="warning">警告按钮</van-button>
<van-button type="danger">危险按钮</van-button>
</div>
</template>
<script setup lang="ts">
// 这两个按钮组件会自动按需引入,不用手动写 import
import { showToast } from 'vant'
const handleClick = () => {
showToast('按钮被点击了!')
}
</script>
<style scoped>
.demo {
padding: 20px;
}
.demo .van-button {
margin: 8px;
}
</style>
这行在干嘛:showToast 是 Vant 内置的消息提示函数,一行代码弹出手机顶部那种短暂提示。
5.2.5 移动端适配核心:Rem 适配
这是移动端开发的灵魂问题:iPhone 和安卓手机屏幕宽度不一样,怎么让同一套代码在不同手机上看起来都正常?
Vant 和 NutUI 都用 Rem 作为尺寸单位。Rem 是一种相对单位,它相对于根元素(<html>)的字体大小。
生活类比:你给小明买衣服,店员不问「你要买多长的上衣」,而是问「你穿多大尺码的」。Rem 就是这个「尺码」——不管你 реальный 身高多少(屏幕多宽),尺码会帮你自动换算成合适的长度。
Vant 默认假设你的设计稿宽度是 375px(iPhone 6/7/8 的宽度),然后按比例缩放到其他屏幕。
要启用这个能力,需要在 index.html 的 <head> 里加一行 JS:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>移动端 Demo</title>
<!-- 这行 JS 很重要!让 Vant 的 Rem 适配生效 -->
<script>
!function(){var e=document.documentElement;var t=e.getBoundingClientRect().width;e.style.fontSize=t/37.5+"px"}();
</script>
</head>
这行在干嘛:获取屏幕宽度,除以 37.5,算出 1rem 等于多少像素。比如 iPhone 14 宽度是 390px,390/37.5 = 10.4,也就是说 1rem = 10.4px。
5.2.6 常用移动端组件速查
| 组件 | 用途 | 什么时候用 |
|---|---|---|
van-button |
按钮 | 任何需要点击触发的地方 |
van-field |
输入框 | 登录、搜索、表单 |
van-cell |
单元格 | 列表项、设置页 |
van-tabs |
标签页 | 切换不同内容区块 |
van-pull-refresh |
下拉刷新 | 列表页刷新数据 |
van-list |
列表 | 配合下拉刷新做分页加载 |
van-swipe-cell |
滑动单元格 | 左滑删除、右滑操作 |
van-popup |
弹出层 | 模态框、底部抽屉 |
van-dialog |
对话框 | 确认操作、提示信息 |
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):用 Vant 做个登录页
目标:写一个最简单的移动端登录页面,理解 Vant 表单组件的基本用法。
<template>
<div class="login-page">
<h2 class="title">欢迎回来</h2>
<van-form @submit="handleLogin">
<van-cell-group inset>
<van-field
v-model="form.phone"
name="phone"
label="手机号"
placeholder="请输入手机号"
:rules="[{ required: true, message: '请填写手机号' }]"
/>
<van-field
v-model="form.password"
type="password"
name="password"
label="密码"
placeholder="请输入密码"
:rules="[{ required: true, message: '请填写密码' }]"
/>
</van-cell-group>
<div class="submit-btn">
<van-button
round
block
type="primary"
native-type="submit"
:loading="loading"
>
登录
</van-button>
</div>
</van-form>
<div class="footer-links">
<span @click="goRegister">没有账号?去注册</span>
<span @click="forgotPassword">忘记密码</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { showToast } from 'vant'
const form = ref({
phone: '',
password: ''
})
const loading = ref(false)
const handleLogin = () => {
loading.value = true
// 模拟登录请求
setTimeout(() => {
loading.value = false
showToast('登录成功!')
}, 1500)
}
const goRegister = () => showToast('跳转注册页')
const forgotPassword = () => showToast('跳转找回密码页')
</script>
<style scoped>
.login-page {
padding: 40px 20px;
background: #f7f8fa;
min-height: 100vh;
}
.title {
text-align: center;
font-size: 24px;
color: #323233;
margin-bottom: 40px;
}
.submit-btn {
margin: 24px 16px;
}
.footer-links {
display: flex;
justify-content: space-between;
padding: 0 16px;
font-size: 14px;
color: #1989fa;
}
</style>
预期输出:页面显示「欢迎回来」标题,一个手机号输入框、一个密码输入框、底部登录按钮。点击登录按钮显示 loading 状态,1.5 秒后弹出「登录成功!」
一句话解释:van-form + van-field 是 Vant 的表单组合,配合 :rules 可以一键完成表单校验。
项目 2(15 分钟):NutUI 做商品列表页(带下拉刷新)
目标:做一个商品列表页,支持「下拉刷新」和「上拉加载更多」。
<template>
<div class="product-page">
<van-nav-bar title="商品列表" fixed />
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div class="product-list">
<div
v-for="item in productList"
:key="item.id"
class="product-card"
>
<img :src="item.image" class="product-image" />
<div class="product-info">
<div class="product-name">{{ item.name }}</div>
<div class="product-price">¥{{ item.price }}</div>
<van-tag type="primary" size="small">{{ item.tag }}</van-tag>
</div>
</div>
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { showToast } from 'vant'
interface Product {
id: number
name: string
price: number
image: string
tag: string
}
// 模拟数据
const mockProducts: Product[] = [
{ id: 1, name: 'iPhone 15 Pro', price: 8999, image: 'https://placehold.co/100x100?text=Phone', tag: '热门' },
{ id: 2, name: 'AirPods Pro', price: 1899, image: 'https://placehold.co/100x100?text=Pods', tag: '新品' },
{ id: 3, name: 'MacBook Air', price: 9999, image: 'https://placehold.co/100x100?text=Mac', tag: '推荐' },
{ id: 4, name: 'iPad Pro', price: 6999, image: 'https://placehold.co/100x100?text=iPad', tag: '热卖' },
]
const productList = ref<Product[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
let page = 1
const onLoad = () => {
// 模拟加载延迟
setTimeout(() => {
// 模拟分页:每次加载 4 条
const start = (page - 1) * 4
const end = start + 4
const newItems = mockProducts.slice(start, end)
productList.value.push(...newItems)
loading.value = false
// 模拟只有一页数据
if (page >= 1) {
finished.value = true
}
page++
}, 1000)
}
const onRefresh = () => {
// 重置列表
productList.value = []
page = 1
finished.value = false
loading.value = true
onLoad()
refreshing.value = false
showToast('刷新成功')
}
</script>
<style scoped>
.product-page {
background: #f7f8fa;
min-height: 100vh;
padding-top: 46px;
}
.product-list {
display: flex;
flex-wrap: wrap;
padding: 12px;
gap: 12px;
}
.product-card {
width: calc(50% - 6px);
background: white;
border-radius: 8px;
overflow: hidden;
}
.product-image {
width: 100%;
height: 160px;
object-fit: cover;
}
.product-info {
padding: 12px;
}
.product-name {
font-size: 14px;
color: #323233;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-price {
font-size: 16px;
color: #ee0a24;
font-weight: bold;
margin-bottom: 6px;
}
</style>
预期输出:页面顶部有「商品列表」导航栏。下拉页面时显示下拉刷新动画,松手后数据重新加载。上拉到列表底部时显示加载状态,加载完成后显示「没有更多了」。
一句话解释:van-pull-refresh + van-list 是移动端列表的黄金组合,下拉刷新 + 上拉加载一网打尽。
项目 3(15 分钟):Vant + 组合技能做个「待办清单」
目标:综合运用前面学的知识,做一个可以添加、完成、删除待办事项的清单页。
<template>
<div class="todo-page">
<van-nav-bar title="我的待办" fixed />
<div class="todo-content">
<!-- 添加待办 -->
<van-cell-group inset>
<van-field
v-model="newTodo"
placeholder="输入待办事项,按回车添加"
@keyup.enter="addTodo"
>
<template #button>
<van-button size="small" type="primary" @click="addTodo">
添加
</van-button>
</template>
</van-field>
</van-cell-group>
<!-- 待办列表 -->
<van-swipe-cell v-for="todo in todoList" :key="todo.id">
<van-cell-group inset>
<van-cell>
<template #title>
<div class="todo-item" :class="{ completed: todo.done }">
<van-checkbox
:model-value="todo.done"
@update:model-value="toggleTodo(todo.id)"
/>
<span>{{ todo.text }}</span>
</div>
</template>
</van-cell>
</van-cell-group>
<template #right>
<van-button
square
type="danger"
text="删除"
class="delete-btn"
@click="deleteTodo(todo.id)"
/>
</template>
</van-swipe-cell>
<!-- 统计 -->
<div class="stats">
共 {{ todoList.length }} 项,已完成 {{ completedCount }} 项
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
interface Todo {
id: number
text: string
done: boolean
}
const todoList = ref<Todo[]>([
{ id: 1, text: '学习 Vant 组件库', done: false },
{ id: 2, text: '完成项目实战', done: true },
{ id: 3, text: '整理笔记', done: false },
])
const newTodo = ref('')
let nextId = 4
const completedCount = computed(() => {
return todoList.value.filter(t => t.done).length
})
const addTodo = () => {
if (!newTodo.value.trim()) {
showToast('请输入内容')
return
}
todoList.value.push({
id: nextId++,
text: newTodo.value.trim(),
done: false
})
newTodo.value = ''
showToast('添加成功')
}
const toggleTodo = (id: number) => {
const todo = todoList.value.find(t => t.id === id)
if (todo) {
todo.done = !todo.done
}
}
const deleteTodo = async (id: number) => {
try {
await showConfirmDialog({
title: '确认删除',
message: '确定要删除这条待办吗?',
})
todoList.value = todoList.value.filter(t => t.id !== id)
showToast('删除成功')
} catch {
// 用户取消了
}
}
</script>
<style scoped>
.todo-page {
background: #f7f8fa;
min-height: 100vh;
padding-top: 46px;
}
.todo-content {
padding-top: 12px;
}
.todo-item {
display: flex;
align-items: center;
gap: 12px;
}
.todo-item.completed span {
text-decoration: line-through;
color: #969799;
}
.delete-btn {
height: 100%;
}
.stats {
text-align: center;
padding: 20px;
color: #969799;
font-size: 14px;
}
</style>
预期输出:页面显示「我的待办」标题,输入框可以添加新待办。已有三条待办,左滑每条会露出红色「删除」按钮,点击删除会弹出确认对话框。勾选复选框可以标记完成(文字变灰+删除线)。
一句话解释:van-swipe-cell 实现了左滑删除功能,配合 showConfirmDialog 做二次确认,用户体验更安全。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:Rem 适配不生效?
❌ 错误做法:
// 只在 CSS 里写了 rem,但忘了在 index.html 加 JS
.box {
width: 10rem; // 可能不生效!
}
✅ 正确做法:
<!-- 必须在 index.html 的 <head> 中加这段 JS -->
<script>
!function(){var e=document.documentElement,t=e.getBoundingClientRect().width;e.style.fontSize=t/37.5+"px"}();
</script>
坑 2:van-list 数据变了但界面不更新?
❌ 错误做法:
// 直接替换数组(Vue 检测不到)
productList.value = newList
✅ 正确做法:
// 用 splice 或者 push
productList.value.splice(0, productList.value.length, ...newList)
// 或者
productList.value = [...newList] // 创建新数组引用
坑 3:在 Vite 里按需引入不生效?
❌ 错误做法:没装 unplugin-vue-components 就开始写组件代码。
✅ 正确做法:
npm install unplugin-vue-components -D
npm install vant -S
然后 vite.config.ts 里一定要配置 VantResolver()。
坑 4:移动端点击延迟(300ms)?
❌ 错误做法:用 click 事件,在手机上感觉慢半拍。
✅ 正确做法:Vant 组件内部已经处理了这个问题,但如果你自己写交互,可以加这个 meta 标签:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
坑 5:NutUI 和 Vant 混用导致样式冲突?
❌ 错误做法:同时在项目里引入 NutUI 和 Vant 的全部样式。
✅ 正确做法:
- 方案一:只用一个库,避免混用
- 方案二:如果必须混用,用命名空间隔离
- 方案三:按需引入,只加载你用到的组件样式
性能小贴士:图片懒加载
移动端列表图片多的话,加载慢还费流量。用 Vant 的 Lazyload 指令:
import { Lazyload } from 'vant'
const app = createApp(App)
app.use(Lazyload, {
loading: 'https://placehold.co/100x100?text=Loading', // 加载中占位图
error: 'https://placehold.co/100x100?text=Error', // 加载失败占位图
})
然后在模板里用 v-lazy 替代 src:
<img v-lazy="item.image" />
调试技巧:Vant DevTools
Vant 提供了浏览器控制台调试工具,可以看到组件的层级和状态。安装方式:
npm install @vant/touch-emulator -S
这个包会自动在开发环境模拟桌面端的触控事件,方便你在 Chrome DevTools 里调试滑动等交互。
✏️ 练习题
练习 1(2 分钟):换个按钮类型
- 输入:把项目 1 里的登录按钮从
type="primary"改成type="success" - 预期输出:按钮颜色从蓝色变成绿色
- 提示:直接改
type属性的值
练习 2(2 分钟):加个必填校验
- 输入:在项目 1 的手机号输入框,加一条规则「手机号必须是 11 位」
- 预期输出:输入 10 位以下数字时点登录,弹出「手机号格式错误」之类的提示
- 提示:
:rules可以写自定义校验函数
练习 3(3 分钟):NutUI 的筛选组件
- 输入:用 NutUI 的
Picker组件做一个「选择城市」的功能(至少包含北上广深) - 预期输出:点击弹出城市选择器,选择后显示选中的城市名
- 提示:NutUI 的
Picker用法和 Vant 类似
练习 4(3 分钟):串联项目 1 和项目 3
- 输入:把项目 3 的待办清单加上「登录后可见」的限制
- 预期输出:用户没登录时看不到待办列表,只能看到「请先登录」
- 提示:用一个
isLoggedIn的 ref 控制显示
练习 5(挑战题,10 分钟):分析报错截图
- 输入:页面报错
Error: Cannot read properties of undefined (reading 'id') - 预期输出:找出报错原因,修复代码
- 提示:检查
v-for里是不是访问了一个可能是undefined的对象属性
作业:做一个「移动端记账本」
需求描述:
用 Vant 或 NutUI 做一个小巧的记账工具,帮助你记录日常花销。
功能点:
1. 录入消费:金额、类别(餐饮/交通/购物/其他)、备注
2. 列表展示:按日期分组,显示每笔消费
3. 统计汇总:显示今日/本周/本月总支出
加分项:
1. 本地持久化(用 localStorage,刷新不丢数据)
2. 饼图/柱状图可视化支出结构
验收标准:
- 能正常添加消费记录
- 列表能按日期分组展示
- 统计数字正确
- 刷新后数据还在
提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
本章 3 个核心收获
- Vant/NutUI 是移动端专属的 UI 组件库,触控友好、包体积小、组件都是为手机设计的
- Rem 适配是移动端开发的基础,靠那行 JS 计算让不同屏幕都能完美显示
- 下拉刷新 + 滑动操作是移动端列表的灵魂,Vant 用不到 10 行代码就能搞定
延伸学习资源
- Vant 官方文档 — 组件最全,示例最详细
- NutUI 官方文档 — 京东出品,电商组件特别丰富
- 《移动端 UI 设计规范》— 了解 44px 最小触控区域等基础常识
互动钩子:你在做移动端项目时,是选 Vant 还是 NutUI?有没有遇到过什么奇葩的适配问题?评论区聊聊,老粉优先回复!
📌 下章预告:学会了 UI 组件库,下一章我们要解决一个问题——组件样式有时候不够灵活,这时候怎么办?答案是 CSS 预处理器!下一章我们来聊聊 Sass 和 Less,看它们如何让 CSS 也能「编程」。

评论(0)