第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\nSimple tech illustration expla\n\nAI comic creation scene, creat\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 的全部样式。

正确做法

  1. 方案一:只用一个库,避免混用
  2. 方案二:如果必须混用,用命名空间隔离
  3. 方案三:按需引入,只加载你用到的组件样式

性能小贴士:图片懒加载

移动端列表图片多的话,加载慢还费流量。用 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 个核心收获

  1. Vant/NutUI 是移动端专属的 UI 组件库,触控友好、包体积小、组件都是为手机设计的
  2. Rem 适配是移动端开发的基础,靠那行 JS 计算让不同屏幕都能完美显示
  3. 下拉刷新 + 滑动操作是移动端列表的灵魂,Vant 用不到 10 行代码就能搞定

延伸学习资源

  1. Vant 官方文档 — 组件最全,示例最详细
  2. NutUI 官方文档 — 京东出品,电商组件特别丰富
  3. 《移动端 UI 设计规范》— 了解 44px 最小触控区域等基础常识

互动钩子:你在做移动端项目时,是选 Vant 还是 NutUI?有没有遇到过什么奇葩的适配问题?评论区聊聊,老粉优先回复!


📌 下章预告:学会了 UI 组件库,下一章我们要解决一个问题——组件样式有时候不够灵活,这时候怎么办?答案是 CSS 预处理器!下一章我们来聊聊 Sass 和 Less,看它们如何让 CSS 也能「编程」。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。