工程化、请求层与常用控件

Vue 工程化、请求层与常用控件

00 到 04 攒齐了零件, 这篇装整车: 一条真实后台项目的完整 CRUD 链路。所谓真实, 意思是每一步都要处理 Loading、空数据、失败重试、重复点击、权限这些教程里从来不写但上线必炸的东西。这也是我离”能写玩具”和”能写项目”差距最大的一篇。

flowchart LR
    Q[查询条件] --> L[加载列表]
    L --> T[表格]
    T --> E[编辑表单]
    E --> V[校验]
    V --> S[提交]
    S --> R[刷新或回写]

1. Vite 环境与别名

代码块TS · 19 行收起展开
// 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:

代码块TS · 22 行收起展开
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 分流处理:

代码块TS · 6 行收起展开
export interface AppError extends Error {
  kind: 'network' | 'timeout' | 'unauthorized' | 'validation' | 'server'
  status?: number
  fieldErrors?: Record<string, string>   // 后端字段校验错误, 表单要用
  requestId?: string                     // 排查问题时和后端对暗号用
}

4. useRequest: 把”请求三态”封装掉

每个请求都要 data/loading/error 三个变量加一套竞态处理, 抽成组合式函数一劳永逸:

代码块TS · 24 行收起展开
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 全家桶把校验、布局、错误展示都包了:

代码块VUE · 53 行收起展开
<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. 表格、分页与筛选

代码块VUE · 27 行收起展开
<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. 弹窗与确认

代码块VUE · 9 行收起展开
<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 走端到端主流程: 登录 → 查询 → 新建 → 编辑 → 删除。
代码块TS · 6 行收起展开
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. 交付前检查

代码块BASH · 1 行收起展开
npm run lint && npm run type-check && npm run test && npm run build

四连绿之后再人肉过一遍: 直接刷新二级路由、换低权限账号、慢网、401/403/500、空数据、超长文本、手机宽度、狂点提交按钮、前进后退。性能问题的系统排查在性能优化篇

12. 动手验收

  • 完整跑通任务 CRUD: 表单、表格、分页、弹窗、日期控件全用上。
  • 列表查询条件同步进 URL, 刷新后完整恢复。
  • 模拟后端字段校验失败, 错误信息落到对应输入框。
  • 模拟慢请求和乱序返回, 界面状态全程正确。
  • 写 3 个组件/逻辑测试加 1 条端到端主流程。

延伸阅读