Vue3 表格封装实战:列配置 + slot 扩展 + 请求生命周期|Vue生态精选篇

2 阅读9分钟

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

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

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、开篇

如果你对 slot 或列配置还不熟,建议先看:

本篇会围绕「列配置 + slot 扩展 + 请求生命周期」三块,讲清楚:

  • 表格组件的设计思路
  • 如何用配置驱动列渲染
  • 如何用 slot 做灵活扩展
  • 如何把请求、loading、分页、错误统一收进组件

不追求底层原理,重点放在「怎么写、为什么这么写、容易踩什么坑」。

二、为什么要自己封装表格组件?

Element Plus、Ant Design Vue 的表格都很成熟,但业务里常遇到这些问题:

问题痛点
每页都写 el-table-column代码重复,改一处要改很多地方
自定义列写法不统一有的用 slot,有的写 formatter,风格混乱
请求逻辑散落各处loading、错误、分页分散在页面,难以复用
分页、筛选、刷新逻辑相似每个列表页都要重写一套

封装一套表格组件,可以:

  • 列配置统一管理列,方便维护和扩展
  • slot 解决头像、操作按钮等自定义列
  • 在组件内部统一处理 请求生命周期:loading、错误、分页、刷新

下面按「列配置 → slot 扩展 → 请求生命周期」的顺序展开。

三、第一步:列配置设计

3.1 为什么用配置数组,而不是写死 el-table-column?

硬编码写法:

<el-table :data="tableData">
  <el-table-column prop="name" label="姓名" width="120" />
  <el-table-column prop="age" label="年龄" width="80" />
  <el-table-column prop="email" label="邮箱" min-width="180" />
</el-table>

问题在于:不同页面列不同、顺序不同,每次都要改模板,扩展性差。

列配置的好处:

  • 一份配置驱动多张表
  • 列顺序、显隐、格式可以动态控制
  • 容易和后台「列配置」或「动态列」对接

3.2 列配置的数据结构

定义列配置类型(TypeScript 项目可保留,纯 JS 可去掉类型):

/** 列配置项 */
export interface ColumnConfig {
  prop: string                    // 数据字段名
  label: string                   // 表头
  width?: string                  // 列宽,如 '120px'
  minWidth?: string               // 最小宽度
  slot?: string                   // 插槽名,有此字段则走 slot 渲染
  formatter?: (row: any) => string | number  // 格式化函数
  align?: 'left' | 'center' | 'right'
  fixed?: 'left' | 'right'        // 固定列
}

使用示例:

const columns = [
  { prop: 'name', label: '姓名', width: '120px' },
  { prop: 'age', label: '年龄', width: '80px', align: 'center' },
  { prop: 'avatar', label: '头像', width: '80px', slot: 'avatar' },
  { prop: 'status', label: '状态', width: '100px', slot: 'status' },
  { 
    prop: 'createdAt', 
    label: '创建时间', 
    width: '160px',
    formatter: (row) => row.createdAt ? row.createdAt.slice(0, 10) : '-'
  },
  { prop: 'action', label: '操作', width: '180px', slot: 'action', fixed: 'right' }
]

3.3 根据配置渲染列

在表格组件里用 v-for 遍历 columns

<el-table :data="tableData" v-loading="loading" border>
  <el-table-column
    v-for="col in columns"
    :key="col.prop"
    :prop="col.prop"
    :label="col.label"
    :width="col.width"
    :min-width="col.minWidth"
    :align="col.align || 'left'"
    :fixed="col.fixed"
  >
    <!-- 情况1:配置了 slot,用插槽渲染 -->
    <template v-if="col.slot" #default="{ row }">
      <slot :name="col.slot" :row="row">
        {{ row[col.prop] }}
      </slot>
    </template>
    <!-- 情况2:配置了 formatter,用格式化结果 -->
    <template v-else-if="col.formatter" #default="{ row }">
      {{ col.formatter(row) }}
    </template>
    <!-- 情况3:默认直接显示 prop 对应字段 -->
  </el-table-column>
</el-table>

要点:

  • v-for="col in columns":用配置驱动列
  • col.slot 时用 <slot :name="col.slot">,实现按配置选择插槽
  • 兜底:没提供 slot 时显示 row[col.prop]

3.4 踩坑:为什么 slot 要写 :name 而不是 name

错误写法:

<slot name="avatar" :row="row" />

这样只能匹配名为 avatar 的插槽,col.slot 可能是 avataraction 等任意字符串。

正确写法:

<slot :name="col.slot" :row="row" />

:name 是动态绑定,col.slot 才能正确生效。

四、第二步:Slot 扩展

4.1 Slot 在这里的作用(简要回顾)

Slot 是父组件往子组件「占位符」里插入内容的方式。作用域插槽则允许子组件把数据(如当前行 row)传给父组件。

表格场景:某些列需要头像、标签、按钮等自定义内容,无法用 propformatter 完成,就需要 slot。

4.2 作用域插槽:如何把 row 传给父组件?

子组件(BaseTable)里:

<slot :name="col.slot" :row="row" />

父组件使用:

<BaseTable :columns="columns" :fetch="fetchList" :query="searchForm">
  <template #avatar="{ row }">
    <el-avatar :src="row.avatar" :size="32" />
  </template>
  <template #action="{ row }">
    <el-button size="small" @click="handleEdit(row)">编辑</el-button>
    <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
  </template>
</BaseTable>

#avatarv-slot:avatar 的简写,{ row } 是子组件通过 :row="row" 传上来的插槽 props。

4.3 兜底:父组件没有提供 slot 时

如果列配置里写了 slot: 'avatar',但父组件没写 #avatar,默认会显示空白。可以在 slot 里加兜底内容:

<slot :name="col.slot" :row="row">
  {{ row[col.prop] }}
</slot>

这样未提供插槽时,会显示原始字段值。

4.4 传递更多上下文:index、column 等

有些场景需要行下标、列配置等,可以一起传:

<slot 
  :name="col.slot" 
  :row="row" 
  :column="col" 
  :index="index"
/>

父组件:

<template #action="{ row, column, index }">
  <el-button @click="handleEdit(row, index)">编辑</el-button>
</template>

五、第三步:请求生命周期

5.1 什么叫「请求生命周期」

这里指的是「发起请求 → loading → 成功/失败 → 刷新」的完整流程。我们希望:

  • 进入页面自动请求
  • 分页、筛选、排序时自动重新请求
  • loading、错误、空状态统一处理
  • 对外暴露 refresh(),支持手动刷新

5.2 设计思路

组件接收:

  • fetch(query, page, pageSize):返回 Promise<{ list, total }>
  • query:查询条件对象
  • pageSize:每页条数(可选)

组件内部维护:

  • tableDatatotalloadingerror
  • currentPage
  • loadData():执行请求
  • refresh():重置页码并重新请求,通过 defineExpose 暴露

5.3 完整请求逻辑代码

// BaseTable.vue - script 部分

const props = defineProps({
  columns: { type: Array, required: true },
  fetch: { type: Function, required: true },
  query: { type: Object, default: () => ({}) },
  pageSize: { type: Number, default: 10 }
})

const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const error = ref(null)
const currentPage = ref(1)

const loadData = async () => {
  loading.value = true
  error.value = null
  try {
    const res = await props.fetch({
      ...props.query,
      page: currentPage.value,
      pageSize: props.pageSize
    })
    // 兼容 { list, total } 和 { data: { list, total } }
    const result = res?.list !== undefined ? res : res?.data
    tableData.value = result?.list ?? []
    total.value = result?.total ?? 0
  } catch (e) {
    error.value = e?.message || '请求失败,请稍后重试'
    tableData.value = []
  } finally {
    loading.value = false
  }
}

const handlePageChange = (page) => {
  currentPage.value = page
  loadData()
}

const refresh = () => {
  currentPage.value = 1
  loadData()
}
defineExpose({ refresh })

// query 变化时重置页码并重新请求
watch(
  () => props.query,
  () => {
    currentPage.value = 1
    loadData()
  },
  { deep: true }
)

onMounted(loadData)

5.4 常见坑与对策

原因建议
接口结构不统一有的 { list, total },有的 { data: { list, total } }loadData 里做兼容,或约束接口规范
query 变化不触发请求对象引用未变,watch 不触发watch(() => props.query, ..., { deep: true })
组件销毁后仍 setState异步返回时组件已卸载onBeforeUnmount + 标记,或在请求里用 AbortController
初始化请求两次watchonMounted 同时触发可用 immediate: false,或统一用 watch 初始化

六、完整组件代码

下面是基于 Element Plus 的完整表格组件(Vue 3 + Composition API):

<!-- BaseTable.vue -->
<template>
  <div class="base-table">
    <el-table 
      :data="tableData" 
      v-loading="loading" 
      border 
      stripe
    >
      <el-table-column
        v-for="col in columns"
        :key="col.prop"
        :prop="col.prop"
        :label="col.label"
        :width="col.width"
        :min-width="col.minWidth"
        :align="col.align || 'left'"
        :fixed="col.fixed"
      >
        <template v-if="col.slot" #default="{ row, $index }">
          <slot :name="col.slot" :row="row" :column="col" :index="$index">
            {{ row[col.prop] }}
          </slot>
        </template>
        <template v-else-if="col.formatter" #default="{ row }">
          {{ col.formatter(row) }}
        </template>
      </el-table-column>
    </el-table>

    <div v-if="error" class="error-tip">
      {{ error }}
    </div>

    <el-pagination
      v-if="total > 0"
      class="pagination"
      :current-page="currentPage"
      :page-size="pageSize"
      :total="total"
      layout="total, prev, pager, next, jumper"
      @current-change="handlePageChange"
    />

    <el-empty 
      v-if="!loading && tableData.length === 0 && !error" 
      description="暂无数据" 
      :image-size="80"
    />
  </div>
</template>

<script setup>
import { ref, watch, onMounted } from 'vue'

const props = defineProps({
  columns: { type: Array, required: true },
  fetch: { type: Function, required: true },
  query: { type: Object, default: () => ({}) },
  pageSize: { type: Number, default: 10 }
})

const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const error = ref(null)
const currentPage = ref(1)

const loadData = async () => {
  loading.value = true
  error.value = null
  try {
    const res = await props.fetch({
      ...props.query,
      page: currentPage.value,
      pageSize: props.pageSize
    })
    const result = res?.list !== undefined ? res : res?.data
    tableData.value = result?.list ?? []
    total.value = result?.total ?? 0
  } catch (e) {
    error.value = e?.message || '请求失败,请稍后重试'
    tableData.value = []
  } finally {
    loading.value = false
  }
}

const handlePageChange = (page) => {
  currentPage.value = page
  loadData()
}

const refresh = () => {
  currentPage.value = 1
  loadData()
}
defineExpose({ refresh })

watch(
  () => props.query,
  () => {
    currentPage.value = 1
    loadData()
  },
  { deep: true }
)

onMounted(loadData)
</script>

<style scoped>
.base-table {
  padding: 16px 0;
}
.pagination {
  margin-top: 16px;
  display: flex;
  justify-content: flex-end;
}
.error-tip {
  color: #f56c6c;
  margin-top: 12px;
  font-size: 14px;
}
</style>

七、使用示例

<!-- UserList.vue -->
<template>
  <div class="user-list">
    <el-form :inline="true" :model="searchForm" class="search-form">
      <el-form-item label="关键词">
        <el-input v-model="searchForm.keyword" placeholder="姓名/邮箱" clearable />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="handleSearch">搜索</el-button>
        <el-button @click="handleReset">重置</el-button>
      </el-form-item>
    </el-form>

    <BaseTable
      ref="tableRef"
      :columns="columns"
      :fetch="fetchUserList"
      :query="searchForm"
    >
      <template #avatar="{ row }">
        <el-avatar :src="row.avatar" :size="36">
          {{ row.name?.charAt(0) }}
        </el-avatar>
      </template>
      <template #status="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'">
          {{ row.status === 1 ? '正常' : '禁用' }}
        </el-tag>
      </template>
      <template #action="{ row }">
        <el-button size="small" @click="handleEdit(row)">编辑</el-button>
        <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
      </template>
    </BaseTable>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import BaseTable from '@/components/BaseTable.vue'

const tableRef = ref(null)
const searchForm = reactive({
  keyword: ''
})

const columns = [
  { prop: 'name', label: '姓名', width: '120px' },
  { prop: 'avatar', label: '头像', width: '80px', slot: 'avatar' },
  { prop: 'age', label: '年龄', width: '80px', align: 'center' },
  { prop: 'email', label: '邮箱', minWidth: '180px' },
  { prop: 'status', label: '状态', width: '100px', slot: 'status' },
  { 
    prop: 'createdAt', 
    label: '创建时间', 
    width: '160px',
    formatter: (row) => row.createdAt ? row.createdAt.slice(0, 10) : '-'
  },
  { prop: 'action', label: '操作', width: '180px', slot: 'action', fixed: 'right' }
]

const fetchUserList = async (params) => {
  const res = await axios.get('/api/users', { params })
  return res.data  // 约定返回 { list: [...], total: 100 }
}

const handleSearch = () => {
  tableRef.value?.refresh()
}

const handleReset = () => {
  searchForm.keyword = ''
  tableRef.value?.refresh()
}

const handleEdit = (row) => {
  // 编辑逻辑
}

const handleDelete = (row) => {
  // 删除逻辑,成功后调用 tableRef.value?.refresh()
}
</script>

八、可扩展方向

在现有基础上可以继续做这些扩展:

扩展点思路
多选增加 el-table-column type="selection",用 v-model 或事件暴露选中行
排序在列配置加 sortable,在 fetch 参数里传 sortFieldsortOrder
列显隐列配置加 visible,用 computed 过滤再渲染
空状态插槽<slot name="empty"> 替代固定 el-empty
表格顶部工具栏用 slot 预留 header,放刷新、导出等按钮

九、小结

  • 列配置:用配置数组驱动列,支持动态列和统一维护。
  • Slot 扩展:通过 slot 字段 + 作用域插槽,把自定义列交给父组件,并处理好兜底。
  • 请求生命周期:在组件内统一处理 loading、错误、分页和刷新,调用方只需提供 fetchquery

按这个思路封装表格组件,既能复用逻辑,又能通过 slot 保持灵活性,适合作为中后台的基础表格组件。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~