表格组件封装详解(含完整代码)

表格组件封装详解(含完整代码)

本文详细解析 布局容器 + 动态搜索栏 + 智能表格 三组件的封装逻辑、实现细节与标准用法,附带完整可运行代码。


一、整体架构与协作关系

🧩 组件职责划分

组件 职责 关键能力
LayoutContainer.vue 布局骨架 统一结构、操作按钮(刷新/显隐列/折叠搜索)
DynamicSearchBar.vue 动态表单 根据配置生成 input/select/date 等控件
SmartTable.vue 数据展示 自动请求、分页、字典转换、时间格式化

🔗 数据流图

scss 复制代码
[LayoutContainer]
   │
   ├─ #search → [DynamicSearchBar] ←→ params (v-model)
   │                │
   │                └── tableRef.getList() ←──┐
   │                                          │
   └─ #default → [SmartTable] ←───────────────┘
                   ↑
             columns (响应式数组)
             tableRef (expose 方法)

关键设计

  • columns共享状态 :LayoutContainer 修改 .hide → SmartTable 自动响应
  • tableRef方法通道:SearchBar 和 LayoutContainer 通过它触发表格刷新

二、智能表格组件(SmartTable.vue)

💡 封装目标

  • 自动处理分页、排序、加载状态
  • 支持字典、时间、链接等常见字段类型
  • 提供插槽覆盖默认渲染

📄 完整代码

vue 复制代码
<!-- SmartTable.vue -->
<template>
  <div class="smart-table">
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="tableData"
      v-bind="mergedConfig.table"
      @sort-change="handleSortChange"
    >
      <!-- 遍历 columns 渲染列 -->
      <template v-for="column in visibleColumns" :key="column.prop">
        <!-- selection / index 列 -->
        <el-table-column
          v-if="column.type === 'selection'"
          type="selection"
          :width="column.width || 55"
        />
        <el-table-column
          v-else-if="column.type === 'index'"
          type="index"
          :label="column.label"
          :width="column.width || 60"
        />

        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :sortable="column.sortable || false"
          :show-overflow-tooltip="true"
        >
          <template #default="{ row }">
            <!-- 插槽优先 -->
            <slot
              :name="column.slot"
              :row="row"
              :column="column"
              v-if="column.slot"
            />
            <!-- 字典标签 -->
            <dict-tag
              v-else-if="column.dict"
              :value="row[column.prop]"
              :dict-key="column.dict"
            />
            <!-- 时间格式化 -->
            <span v-else-if="column.date">
              {{ formatDate(row[column.prop], column.dateFormat) }}
            </span>
            <!-- 链接 -->
            <el-link
              v-else-if="column.link"
              type="primary"
              @click="handleLinkClick(column, row)"
            >
              {{ row[column.prop] }}
            </el-link>
            <!-- 默认文本 -->
            <span v-else>{{ row[column.prop] }}</span>
          </template>

          <!-- 表头提示 -->
          <template #header>
            <span>{{ column.label }}</span>
            <el-tooltip
              v-if="column.tip"
              :content="column.tip.content"
              placement="top"
            >
              <i class="el-icon-question" style="margin-left: 4px; color: #999"></i>
            </el-tooltip>
          </template>
        </el-table-column>
      </template>

      <!-- 空状态 -->
      <template #empty>
        <div class="no-data">
          <img src="@/assets/images/no-data.png" alt="无数据" />
          <p>暂无数据</p>
        </div>
      </template>
    </el-table>

    <!-- 分页 -->
    <el-pagination
      v-if="!mergedConfig.notPagination && total > 0"
      v-model:current-page="queryParams.pageNum"
      v-model:page-size="queryParams.pageSize"
      :total="total"
      layout="total, sizes, prev, pager, next, jumper"
      @size-change="handleSizeChange"
      @current-change="getList"
      v-bind="mergedConfig.pagination"
    />
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { parseTime } from '@/utils'

// Props
const props = defineProps({
  columns: {
    type: Array,
    required: true
  },
  func: {
    type: Function,
    required: true
  },
  params: {
    type: Object,
    default: () => ({})
  },
  config: {
    type: Object,
    default: () => ({})
  },
  events: {
    type: Object,
    default: () => ({})
  }
})

// Expose
const tableRef = ref(null)
defineExpose({
  getList,
  resetQuery,
  reload
})

// 响应式数据
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const queryParams = reactive({
  pageNum: 1,
  pageSize: 10,
  ...props.params
})

// 合并配置
const mergedConfig = computed(() => {
  return {
    table: {
      border: true,
      stripe: true,
      ...props.config.table
    },
    pagination: {
      background: true,
      pageSizes: [10, 20, 50, 100],
      ...props.config.pagination
    },
    sort: props.config.sort ?? false,
    notPagination: props.config.notPagination ?? false,
    autoPagination: props.config.autoPagination ?? false,
    initResquest: props.config.initResquest ?? true
  }
})

// 可见列(过滤 hide = true 的列)
const visibleColumns = computed(() => {
  return props.columns.filter(col => !col.hide)
})

// 格式化时间
function formatDate(value, format = '{y}-{m}-{d}') {
  if (!value) return ''
  return parseTime(value, format)
}

// 获取数据
async function getList() {
  try {
    loading.value = true

    // 触发 formatParams 事件
    let finalParams = { ...queryParams }
    if (props.events?.formatParams) {
      finalParams = props.events.formatParams(finalParams) || finalParams
    }

    const res = await props.func(finalParams)

    // 触发 formatData 事件
    let finalData = res
    if (props.events?.formatData) {
      finalData = props.events.formatData(res) || res
    }

    // 处理分页数据
    if (mergedConfig.value.autoPagination) {
      // 前端分页
      tableData.value = finalData.data || []
      total.value = tableData.value.length
    } else {
      // 后端分页
      tableData.value = finalData.data?.rows || []
      total.value = finalData.data?.total || 0
    }
  } catch (error) {
    console.error('表格请求失败:', error)
    tableData.value = []
    total.value = 0
  } finally {
    loading.value = false
  }
}

// 重置查询
function resetQuery() {
  queryParams.pageNum = 1
  getList()
}

// 强制重绘
function reload() {
  tableRef.value?.doLayout()
}

// 排序变更
function handleSortChange({ prop, order }) {
  if (mergedConfig.value.sort) {
    const sort = order ? { prop, order: order === 'ascending' ? 'asc' : 'desc' } : null
    if (props.events?.onSortChange) {
      props.events.onSortChange(queryParams, sort)
    }
    getList()
  }
}

// 分页大小变更
function handleSizeChange(val) {
  queryParams.pageSize = val
  getList()
}

// 链接点击
function handleLinkClick(column, row) {
  if (props.events?.onLinkClick) {
    props.events.onLinkClick(column, row)
  } else if (column.link?.name) {
    // 路由跳转
    router.push({
      name: column.link.name,
      params: typeof column.link.params === 'function'
        ? column.link.params(row)
        : column.link.params
    })
  }
}

// 初始化
onMounted(() => {
  if (mergedConfig.value.initResquest) {
    getList()
  }
})

// 监听外部 params 变更
watch(() => props.params, (newVal) => {
  Object.assign(queryParams, newVal)
}, { deep: true })
</script>

<style scoped>
.smart-table {
  width: 100%;
}
.no-data {
  text-align: center;
  padding: 40px 0;
}
.no-data img {
  width: 120px;
  opacity: 0.6;
}
</style>

三、动态搜索栏组件(DynamicSearchBar.vue)

💡 封装目标

  • 根据配置动态生成不同类型的输入控件
  • 自动绑定参数,支持回车查询
  • 超过3项自动折叠

📄 完整代码

vue 复制代码
<!-- DynamicSearchBar.vue -->
<template>
  <el-form
    ref="formRef"
    :model="localParams"
    :inline="true"
    label-width="80px"
    size="small"
  >
    <!-- 显示项 -->
    <el-form-item
      v-for="(item, index) in displayedItems"
      :key="item.prop"
      :label="item.label"
      v-has-permi="item.permi"
    >
      <!-- input -->
      <el-input
        v-if="item.component.is === 'input'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @keyup.enter="handleQuery"
        clearable
      />
      <!-- select -->
      <el-select
        v-else-if="item.component.is === 'select'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
        clearable
      >
        <el-option
          v-for="opt in item.component.options"
          :key="opt.value"
          :label="opt.label"
          :value="opt.value"
        />
      </el-select>
      <!-- date-picker -->
      <el-date-picker
        v-else-if="item.component.is === 'date-picker'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
      />
      <!-- tree-select -->
      <el-tree-select
        v-else-if="item.component.is === 'tree-select'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
      />
    </el-form-item>

    <!-- 操作按钮 -->
    <el-form-item>
      <el-button type="primary" @click="handleQuery">查询</el-button>
      <el-button @click="handleReset">重置</el-button>
      <el-button
        v-if="items.length > 3"
        type="text"
        @click="toggleExpand"
      >
        {{ isExpanded ? '收起' : '展开' }}<i :class="`el-icon-arrow-${isExpanded ? 'up' : 'down'}`"></i>
      </el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive, computed, watch } from 'vue'
const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  params: {
    type: Object,
    required: true
  },
  config: {
    type: Object,
    default: () => ({})
  },
  tableRef: {
    type: Object,
    default: () => ({})
  }
})

// 响应式数据
const localParams = reactive({})
const isExpanded = ref(false)

// 计算显示项(折叠逻辑)
const displayedItems = computed(() => {
  if (isExpanded.value || props.items.length <= 3) {
    return props.items
  }
  return props.items.slice(0, 3)
})

// 同步外部 params
watch(() => props.params, (newVal) => {
  Object.assign(localParams, newVal)
}, { immediate: true, deep: true })

// 同步到外部
watch(localParams, (newVal) => {
  Object.assign(props.params, newVal)
}, { deep: true })

// 查询
function handleQuery() {
  if (props.tableRef?.getList) {
    props.tableRef.getList()
  }
  // 触发事件
  emit('query', { ...localParams })
}

// 重置
function handleReset() {
  // 重置为初始值
  for (const key in localParams) {
    localParams[key] = ''
  }
  handleQuery()
  emit('reset')
}

// 切换展开
function toggleExpand() {
  isExpanded.value = !isExpanded.value
}

// 权限指令(示例)
const vHasPermi = {
  mounted(el, binding) {
    const { value } = binding
    if (value && !checkPermission(value)) {
      el.style.display = 'none'
    }
  }
}

// 模拟权限检查
function checkPermission(permi) {
  // 实际项目中从 store 或全局状态获取用户权限
  const userPermi = ['user:query', 'role:edit'] // 示例
  if (Array.isArray(permi)) {
    return permi.some(p => userPermi.includes(p))
  }
  return userPermi.includes(permi)
}

const emit = defineEmits(['query', 'reset'])
</script>

四、布局容器组件(LayoutContainer.vue)

💡 封装目标

  • 提供标准化布局结构
  • 集成常用操作(刷新/显隐列/折叠搜索)
  • 控制内容区高度自适应

📄 完整代码

vue 复制代码
<!-- LayoutContainer.vue -->
<template>
  <div class="layout-container">
    <!-- 搜索区 -->
    <div class="search-area" v-if="$slots.search" v-show="store.search">
      <slot name="search"></slot>
    </div>

    <!-- 内容区 -->
    <div :class="['content-area', config.fullContent ? 'full' : '']">
      <!-- 操作栏 -->
      <div class="action-bar" v-if="config.actions.show">
        <div class="left-actions">
          <slot name="actions-data"></slot>
        </div>
        <div class="right-actions" v-if="config.actions.table.show">
          <!-- 折叠搜索 -->
          <el-tooltip content="隐藏搜索" placement="top">
            <el-button
              circle
              @click="store.search = !store.search"
              v-show="config.actions.table.search"
            >
              <i class="el-icon-search"></i>
            </el-button>
          </el-tooltip>

          <!-- 刷新 -->
          <el-tooltip content="刷新" placement="top">
            <el-button
              circle
              v-show="config.actions.table.refresh"
              @click="handleRefresh"
            >
              <i class="el-icon-refresh"></i>
            </el-button>
          </el-tooltip>

          <!-- 显隐列 -->
          <el-tooltip content="显隐列" placement="top">
            <el-dropdown
              trigger="click"
              :hide-on-click="false"
              v-show="config.actions.table.columns"
              popper-class="column-toggle-popper"
            >
              <el-button circle>
                <i class="el-icon-menu"></i>
              </el-button>
              <template #dropdown>
                <el-dropdown-menu>
                  <el-dropdown-item
                    v-for="col in props.columns"
                    :key="col.prop"
                  >
                    <el-checkbox
                      v-if="col.type !== 'selection'"
                      :model-value="!col.hide"
                      @update:model-value="(val) => toggleColumn(col, val)"
                    >
                      {{ col.label }}
                    </el-checkbox>
                  </el-dropdown-item>
                </el-dropdown-menu>
              </template>
            </el-dropdown>
          </el-tooltip>
        </div>
      </div>

      <!-- 主体内容 -->
      <div class="main-content" v-if="$slots.default">
        <slot></slot>
      </div>
    </div>
  </div>
</template>

<script setup>
import { reactive, computed } from 'vue'
import { merge } from 'lodash-es'

// 默认配置
const DEFAULT_CONFIG = {
  fullContent: true,
  actions: {
    show: true,
    table: {
      show: true,
      search: true,
      refresh: true,
      columns: true
    }
  }
}

const props = defineProps({
  columns: {
    type: Array,
    default: () => []
  },
  config: {
    type: Object,
    default: () => ({})
  },
  tableRef: {
    type: Object,
    default: () => ({})
  }
})

const config = computed(() => {
  return merge({}, DEFAULT_CONFIG, props.config)
})

const store = reactive({
  search: true
})

// 刷新
function handleRefresh() {
  if (props.tableRef?.getList) {
    props.tableRef.getList()
  }
}

// 切换列显隐
function toggleColumn(column, visible) {
  column.hide = !visible
}
</script>

<style scoped>
.layout-container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.search-area {
  padding: 15px;
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  margin-bottom: 16px;
}

.content-area {
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  flex: none;
}

.content-area.full {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.action-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px 0;
  gap: 12px;
}

.left-actions,
.right-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.main-content {
  flex: 1;
  padding: 16px;
  overflow: auto;
}
</style>

<style>
/* 全局样式(非 scoped) */
.column-toggle-popper .el-dropdown-menu__item {
  line-height: 32px;
  padding: 0 16px;
}
</style>

五、标准使用示例

📄 父组件(业务页面)

vue 复制代码
<template>
  <layout-container 
    :columns="columns" 
    :config="wrapConfig" 
    :table-ref="tableRef"
  >
    <!-- 搜索区 -->
    <template #search>
      <dynamic-search-bar
        :items="searchItems"
        :params="queryParams"
        :table-ref="tableRef"
      />
    </template>

    <!-- 左侧操作 -->
    <template #actions-data>
      <el-button type="primary" @click="handleAdd">新增用户</el-button>
    </template>

    <!-- 表格 -->
    <smart-table
      ref="tableRef"
      :columns="columns"
      :func="getUserList"
      :params="queryParams"
      :config="tableConfig"
      :events="tableEvents"
    >
      <!-- 自定义操作列 -->
      <template #operation="{ row }">
        <el-button size="small" @click="handleEdit(row)">编辑</el-button>
        <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
      </template>
    </smart-table>
  </layout-container>
</template>

<script setup>
import { reactive, ref } from 'vue'
import { getUserListAPI } from '@/api/user'

// 查询参数
const queryParams = reactive({
  username: '',
  status: '',
  createTime: []
})

// 表格列
const columns = reactive([
  { label: '用户名', prop: 'username' },
  { label: '状态', prop: 'status', dict: 'sys_normal_disable' },
  { label: '创建时间', prop: 'createTime', date: true },
  { label: '操作', prop: 'operation', slot: 'operation', width: 180 }
])

// 配置
const wrapConfig = {
  fullContent: true
}

const searchItems = [
  { label: '用户名', prop: 'username', component: { is: 'input', placeholder: '请输入' } },
  { 
    label: '状态', 
    prop: 'status',
    component: { 
      is: 'select',
      options: [
        { value: '1', label: '启用' },
        { value: '0', label: '禁用' }
      ]
    }
  },
  {
    label: '创建时间',
    prop: 'createTime',
    component: { is: 'date-picker', type: 'daterange', rangeSeparator: '-' }
  }
]

const tableConfig = {
  sort: true
}

const tableEvents = {
  formatParams(params) {
    // 处理日期范围
    if (params.createTime?.length) {
      params.beginTime = params.createTime[0]
      params.endTime = params.createTime[1]
      delete params.createTime
    }
    return params
  }
}

const tableRef = ref(null)

// API 方法
async function getUserList(params) {
  const res = await getUserListAPI(params)
  return { data: { rows: res.list, total: res.total } }
}

// 操作方法
function handleAdd() { /* ... */ }
function handleEdit(row) { /* ... */ }
function handleDelete(row) { /* ... */ }
</script>

六、关键设计总结

✅ 为什么这样设计?

问题 解决方案 优势
每页重复写表格结构 SmartTable 封装 减少 70% 模板代码
搜索表单千奇百怪 DynamicSearchBar 配置驱动 统一体验,快速开发
刷新/显隐列位置不一 LayoutContainer 标准化 全系统交互一致
列显隐状态难管理 直接修改 columns.hide 无需 emit,天然响应式
业务逻辑耦合 UI events 解耦 + slot 覆盖 高内聚低耦合

⚠️ 使用注意事项

  1. columns 必须是响应式对象

    js 复制代码
    // ✅ 正确
    const columns = reactive([...])
    
    // ❌ 错误
    :columns="[{ label: 'ID', prop: 'id' }]"
  2. 不要在组件内部写业务 API

    • 所有数据请求通过 func prop 传入
    • 参数处理通过 events.formatParams
  3. 复杂 UI 用插槽覆盖

    • 操作列 → slot
    • 自定义单元格 → slot
  4. 权限控制统一接入

    • 搜索项权限 → v-hasPermi
    • 按钮权限 → 父组件控制

💬 结语 :这套组件体系已在多个大型后台项目中验证,显著提升开发效率与代码质量。核心思想是 "配置驱动 UI,事件解耦逻辑,插槽覆盖特例",在规范性与灵活性之间取得平衡。

相关推荐
晴虹2 小时前
lecen:一个更好的开源可视化系统搭建项目--页面设计器(表单设计器)--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一个懂你的人
前端·后端·低代码
小皮虾2 小时前
这应该是前端转后端最简单的办法了,不买服务器、不配 Nginx,也能写服务端接口,腾讯云云函数全栈实践
前端·javascript·全栈
码途进化论2 小时前
Vue3 防重复点击指令 - clickOnce
前端·javascript·vue.js
小二·2 小时前
从零手写俄罗斯方块(Tetris)——前端工程化实战与性能优化
前端·性能优化
xiaoxue..2 小时前
高频事件的“冷静剂” 闭包的实用场景:防抖与节流
前端·javascript·面试·html·编程思想
优弧2 小时前
2025 提效别再卷了:当我把 AI 当“团队”,工作真的顺了
前端
.try-2 小时前
cssTab卡片式
java·前端·javascript
怕浪猫3 小时前
2026最新React技术栈梳理,全栈必备
前端·javascript·面试
ulias2123 小时前
多态理论与实践
java·开发语言·前端·c++·算法