Day 3|表格表单分页范式与 vue-request 最佳实践:从配置驱动到业务落地

1. 目标与产出

  • 掌握中后台表格/表单/分页的标准化开发范式。
  • 深入理解 vue-request 的最佳实践,包括请求缓存、轮询、防抖等高级特性。
  • 通过楼栋管理实战页面,串联 Day 1 请求层 + Day 2 守卫/指令的完整数据流。
  • 产出可复用的 useListuseFormuseExport 组合式函数与 SchemaForm 组件。

2. 核心技术栈选型

  • UI 组件库: Element Plus(表格、表单、分页、弹窗等基础组件)
  • 请求库: vue-request(基于 Vue 3 的请求管理库)
  • HTTP 客户端: axios + foca-axios(增强型 axios)
  • 表单方案: SchemaForm(配置驱动的动态表单)
  • 状态管理: Pinia(用户信息、权限状态)

依赖版本(参考项目 package.json):

json 复制代码
{
  "vue": "^3.5.26",
  "element-plus": "^2.13.6",
  "vue-request": "^2.0.4",
  "axios": "^1.13.2",
  "foca-axios": "^4.3.0",
  "pinia": "^3.0.4"
}

3. vue-request 核心特性与最佳实践

3.1 基础用法

vue-request 提供了简洁的 API 来管理异步请求状态:

ts 复制代码
import { useRequest } from 'vue-request'
import { amsAssetBuildingList } from '@/service/api/amsAsset'

// 基础用法
const { data, loading, error, runAsync } = useRequest(amsAssetBuildingList)

// 手动触发请求
const handleSearch = async () => {
  const result = await runAsync({ pageNum: 1, pageSize: 10 })
}

3.2 高级特性

3.2.1 请求缓存
ts 复制代码
// 缓存 5 分钟
const { data } = useRequest(getUserInfo, {
  cacheKey: 'user-info',
  cacheTime: 5 * 60 * 1000,
})
3.2.2 轮询
ts 复制代码
// 每 3 秒轮询一次
const { data } = useRequest(getSystemStatus, {
  pollingInterval: 3000,
})
3.2.3 防抖与节流
ts 复制代码
// 搜索框输入防抖 500ms
const { run: search } = useRequest(searchAPI, {
  debounceWait: 500,
})

// 按钮点击节流 1000ms
const { run: submit } = useRequest(submitAPI, {
  throttleWait: 1000,
})
3.2.4 依赖请求
ts 复制代码
// 当 userId 变化时自动重新请求
const { data } = useRequest(() => getUserDetail(userId.value), {
  refreshDeps: [userId],
})
3.2.5 错误重试
ts 复制代码
// 失败后自动重试 3 次,间隔 1 秒
const { data } = useRequest(getData, {
  retryCount: 3,
  retryDelay: 1000,
})

4. 表格开发范式

4.1 标准化表格页面结构

一个完整的表格页面应包含:

  • 筛选表单区
  • 操作按钮区(新增、导入、导出)
  • 数据表格区
  • 分页区

4.2 楼栋管理实战示例

基于项目的楼栋管理页面 (src/views/asset/building/building/index.vue):

vue 复制代码
<script setup lang="ts">
import { defineField, defineSchema } from '@/components'
import { onMounted, reactive, ref, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRequest } from 'vue-request'
import { useExport } from '@/common/hooks'
import { useCompanyOptions } from '@/utils/useSelectDict'
import {
  amsAssetBuildingDelete,
  amsAssetBuildingEnable,
  amsAssetBuildingList,
} from '@/service/api/amsAsset'

// 产权单位选项
const companyOptions = useCompanyOptions()

// 使用 vue-request 管理请求
const { runAsync: buildingList } = useRequest(amsAssetBuildingList)
const { runAsync: toggleStatusBuilding } = useRequest(amsAssetBuildingEnable)
const { runAsync: deleteBuilding } = useRequest(amsAssetBuildingDelete)

// 筛选表单状态
const formState = reactive({
  pageNum: 1,
  pageSize: 10,
} as AssetBuildingListDTO)

// 动态表单配置
const formSchema = defineSchema({
  fields: [
    defineField.Input({ label: '楼栋名称', prop: 'buildingName', clearable: true }),
    defineField.Input({ label: '楼栋编码', prop: 'buildingId', clearable: true }),
    defineField.Input({ label: '所属项目', prop: 'projectName', clearable: true }),
    defineField.Select({
      label: '产权单位',
      prop: 'ownershipUnitCode',
      options: companyOptions,
      props: {
        value: 'unifiedSocialCreditCode',
        label: 'companyName',
      },
      clearable: true,
      filterable: true,
      multiple: true,
    }),
    defineField.Select({
      label: '状态',
      prop: 'enable',
      options: [
        { value: '0', label: '禁用' },
        { value: '1', label: '启用' },
      ],
      clearable: true,
      filterable: true,
    }),
  ],
})

// 分页状态
const total = ref<number>(0)
const loading = ref<boolean>(false)
const tableData = reactive<AssetBuildingVO[]>([])

// 获取数据
const getData = async (): Promise<void> => {
  loading.value = true
  const params = getParams()
  const { total: resTotal, data } = await buildingList({ ...params })
  total.value = resTotal
  tableData.length = 0
  tableData.push(...data)
  loading.value = false
}

const getParams = () => {
  const cloneformState = { ...formState }
  return cloneformState
}

// 搜索
const handleSearch = () => {
  formState.pageNum = 1
  getData()
}

// 分页事件
const handleSizeChange = (val: number): void => {
  formState.pageSize = val
  getData()
}

const handleCurrentChange = (val: number): void => {
  formState.pageNum = val
  getData()
}

// 路由跳转
const router = useRouter()
const addBuilding = () => router.push('/asset/management/add-building')
const editBuilding = (buildingId: string) =>
  router.push(`/asset/management/edit-building/${buildingId}`)
const detailBuilding = (buildingId: string) =>
  router.push(`/asset/management/detail-building/${buildingId}`)

// 修改状态
const toggleStatus = async (buildingId: string, enable: number) => {
  await ElMessageBox.confirm(`是否确定${enable ? '停用' : '启用'}楼栋?`, '确认提示', {
    type: 'warning',
  })
  await toggleStatusBuilding({ buildingId, enable: !enable })
  ElMessage.success('修改成功')
  getData()
}

// 删除
const deleteData = async (buildingId: string) => {
  await ElMessageBox.confirm(`是否确定删除楼栋?`, '确认提示', { type: 'warning' })
  await deleteBuilding({ buildingId })
  ElMessage.success('删除成功')
  getData()
}

// 导入导出
const handleImport = () => {
  router.push('/asset/management/import-building')
}

const { exportData, loading: exportLoading } = useExport({
  meta: '/ams/asset-building/list-export-meta',
  url: '/ams/asset-building/list-export',
})

// 生命周期
onMounted(() => getData())
onActivated(() => getData())
</script>

<template>
  <section-group title="筛选查询">
    <schema-form
      :schema="formSchema"
      :model="formState"
      @finish="handleSearch"
      @reset="handleSearch"
    />
  </section-group>

  <section-group title="数据列表">
    <template #extra>
      <el-button type="primary" @click="addBuilding">新增楼栋</el-button>
      <el-button @click="handleImport">导入</el-button>
      <el-button @click="exportData(getParams())" :loading="exportLoading">导出</el-button>
    </template>

    <el-table v-loading="loading" :data="tableData" border>
      <el-table-column label="序号" type="index" width="55" fixed="left" />
      <el-table-column label="楼栋编码" prop="buildingId" />
      <el-table-column label="楼栋名称" prop="buildingName" />
      <el-table-column label="所属项目" prop="projectName" />
      <el-table-column label="项目类型" prop="projectTypeName" />
      <el-table-column label="产权单位" prop="ownershipUnitName" width="240" />
      <el-table-column label="状态" prop="enable" width="70">
        <template #default="{ row }">
          <el-switch
            :model-value="row.enable === 1"
            @change="toggleStatus(row.buildingId, row.enable)"
          />
        </template>
      </el-table-column>
      <el-table-column fixed="right" label="操作" width="180">
        <template #default="{ row }">
          <el-button link type="primary" @click="detailBuilding(row.buildingId)">
            查看详情
          </el-button>
          <el-button link type="primary" @click="editBuilding(row.buildingId)">编辑</el-button>
          <el-button link type="danger" @click="deleteData(row.buildingId)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <el-pagination
      class="mt-base float-right"
      background
      v-model:current-page="formState.pageNum"
      v-model:page-size="formState.pageSize"
      :total="total"
      layout="total, sizes, prev, pager, next, jumper"
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
    />
  </section-group>
</template>

4.3 表格开发最佳实践

4.3.1 数据管理
  • 使用 reactive 管理表格数据,避免直接赋值导致响应式丢失
  • 使用 tableData.length = 0 清空数组,然后 push 新数据
4.3.2 分页状态
  • pageNumpageSize 放入表单状态中,便于筛选时重置
  • 搜索时重置 pageNum = 1,确保从第一页开始
4.3.3 生命周期
  • onMounted: 首次加载数据
  • onActivated: keep-alive 缓存激活时重新加载(适用于 tab 切换场景)
4.3.4 权限控制

结合 Day 2 的权限指令:

vue 复制代码
<el-button v-perm="'building:add'" type="primary" @click="addBuilding">
  新增楼栋
</el-button>
<el-button v-perm="'building:delete'" link type="danger" @click="deleteData(row.buildingId)">
  删除
</el-button>

5. 表单开发范式

5.1 SchemaForm 配置驱动表单

项目中的 SchemaForm 组件通过配置驱动表单生成:

ts 复制代码
const formSchema = defineSchema({
  fields: [
    defineField.Input({ label: '楼栋名称', prop: 'buildingName', clearable: true }),
    defineField.Select({
      label: '产权单位',
      prop: 'ownershipUnitCode',
      options: companyOptions,
      props: {
        value: 'unifiedSocialCreditCode',
        label: 'companyName',
      },
      clearable: true,
      filterable: true,
      multiple: true,
    }),
  ],
})

5.2 useForm 组合式函数

项目中的 useForm 提供表单状态管理和重置功能:

ts 复制代码
import { useForm } from '@/common/hooks'

const modelFormRef = useTemplateRef('modelFormRef')
const [form, resetForm] = useForm(
  { dicType: 1001, dicLevel: 1 } as SysDicUpsertDTO,
  modelFormRef
)

// 重置表单
resetForm()

5.3 表单验证

使用 Element Plus 的表单验证规则:

ts 复制代码
const rules = {
  buildingName: [
    { required: true, message: '请输入楼栋名称', trigger: 'blur' },
    { min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
  ],
  buildingId: [
    { required: true, message: '请输入楼栋编码', trigger: 'blur' },
    { pattern: /^[A-Z0-9]+$/, message: '只能输入大写字母和数字', trigger: 'blur' },
  ],
}

6. 分页开发范式

6.1 标准分页组件

vue 复制代码
<el-pagination
  class="mt-base float-right"
  background
  v-model:current-page="formState.pageNum"
  v-model:page-size="formState.pageSize"
  :total="total"
  :page-sizes="[10, 20, 50, 100]"
  layout="total, sizes, prev, pager, next, jumper"
  @size-change="handleSizeChange"
  @current-change="handleCurrentChange"
/>

6.2 分页事件处理

ts 复制代码
const handleSizeChange = (val: number): void => {
  formState.pageSize = val
  getData()
}

const handleCurrentChange = (val: number): void => {
  formState.pageNum = val
  getData()
}

6.3 分页参数传递

确保后端接口接收正确的分页参数:

ts 复制代码
const getParams = () => {
  const cloneformState = { ...formState }
  return cloneformState
}

const getData = async (): Promise<void> => {
  loading.value = true
  const params = getParams()
  const { total: resTotal, data } = await buildingList({ ...params })
  total.value = resTotal
  tableData.length = 0
  tableData.push(...data)
  loading.value = false
}

7. 导入导出功能

7.1 导出功能

项目中的 useExport hook 封装导出逻辑:

ts 复制代码
const { exportData, loading: exportLoading } = useExport({
  meta: '/ams/asset-building/list-export-meta',
  url: '/ams/asset-building/list-export',
})

// 调用导出
const handleExport = () => {
  exportData(getParams())
}

7.2 导入功能

导入通常跳转到专门的导入页面:

ts 复制代码
const handleImport = () => {
  router.push('/asset/management/import-building')
}

8. 完整数据流图

#mermaid-svg-lDVoKKwwPNwdk4n2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lDVoKKwwPNwdk4n2 .error-icon{fill:#552222;}#mermaid-svg-lDVoKKwwPNwdk4n2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lDVoKKwwPNwdk4n2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .marker.cross{stroke:#333333;}#mermaid-svg-lDVoKKwwPNwdk4n2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lDVoKKwwPNwdk4n2 p{margin:0;}#mermaid-svg-lDVoKKwwPNwdk4n2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster-label text{fill:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster-label span{color:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster-label span p{background-color:transparent;}#mermaid-svg-lDVoKKwwPNwdk4n2 .label text,#mermaid-svg-lDVoKKwwPNwdk4n2 span{fill:#333;color:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .node rect,#mermaid-svg-lDVoKKwwPNwdk4n2 .node circle,#mermaid-svg-lDVoKKwwPNwdk4n2 .node ellipse,#mermaid-svg-lDVoKKwwPNwdk4n2 .node polygon,#mermaid-svg-lDVoKKwwPNwdk4n2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .rough-node .label text,#mermaid-svg-lDVoKKwwPNwdk4n2 .node .label text,#mermaid-svg-lDVoKKwwPNwdk4n2 .image-shape .label,#mermaid-svg-lDVoKKwwPNwdk4n2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-lDVoKKwwPNwdk4n2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .rough-node .label,#mermaid-svg-lDVoKKwwPNwdk4n2 .node .label,#mermaid-svg-lDVoKKwwPNwdk4n2 .image-shape .label,#mermaid-svg-lDVoKKwwPNwdk4n2 .icon-shape .label{text-align:center;}#mermaid-svg-lDVoKKwwPNwdk4n2 .node.clickable{cursor:pointer;}#mermaid-svg-lDVoKKwwPNwdk4n2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .arrowheadPath{fill:#333333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lDVoKKwwPNwdk4n2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lDVoKKwwPNwdk4n2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lDVoKKwwPNwdk4n2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster text{fill:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 .cluster span{color:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lDVoKKwwPNwdk4n2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lDVoKKwwPNwdk4n2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-lDVoKKwwPNwdk4n2 .icon-shape,#mermaid-svg-lDVoKKwwPNwdk4n2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lDVoKKwwPNwdk4n2 .icon-shape p,#mermaid-svg-lDVoKKwwPNwdk4n2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lDVoKKwwPNwdk4n2 .icon-shape .label rect,#mermaid-svg-lDVoKKwwPNwdk4n2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lDVoKKwwPNwdk4n2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lDVoKKwwPNwdk4n2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lDVoKKwwPNwdk4n2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户操作
表单筛选
构建请求参数
vue-request 发起请求
axios 拦截器
添加 token/签名
发送 HTTP 请求
后端接口
返回数据
axios 响应拦截器
解密/错误处理
vue-request 处理响应
更新表格数据
更新分页状态
渲染表格


9. 性能优化建议

9.1 请求优化

  • 使用 vue-request 的缓存功能减少重复请求
  • 防抖处理搜索输入,避免频繁请求
  • 轮询场景使用 pollingInterval 替代 setInterval

9.2 渲染优化

  • 大数据量表格使用虚拟滚动(Element Plus 的 virtual-scroll
  • 复杂列使用 v-if 按需渲染
  • 图片懒加载

9.3 状态优化

  • 使用 shallowRef / shallowReactive 减少响应式开销
  • 合理使用 computed 缓存计算结果

10. 测试建议

10.1 单元测试

ts 复制代码
import { describe, it, expect, vi } from 'vitest'
import { useRequest } from 'vue-request'

describe('useRequest', () => {
  it('should fetch data successfully', async () => {
    const mockAPI = vi.fn().mockResolvedValue({ data: [] })
    const { data, runAsync } = useRequest(mockAPI)
    
    await runAsync()
    expect(mockAPI).toHaveBeenCalled()
    expect(data.value).toEqual({ data: [] })
  })
})

10.2 E2E 测试

ts 复制代码
import { test, expect } from '@playwright/test'

test('table pagination', async ({ page }) => {
  await page.goto('/asset/management/building')
  
  // 等待表格加载
  await page.waitForSelector('.el-table')
  
  // 测试分页
  await page.click('.el-pagination button:has-text("下一页")')
  await expect(page.locator('.el-pagination')).toContainText('2')
})

11. 常见问题与解决方案

11.1 表格数据不更新

问题 : 修改数据后表格不刷新

解决 : 确保使用 tableData.length = 0 清空后再 push,或直接赋值 Object.assign(tableData, newData)

11.2 分页重置问题

问题 : 搜索后分页没有重置到第一页

解决 : 搜索时显式设置 formState.pageNum = 1

11.3 表单验证不生效

问题 : 表单提交时验证不触发

解决 : 确保表单组件有正确的 prop 属性,且 rules 正确配置

11.4 导出文件名乱码

问题 : 导出的文件名中文乱码

解决 : 使用 encodeURI 对文件名进行编码


12. 总结与明日预告

12.1 今日总结

  • 掌握了 vue-request 的核心特性和最佳实践
  • 实现了标准化的表格/表单/分页开发范式
  • 通过楼栋管理页面串联了完整的数据流
  • 了解了导入导出、权限控制的集成方式

12.2 明日预告

明日将讲解高级组件开发与状态管理优化,包括:

  • 自定义复杂组件(树形选择器、级联选择器等)
  • Pinia 状态管理最佳实践
  • 组件通信与事件总线
  • 性能监控与错误处理

13. 参考资源

相关推荐
ZengLiangYi11 小时前
任务队列设计:p-queue 限速 + 重试策略
前端·javascript·后端
sugar__salt11 小时前
从零吃透 ES6 核心:变量声明、作用域、变量提升与坑点
前端·javascript·ecmascript·es6
罗超驿11 小时前
1.HTML基础入门:标签、属性与路径详解(VSCode开发环境)
前端·vscode·html
Dante丶11 小时前
Codex Desktop 不断 Reconnecting 的代理环境变量处理
前端·后端·代码规范
Asmewill11 小时前
LangGraph学习笔记五(Command+Send+Runtime)
前端
代码搬运媛11 小时前
【前端必知】浏览器原生 API 底层机制详解
前端
咔咔库奇11 小时前
js-执行上下文
开发语言·前端·javascript
咪饭只吃一小碗11 小时前
JS 打工记:同步搬砖、异步摸鱼,Promise 来救场
前端·javascript·面试
用户7138742290011 小时前
彻底搞懂浏览器客户端存储:从 localStorage 到完整存储体系
前端