Vue3 配置驱动表格:列配置/操作配置/分页配置,统一表格渲染|配置驱动开发实战篇

Vue3 + TypeScript + Element Plus】×【中后台列表页 / 表格页】:从「columns / actions / pagination 三块配置抽象」到「ConfigTable 统一渲染 + 页面级分页双向绑定」,彻底搞懂配置驱动表格 的工程化写法,避开行 key 不稳、formatter 塞业务、改 pageSize 不回第一页、权限写死模板与配置散落等高频坑!

📑 文章目录

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)

当你能写出规范、可维护的代码后,下一个真正的瓶颈,就是架构

面对大型项目、复杂业务,你是否也会遇到:组件越写越乱、重复开发越来越多;需求一变全链路改动;不知道怎么分层、怎么抽象、怎么设计才能支撑长期迭代;想晋升、想带项目,却缺少架构思维

这一系列《前端组件化与架构实战》,我会继续用大白话 + 真实业务场景 ,不讲玄学、不啃晦涩源码,只教你能落地、能抗复杂项目的架构思路。

帮你从「写页面的开发者」,真正升级为「能做架构、能带项目、能搞定复杂需求的前端工程师」。


一、这篇文章解决什么问题?

很多项目里,表格页面都长这样:

  • 页面 A 有表格、分页、操作按钮
  • 页面 B 也有,基本一样
  • 页面 C 还要再复制一份,然后改几个字段

最后就会出现:

  • 重复代码越来越多
  • 字段改名要改 10 个页面
  • 新人接手成本高
  • 功能迭代慢,bug 难查

配置驱动开发 要解决的就是:

把"写死在模板里的规则"提炼成"配置",让渲染逻辑统一、行为可控、维护成本更低。

这篇我们只讲实战,不讲玄学。核心是三块配置:

  1. 列配置(columns)
  2. 行操作配置(actions)
  3. 分页配置(pagination)

[⬆ 返回目录](#⬆ 返回目录)


二、先把概念讲人话:什么叫"配置驱动"?

传统写法(命令式):

  • 我在模板里一个个写 <th><td>、按钮和分页
  • 业务改了,我就改模板和方法

配置驱动写法(声明式):

  • 我先定义"这张表长什么样":列有哪些、按钮有哪些、分页怎么走
  • 表格组件只负责"读配置并渲染"
  • 页面只管传数据和处理事件

一句话:把"怎么画"交给组件,把"画什么"交给配置

[⬆ 返回目录](#⬆ 返回目录)


三、最终效果(你会得到什么)

我们将做一个 ConfigTable.vue,支持:

  • 列配置:文本列、格式化列、插槽列
  • 操作配置:查看/编辑/删除,支持显示条件和禁用条件
  • 分页配置:页码/页大小/总数,统一 v-model 双向绑定
  • 事件透出:actionpage-changesize-change
  • 可读性优先:类型清晰、命名统一、默认值完整

[⬆ 返回目录](#⬆ 返回目录)


四、项目技术栈说明

  • Vue 3 + <script setup> + TypeScript
  • UI 库:Element Plus(企业项目常用,学习成本低)

你也可以替换成 Ant Design Vue 或自己封装组件,思路一样。

[⬆ 返回目录](#⬆ 返回目录)


五、核心类型设计(先把"规则"定清楚)

新建 types/table.ts

ts 复制代码
import type { VNodeChild } from 'vue'

export interface TableColumnConfig<Row = Record<string, any>> {
  key: string
  label: string
  width?: number | string
  minWidth?: number | string
  align?: 'left' | 'center' | 'right'
  // 普通字段取值,比如 row.name
  prop?: keyof Row | string
  // 自定义渲染(优先级高于 prop)
  formatter?: (row: Row, index: number) => VNodeChild | string | number
  // 是否使用具名插槽渲染
  slot?: string
}

export interface TableActionConfig<Row = Record<string, any>> {
  key: string
  label: string
  type?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
  text?: boolean
  // 是否展示(例如:只有管理员可见)
  visible?: (row: Row, index: number) => boolean
  // 是否禁用(例如:已锁定不可删除)
  disabled?: (row: Row, index: number) => boolean
  // 二次确认文案(可选)
  confirmText?: string
}

export interface TablePaginationConfig {
  page: number
  pageSize: number
  total: number
  pageSizes?: number[]
  layout?: string
}

export interface ConfigTableProps<Row = Record<string, any>> {
  loading?: boolean
  columns: TableColumnConfig<Row>[]
  data: Row[]
  actions?: TableActionConfig<Row>[]
  rowKey?: string | ((row: Row) => string | number)
  pagination?: TablePaginationConfig
}

为什么先写类型?

因为类型就是你的"规范文档":

  • 新人一看就知道这张表支持什么能力
  • 配置传错,编辑器立即提示
  • 避免"靠约定、靠记忆"导致线上踩坑

[⬆ 返回目录](#⬆ 返回目录)


六、通用表格组件实现(重点)

新建 components/ConfigTable.vue

html 复制代码
<template>
  <div class="config-table">
    <el-table
      :data="data"
      :loading="loading"
      :row-key="rowKey"
      border
      style="width: 100%"
    >
      <!-- 动态列渲染 -->
      <el-table-column
        v-for="(col, colIndex) in columns"
        :key="col.key || colIndex"
        :label="col.label"
        :prop="col.prop as string"
        :width="col.width"
        :min-width="col.minWidth"
        :align="col.align || 'left'"
      >
        <template #default="{ row, $index }">
          <!-- 1) 插槽优先 -->
          <slot
            v-if="col.slot"
            :name="col.slot"
            :row="row"
            :index="$index"
          />
          <!-- 2) formatter 次之 -->
          <template v-else-if="col.formatter">
            {{ col.formatter(row, $index) }}
          </template>
          <!-- 3) 最后才是 prop -->
          <template v-else>
            {{ getCellValue(row, col.prop as string) }}
          </template>
        </template>
      </el-table-column>

      <!-- 操作列 -->
      <el-table-column
        v-if="actions && actions.length"
        label="操作"
        fixed="right"
        :width="actionColumnWidth"
        align="center"
      >
        <template #default="{ row, $index }">
          <template v-for="action in actions" :key="action.key">
            <el-popconfirm
              v-if="shouldShowAction(action, row, $index) && action.confirmText"
              :title="action.confirmText"
              @confirm="handleAction(action, row, $index)"
            >
              <template #reference>
                <el-button
                  :type="action.type || 'primary'"
                  :text="action.text ?? true"
                  :disabled="isActionDisabled(action, row, $index)"
                >
                  {{ action.label }}
                </el-button>
              </template>
            </el-popconfirm>

            <el-button
              v-else-if="shouldShowAction(action, row, $index)"
              :type="action.type || 'primary'"
              :text="action.text ?? true"
              :disabled="isActionDisabled(action, row, $index)"
              @click="handleAction(action, row, $index)"
            >
              {{ action.label }}
            </el-button>
          </template>
        </template>
      </el-table-column>
    </el-table>

    <!-- 分页 -->
    <div v-if="pagination" class="pager-wrap">
      <el-pagination
        background
        :current-page="pagination.page"
        :page-size="pagination.pageSize"
        :total="pagination.total"
        :page-sizes="pagination.pageSizes || [10, 20, 50, 100]"
        :layout="
          pagination.layout ||
          'total, sizes, prev, pager, next, jumper'
        "
        @current-change="onCurrentChange"
        @size-change="onSizeChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import type {
  ConfigTableProps,
  TableActionConfig
} from '@/types/table'

type RowData = Record<string, any>

const props = withDefaults(defineProps<ConfigTableProps<RowData>>(), {
  loading: false,
  actions: () => [],
  rowKey: 'id'
})

const emit = defineEmits<{
  (e: 'action', payload: { actionKey: string; row: RowData; index: number }): void
  (e: 'update:pagination', val: {
    page: number
    pageSize: number
    total: number
    pageSizes?: number[]
    layout?: string
  }): void
  (e: 'page-change', page: number): void
  (e: 'size-change', pageSize: number): void
}>()

const actionColumnWidth = computed(() => {
  // 简单估算宽度,避免按钮挤压换行
  const count = props.actions?.length || 0
  return Math.max(120, count * 70)
})

function getCellValue(row: RowData, prop?: string) {
  if (!prop) return '--'
  const value = row[prop]
  return value === null || value === undefined || value === '' ? '--' : value
}

function shouldShowAction(
  action: TableActionConfig<RowData>,
  row: RowData,
  index: number
) {
  return action.visible ? action.visible(row, index) : true
}

function isActionDisabled(
  action: TableActionConfig<RowData>,
  row: RowData,
  index: number
) {
  return action.disabled ? action.disabled(row, index) : false
}

function handleAction(
  action: TableActionConfig<RowData>,
  row: RowData,
  index: number
) {
  emit('action', { actionKey: action.key, row, index })
}

function onCurrentChange(page: number) {
  if (!props.pagination) return
  emit('update:pagination', { ...props.pagination, page })
  emit('page-change', page)
}

function onSizeChange(pageSize: number) {
  if (!props.pagination) return
  // 常见约定:修改 pageSize 后回到第一页
  emit('update:pagination', { ...props.pagination, pageSize, page: 1 })
  emit('size-change', pageSize)
}
</script>

<style scoped>
.config-table {
  width: 100%;
}
.pager-wrap {
  margin-top: 16px;
  display: flex;
  justify-content: flex-end;
}
</style>

[⬆ 返回目录](#⬆ 返回目录)


七、页面如何使用(完整示例)

新建 views/UserList.vue

html 复制代码
<template>
  <div class="page">
    <el-card shadow="never">
      <template #header>
        <div class="header">用户列表(配置驱动示例)</div>
      </template>

      <ConfigTable
        v-model:pagination="pagination"
        :loading="loading"
        :columns="columns"
        :data="tableData"
        :actions="actions"
        row-key="id"
        @action="onTableAction"
        @page-change="fetchList"
        @size-change="fetchList"
      >
        <!-- 插槽列示例 -->
        <template #statusSlot="{ row }">
          <el-tag :type="row.status === 'enabled' ? 'success' : 'info'">
            {{ row.status === 'enabled' ? '启用' : '禁用' }}
          </el-tag>
        </template>
      </ConfigTable>
    </el-card>
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import ConfigTable from '@/components/ConfigTable.vue'
import type {
  TableActionConfig,
  TableColumnConfig,
  TablePaginationConfig
} from '@/types/table'
import { ElMessage, ElMessageBox } from 'element-plus'

interface UserItem {
  id: number
  name: string
  age: number
  role: 'admin' | 'user'
  status: 'enabled' | 'disabled'
  createdAt: string
}

const loading = ref(false)
const tableData = ref<UserItem[]>([])

const pagination = ref<TablePaginationConfig>({
  page: 1,
  pageSize: 10,
  total: 0
})

const columns: TableColumnConfig<UserItem>[] = [
  { key: 'name', label: '姓名', prop: 'name', minWidth: 120 },
  { key: 'age', label: '年龄', prop: 'age', width: 80, align: 'center' },
  {
    key: 'role',
    label: '角色',
    prop: 'role',
    minWidth: 100,
    formatter: (row) => (row.role === 'admin' ? '管理员' : '普通用户')
  },
  {
    key: 'status',
    label: '状态',
    slot: 'statusSlot',
    minWidth: 120,
    align: 'center'
  },
  { key: 'createdAt', label: '创建时间', prop: 'createdAt', minWidth: 180 }
]

const actions: TableActionConfig<UserItem>[] = [
  { key: 'view', label: '查看', type: 'primary' },
  { key: 'edit', label: '编辑', type: 'warning' },
  {
    key: 'delete',
    label: '删除',
    type: 'danger',
    confirmText: '确定要删除该用户吗?',
    // 管理员不允许删除,作为禁用示例
    disabled: (row) => row.role === 'admin'
  }
]

async function fetchList() {
  loading.value = true
  try {
    // 这里模拟接口请求
    const { page, pageSize } = pagination.value
    const total = 35
    const list: UserItem[] = Array.from({ length: pageSize }).map((_, i) => {
      const id = (page - 1) * pageSize + i + 1
      return {
        id,
        name: `用户${id}`,
        age: 18 + (id % 10),
        role: id % 7 === 0 ? 'admin' : 'user',
        status: id % 3 === 0 ? 'disabled' : 'enabled',
        createdAt: '2026-03-26 10:00:00'
      }
    })

    tableData.value = list
    pagination.value.total = total
  } finally {
    loading.value = false
  }
}

async function onTableAction(payload: {
  actionKey: string
  row: UserItem
  index: number
}) {
  const { actionKey, row } = payload

  if (actionKey === 'view') {
    ElMessage.info(`查看用户:${row.name}`)
    return
  }

  if (actionKey === 'edit') {
    ElMessage.success(`编辑用户:${row.name}`)
    return
  }

  if (actionKey === 'delete') {
    await ElMessageBox.alert(`已删除用户:${row.name}`, '提示')
    fetchList()
  }
}

onMounted(() => {
  fetchList()
})
</script>

<style scoped>
.page {
  padding: 16px;
}
.header {
  font-weight: 600;
}
</style>

[⬆ 返回目录](#⬆ 返回目录)


八、为什么这样设计?(你真正需要建立的习惯)

1)列配置里为什么有 propformatterslot 三层?

  • prop:最基础、最快速,适合简单字段展示
  • formatter:轻量格式化,适合"男女/状态文案/日期字符串"
  • slot:复杂 UI,适合标签、头像、进度条、组合内容

推荐优先级prop > formatter > slot(按复杂度升级)

不要一上来全用 slot,会让模板过重。

[⬆ 返回目录](#⬆ 返回目录)


2)为什么操作配置要有 visibledisabled

因为这两个语义不同:

  • visible:是否显示(权限维度)
  • disabled:显示但不可点(状态维度)

很多项目把两者混在一起,最后业务逻辑很乱。分清语义,是可维护性的关键。

[⬆ 返回目录](#⬆ 返回目录)


3)分页为什么要统一 v-model:pagination

因为分页状态通常是"页面级状态":

  • 请求参数依赖它
  • 搜索条件重置依赖它
  • 路由缓存恢复依赖它

把它统一双向绑定,比组件内私有状态更可控,也更容易做"列表页状态记忆"。

[⬆ 返回目录](#⬆ 返回目录)


九、常见坑位(高频踩坑清单)

坑 1:key 用 index 导致行错乱

  • key 必须稳定,优先后端 id
  • 分页切换、排序、筛选时最容易出问题

[⬆ 返回目录](#⬆ 返回目录)


坑 2:formatter 里写太重逻辑

  • formatter 只做展示转换,不做异步、不改状态
  • 复杂逻辑放到组合函数/composable 里

[⬆ 返回目录](#⬆ 返回目录)


坑 3:分页改 pageSize 不重置页码

  • 常见 bug:从第 10 页改为 20 条后请求空数据
  • 规范:改页大小后 page = 1

[⬆ 返回目录](#⬆ 返回目录)


坑 4:操作列权限写死在模板里

  • 后续改权限体系时很痛苦
  • 建议统一写在 actions 配置中,逻辑集中可复用

[⬆ 返回目录](#⬆ 返回目录)


坑 5:配置对象散落各处

  • columnsactions 最好按页面维度统一管理
  • 长期建议抽到 xxx.table-config.ts,方便复用和测试

[⬆ 返回目录](#⬆ 返回目录)


十、进阶建议(你可以继续演进)

  • 支持列 sortablefilters,把筛选排序也配置化
  • 支持"表格工具栏配置"(新增/导出/批量操作)
  • 支持"远程分页 + 查询表单"一体化封装
  • 配合路由 query 做分页与筛选状态回填
  • 给配置加单元测试(特别是 visible / disabled 条件函数)

[⬆ 返回目录](#⬆ 返回目录)


十一、给前端的"习惯校准建议"

如果你已经写了很多年业务代码,这里有三个非常实用的校准点:

  1. 少写重复模板,多写结构化配置
  2. 少靠"记忆约定",多靠类型约束
  3. 少在组件里塞业务细节,多做事件透出和职责分离

这不是为了"炫技",而是为了你半年后回来看代码,还能快速改、放心改。

[⬆ 返回目录](#⬆ 返回目录)


十二、总结

配置驱动并不神秘,本质就是一句话:
把变化点变成配置,把稳定逻辑变成组件。

当你把"列、操作、分页"这三块统一起来,表格页会从"复制粘贴地狱"走向"可维护工程化"。

先把这套用在 1~2 个真实页面上,你会马上感受到收益。

[⬆ 返回目录](#⬆ 返回目录)


🔍 系列模块导航

📝 配置驱动开发实战

持续更新中,敬请期待~

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~

[⬆ 返回目录](#⬆ 返回目录)

📚 系列总览

前端体系化学习完全体:基础 → 规范 → 架构 → 大厂面试

四套系列、百余篇高质量实战文,从入门到进阶,一站式补齐前端核心能力

每个系列完结后,都会整理成一篇完整导航文并附上直达链接,方便大家按顺序、体系化学习。

全套内容持续更新中,敬请期待~

[⬆ 返回目录](#⬆ 返回目录)


前端的成长路径很清晰:

会写代码 → 写规范代码 → 做可扩展架构。

每一步,都是职业晋升的关键台阶。

后续我会持续输出组件化、配置驱动、权限架构、工程化、复杂业务实战干货,帮你真正建立架构思维,在工作与面试中更有竞争力。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇硬核内容。

我是 Eugene,与你一起从业务走向架构,搞定复杂项目,我们下篇干货见~

相关推荐
weixin_471383032 小时前
[特殊字符] React Flow 从入门到理解
开发语言·前端·javascript
三翼鸟数字化技术团队2 小时前
前端水印实现方案
前端
ZHENGZJM2 小时前
Server-Sent Events (SSE) 接口实现
架构·go·gin
IT_陈寒2 小时前
SpringBoot自动配置的坑把我埋了半小时
前端·人工智能·后端
专注VB编程开发20年2 小时前
VBA/VB6连接、读取Mdb access数据库最快的方法
前端·ui·ado·vb6
CodeCxil2 小时前
基于Vue的在线Online Word文档编辑器
vue.js·编辑器·word
W.A委员会2 小时前
大数据渲染
前端
五仁火烧2 小时前
前端不传文件,也能用 multipart/form-data
前端·javascript·vue.js·node.js
五仁火烧2 小时前
前端最常用的两种请求数据格式application/json 和 multipart/form-data 完全解析
前端·javascript·vue.js·json