工程化、请求层与常用控件
Vue 工程化、请求层与常用控件
00 到 04 攒齐了零件, 这篇装整车: 一条真实后台项目的完整 CRUD 链路。所谓真实, 意思是每一步都要处理 Loading、空数据、失败重试、重复点击、权限这些教程里从来不写但上线必炸的东西。这也是我离”能写玩具”和”能写项目”差距最大的一篇。
flowchart LR
Q[查询条件] --> L[加载列表]
L --> T[表格]
T --> E[编辑表单]
E --> V[校验]
V --> S[提交]
S --> R[刷新或回写]
1. Vite 环境与别名
代码块收起展开
// vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } // @ 指向 src, 告别 ../../..
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080', // 开发时 /api 请求转发给本地 Spring Boot
changeOrigin: true
}
}
}
})dev proxy 是前后端联调的标配: 前端页面在 5173, 后端在 8080, 浏览器跨域限制直接请求会被拦, 让 Vite 开发服务器代转一手就绕过了。注意它只在开发时生效, 生产环境要靠 Nginx 反代或网关。
环境变量写在 .env 系列文件里, 必须以 VITE_ 开头才会暴露给前端代码 (import.meta.env.VITE_API_BASE_URL)。
这个前缀是道防呆闸: 前端产物里的所有变量用户按 F12 都看得到, 数据库密码这类真机密永远不能出现在前端 .env 里, 加前缀逼你想清楚每个变量是不是真的能公开。
2. TypeScript 数据契约
这块是 Java 人最舒适的部分, interface 就是 DTO:
代码块收起展开
export interface Task { // 后端实体的前端镜像
id: number
title: string
status: 'todo' | 'doing' | 'done' // 字面量联合: 比 String 更严的枚举
assigneeId: number | null
dueAt: string | null
version: number
}
export interface TaskQuery { // 查询参数 DTO
page: number
pageSize: number
keyword?: string
status?: Task['status']
}
export interface PageResult<T> { // 分页包装, 泛型和 Java 一个意思
items: T[]
total: number
}
export type TaskInput = Pick<Task, 'title' | 'status' | 'assigneeId' | 'dueAt'> // 从 Task 挑字段拼出表单类型和后端的分层习惯一样: 实体、查询、表单输入分开定义, 别用一个 any 大对象从请求一路贯穿到表单。另外记住 TS 的类型只在编译期存在, 后端响应是运行时的不可信输入, 关键接口该做运行时校验就得做, 类型标注挡不住后端改字段。
3. Axios 请求层
裸用 fetch 写项目, 每个请求都要手动带 token、手动查 401、手动解析错误, 重复到吐。请求层封装就是把这些横切逻辑收拢到一处, axios 的拦截器和 Spring 的 HandlerInterceptor/AOP 是同一个思想:
// api/http.ts
import axios from 'axios' // npm install axios, 比 fetch 顺手的请求库
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
timeout: 10_000
})
// 请求拦截器: 每个请求出门前自动戴上 token
http.interceptors.request.use(config => {
const auth = useAuthStore()
if (auth.token) config.headers.Authorization = `Bearer ${auth.token}`
return config
})
// 响应拦截器: 统一拆包 + 统一处理 401 + 错误标准化
http.interceptors.response.use(
response => response.data,
async error => {
if (error.response?.status === 401) await handleUnauthorized() // 03 篇写的统一登出
return Promise.reject(normalizeApiError(error))
}
)// api/tasks.ts: 每个业务域一个文件, 相当于后端的 XxxClient
export const taskApi = {
list: (query: TaskQuery, signal?: AbortSignal) =>
http.get<never, PageResult<Task>>('/tasks', { params: query, signal }),
create: (input: TaskInput) =>
http.post<never, Task>('/tasks', input),
update: (id: number, input: TaskInput & { version: number }) =>
http.put<never, Task>(`/tasks/${id}`, input),
remove: (id: number) =>
http.delete(`/tasks/${id}`)
}分层责任: 请求层管协议 (路径、参数、超时、取消、把后端五花八门的错误整形成统一结构), 页面只管拿数据和处理”成功/失败”两种结局。错误标准化后页面能按 kind 分流处理:
代码块收起展开
export interface AppError extends Error {
kind: 'network' | 'timeout' | 'unauthorized' | 'validation' | 'server'
status?: number
fieldErrors?: Record<string, string> // 后端字段校验错误, 表单要用
requestId?: string // 排查问题时和后端对暗号用
}4. useRequest: 把”请求三态”封装掉
每个请求都要 data/loading/error 三个变量加一套竞态处理, 抽成组合式函数一劳永逸:
代码块收起展开
export function useRequest<T, A extends unknown[]>(request: (...args: A) => Promise<T>) {
const data = shallowRef<T>() // shallowRef: 大数据不做深层响应式, 省性能
const loading = ref(false)
const error = shallowRef<unknown>()
let sequence = 0 // 04 篇的版本号防乱序, 又见面了
async function run(...args: A) {
const current = ++sequence
loading.value = true
error.value = undefined
try {
const result = await request(...args)
if (current === sequence) data.value = result
return result
} catch (e) {
if (current === sequence) error.value = e
throw e
} finally {
if (current === sequence) loading.value = false
}
}
return { data, loading, error, run }
}这个最小实现日常够用。等哪天开始想给它加缓存、重试、去重, 停手, 那是在重新发明轮子, 直接换成熟的服务端状态库。
5. Element Plus 表单
表单是后台项目的重头戏, Element Plus 的 el-form 全家桶把校验、布局、错误展示都包了:
代码块收起展开
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
const props = defineProps<{ initial?: Task }>() // 传了就是编辑, 没传就是新建
const emit = defineEmits<{ saved: [task: Task] }>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive<TaskInput>({
title: '', status: 'todo', assigneeId: null, dueAt: null
})
const rules: FormRules<TaskInput> = {
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ min: 2, max: 100, message: '长度为 2 到 100 个字符', trigger: 'blur' }
],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
async function submit() {
if (submitting.value) return // 入口防重
await formRef.value?.validate() // 不过直接抛, 走不到提交
submitting.value = true
try {
const saved = props.initial
? await taskApi.update(props.initial.id, { ...form, version: props.initial.version })
: await taskApi.create({ ...form })
emit('saved', saved)
} catch (error) {
applyServerFieldErrors(formRef.value, error) // 后端字段错误映射回具体表单项
throw error
} finally {
submitting.value = false
}
}
</script>
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
<el-form-item label="标题" prop="title"> <!-- prop 必须对上 model 字段名, 校验才生效 -->
<el-input v-model.trim="form.title" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status">
<el-option label="待处理" value="todo" />
<el-option label="进行中" value="doing" />
<el-option label="已完成" value="done" />
</el-select>
</el-form-item>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</el-form>
</template>几条实战规矩: 编辑弹窗打开时把行数据复制一份当草稿, 用户点取消不能污染列表里的原对象; 前端校验只是体验, 后端必须完整再校验一遍 (和 03 篇的路由权限同一条安全观); 服务端返回的字段错误要落到具体输入框下面, 不能只弹个”请求失败”。
6. 表格、分页与筛选
代码块收起展开
<el-table
v-loading="loading"
:data="rows"
row-key="id"
@selection-change="selection = $event"
>
<el-table-column type="selection" width="48" reserve-selection />
<el-table-column prop="title" label="标题" min-width="220" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }"><TaskStatusTag :status="row.status" /></template> <!-- 02 篇的作用域插槽 -->
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeTask(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="loadTasks"
/>生产边界几条: 分页筛选排序放后端做, 别把全量数据拉到前端过滤; 筛选条件一变页码重置回 1; 删掉当前页最后一条时记得回退一页; row-key 给稳定 id, 跨页选择和局部更新全靠它认人。
7. 弹窗与确认
代码块收起展开
<el-dialog
v-model="visible"
:title="editingTask ? '编辑任务' : '新建任务'"
width="min(560px, calc(100vw - 32px))"
:close-on-click-modal="!dirty"
destroy-on-close
>
<TaskForm :initial="editingTask" @saved="handleSaved" />
</el-dialog>有草稿时禁止点遮罩关闭 (误触丢半小时输入的痛谁丢谁知道); 删除必须二次确认, 而且失败时别提前把行从界面上移掉, 界面骗人比报错更伤信任。
8. 常用控件速查
| 控件 | 适用场景 | 最容易踩的坑 |
|---|---|---|
| Input | 短文本、搜索 | 搜索要防抖 + 取消旧请求 |
| Select | 中小选项集 | 远程搜索的 Loading 和过期结果 |
| DatePicker | 日期/区间 | 时区和”含不含结束日” |
| Upload | 文件上传 | accept 只是选择器过滤, 后端必须重新校验 |
| Tabs | 同上下文切视图 | 想可分享就同步进 query |
| Tooltip | 补充短提示 | 关键信息不能只藏在 hover 里 |
日期多说一句, 这坑后端同学熟: 生日这类纯日期用 YYYY-MM-DD 别碰时区; 时间点存 UTC 按用户时区显示; “当天结束”用左闭右开 [start, nextDay), 别手拼 23:59:59。
9. 自定义权限指令
按钮级权限控制, 封一个指令全项目复用:
export const permission: Directive<HTMLElement, string[]> = {
mounted(el, binding) {
const auth = useAuthStore()
if (!auth.hasAnyRole(binding.value)) el.remove() // 没权限直接从 DOM 摘掉
}
}<el-button v-permission="['admin', 'task:write']">删除</el-button>第三次强调 (03 篇路由、05 篇表单之后): 前端藏按钮只是体验, 后端接口必须自己鉴权。
10. 测试策略
- Vitest 测纯逻辑: composable、Store、数据转换。
- Vue Test Utils 测组件行为: 传 props 后 emit 了什么、校验拦没拦住。
- Playwright 走端到端主流程: 登录 → 查询 → 新建 → 编辑 → 删除。
代码块收起展开
it('emits normalized query', async () => {
const wrapper = mount(TaskFilters)
await wrapper.get('[data-test=keyword]').setValue(' vue ')
await wrapper.get('form').trigger('submit')
expect(wrapper.emitted('search')?.[0]).toEqual([{ keyword: 'vue' }]) // 断言用户可见行为, 不抠内部变量
})定位元素用 data-test 属性, 别依赖随时会改的样式 class。
11. 交付前检查
代码块收起展开
npm run lint && npm run type-check && npm run test && npm run build四连绿之后再人肉过一遍: 直接刷新二级路由、换低权限账号、慢网、401/403/500、空数据、超长文本、手机宽度、狂点提交按钮、前进后退。性能问题的系统排查在性能优化篇。
12. 动手验收
- 完整跑通任务 CRUD: 表单、表格、分页、弹窗、日期控件全用上。
- 列表查询条件同步进 URL, 刷新后完整恢复。
- 模拟后端字段校验失败, 错误信息落到对应输入框。
- 模拟慢请求和乱序返回, 界面状态全程正确。
- 写 3 个组件/逻辑测试加 1 条端到端主流程。