Vue3表格按钮Loading:四种方案对比与最佳实践

20 阅读6分钟

在前端开发中,表格操作按钮的loading状态处理是一个常见的需求。本文将深入探讨Vue3中实现表格按钮loading的四种方案,并分析各自的适用场景。

引言

在现代Web应用中,数据表格是最常见的组件之一。表格中的操作按钮(如编辑、删除、审核等)通常需要与后端进行异步交互。为了提供良好的用户体验,我们需要在按钮点击后显示loading状态,防止用户重复点击并提示操作正在进行。

在Vue3中,我们有多种方式来实现这一功能。本文将详细介绍四种实现方案,并通过对比分析帮助你选择最适合的方案。

方案一:基于行数据的Loading状态

这是最简单直观的方案,直接在行数据对象中添加loading字段。

<template>
  <el-table :data="tableData">
    <el-table-column label="操作">
      <template #default="{ row }">
        <el-button
          :loading="row.loading || false"
          @click="handleAction(row)"
        >
          操作
        </el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup lang="ts">
const handleAction = async (row) => {
  row.loading = true
  try {
    await apiCall()
  } finally {
    row.loading = false
  }
}
</script>

优点:

  • 实现简单,代码直观
  • 状态与数据绑定紧密
  • 无需额外的状态管理

缺点:

  • 污染了原始数据模型
  • 对于复杂操作(多个按钮)需要添加多个字段
  • 数据序列化时需要注意排除这些临时状态

方案二:使用Map存储Loading状态

使用Map或对象来存储每个按钮的loading状态,键通常由行ID和操作类型组合而成。

<template>
  <el-table :data="tableData">
    <el-table-column label="操作">
      <template #default="{ row }">
        <el-button
          :loading="loadingMap.get(`${row.id}-approve`) || false"
          @click="approveRow(row)"
        >
          审核
        </el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup lang="ts">
const loadingMap = ref(new Map<string, boolean>())

const approveRow = async (row) => {
  const key = `${row.id}-approve`
  loadingMap.value.set(key, true)
  try {
    await apiCall()
  } finally {
    loadingMap.value.set(key, false)
  }
}
</script>

优点:

  • 不污染原始数据
  • 状态管理集中,易于调试
  • 支持复杂的键组合

缺点:

  • 需要手动管理键的生成
  • 模板中需要计算键名
  • Map在模板中的使用稍显复杂

方案三:响应式对象集中管理

使用一个响应式对象来统一管理所有按钮的loading状态,结构更加清晰。

<template>
  <el-table :data="tableData">
    <el-table-column label="操作">
      <template #default="{ row }">
        <el-button
          :loading="getButtonLoading(row.id, 'edit')"
          @click="editRow(row)"
        >
          编辑
        </el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup lang="ts">
const buttonLoading = reactive<Record<string, Record<string, boolean>>>({})

const setButtonLoading = (rowId, action, loading) => {
  if (!buttonLoading[rowId]) buttonLoading[rowId] = {}
  buttonLoading[rowId][action] = loading
}

const getButtonLoading = (rowId, action) => {
  return buttonLoading[rowId]?.[action] || false
}

const editRow = async (row) => {
  setButtonLoading(row.id, 'edit', true)
  try {
    await apiCall()
  } finally {
    setButtonLoading(row.id, 'edit', false)
  }
}
</script>

优点:

  • 状态管理集中且结构清晰
  • 易于扩展和复用
  • 支持多种操作类型
  • 调试方便

缺点:

  • 代码量相对较多
  • 需要额外的工具函数

方案四:自定义Hook封装

将loading逻辑封装成自定义Hook,实现逻辑复用和关注点分离。

// useTableLoading.ts
export function useTableLoading() {
  const loadingState = reactive<Record<string, Record<string, boolean>>>({})
  
  const setLoading = (rowId, action, isLoading) => {
    if (!loadingState[rowId]) loadingState[rowId] = {}
    loadingState[rowId][action] = isLoading
  }
  
  const getLoading = (rowId, action) => {
    return loadingState[rowId]?.[action] || false
  }
  
  const withLoading = async (rowId, action, asyncFn) => {
    setLoading(rowId, action, true)
    try {
      return await asyncFn()
    } finally {
      setLoading(rowId, action, false)
    }
  }
  
  return { getLoading, setLoading, withLoading }
}

优点:

  • 高度复用,可跨组件使用
  • 关注点分离,业务逻辑更清晰
  • 易于测试
  • 提供统一的错误处理和状态管理

缺点:

  • 学习成本稍高
  • 对于简单场景可能显得臃肿

详细对比分析

1. 代码复杂度对比

方案模板复杂度逻辑复杂度整体代码量
方案一
方案二
方案三
方案四多(但可复用)

2. 性能影响对比

// 性能考虑点:
// 1. 响应式依赖数量
// 2. 内存占用
// 3. 渲染性能

// 方案一:每个行对象都添加了响应式属性
// 方案二:使用Map,响应式依赖较少
// 方案三:集中管理,响应式对象嵌套
// 方案四:与方案三类似,但可优化

3. 适用场景对比

方案小规模表格大规模表格多操作类型跨组件复用
方案一✅ 推荐❌ 不推荐⚠️ 有限支持❌ 不支持
方案二⚠️ 可用✅ 推荐✅ 支持⚠️ 需要调整
方案三✅ 推荐✅ 推荐✅ 支持⚠️ 需要调整
方案四❌ 过重✅ 推荐✅ 支持✅ 推荐

实战案例:完整实现

下面是一个结合了方案三和方案四优点的综合实现:

<template>
  <div class="table-container">
    <!-- 搜索和筛选 -->
    <div class="table-header">
      <el-input v-model="searchText" placeholder="搜索..." />
      <el-button type="primary" @click="loadData">刷新</el-button>
    </div>
    
    <!-- 数据表格 -->
    <el-table :data="filteredData" v-loading="tableLoading">
      <el-table-column prop="name" label="姓名" />
      <el-table-column prop="status" label="状态">
        <template #default="{ row }">
          <el-tag :type="statusMap[row.status]">
            {{ row.status }}
          </el-tag>
        </template>
      </el-table-column>
      
      <el-table-column label="操作" width="300">
        <template #default="{ row }">
          <div class="action-buttons">
            <!-- 使用统一的loading管理 -->
            <el-button
              size="small"
              type="primary"
              :loading="getButtonLoading(row.id, 'edit')"
              @click="editItem(row)"
              :disabled="row.status === 'disabled'"
            >
              编辑
            </el-button>
            
            <el-button
              size="small"
              type="success"
              :loading="getButtonLoading(row.id, 'enable')"
              @click="toggleStatus(row, 'enabled')"
              v-if="row.status === 'disabled'"
            >
              启用
            </el-button>
            
            <el-button
              size="small"
              type="warning"
              :loading="getButtonLoading(row.id, 'disable')"
              @click="toggleStatus(row, 'disabled')"
              v-else
            >
              禁用
            </el-button>
            
            <el-button
              size="small"
              type="danger"
              :loading="getButtonLoading(row.id, 'delete')"
              @click="deleteItem(row)"
            >
              删除
            </el-button>
          </div>
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页 -->
    <div class="table-footer">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :total="total"
        @current-change="loadData"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useTableLoading } from '@/composables/useTableLoading'

// 使用自定义Hook
const { getButtonLoading, withLoading } = useTableLoading()

// 表格数据
const tableData = ref([])
const tableLoading = ref(false)
const searchText = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)

// 状态映射
const statusMap = {
  enabled: 'success',
  disabled: 'info',
  pending: 'warning'
}

// 过滤后的数据
const filteredData = computed(() => {
  if (!searchText.value) return tableData.value
  return tableData.value.filter(item =>
    item.name.includes(searchText.value) ||
    item.email.includes(searchText.value)
  )
})

// 加载数据
const loadData = async () => {
  tableLoading.value = true
  try {
    // 模拟API调用
    const response = await fetchData(currentPage.value, pageSize.value)
    tableData.value = response.data
    total.value = response.total
  } catch (error) {
    ElMessage.error('加载数据失败')
  } finally {
    tableLoading.value = false
  }
}

// 编辑项目
const editItem = async (row) => {
  await withLoading(row.id, 'edit', async () => {
    // 这里调用编辑API
    await new Promise(resolve => setTimeout(resolve, 1000))
    ElMessage.success('编辑成功')
  })
}

// 切换状态
const toggleStatus = async (row, newStatus) => {
  const action = newStatus === 'enabled' ? 'enable' : 'disable'
  
  await withLoading(row.id, action, async () => {
    await new Promise(resolve => setTimeout(resolve, 800))
    row.status = newStatus
    ElMessage.success(`已${newStatus === 'enabled' ? '启用' : '禁用'}`)
  })
}

// 删除项目(带确认)
const deleteItem = async (row) => {
  try {
    await ElMessageBox.confirm(
      `确定删除 "${row.name}" 吗?`,
      '警告',
      { type: 'warning' }
    )
    
    await withLoading(row.id, 'delete', async () => {
      await new Promise(resolve => setTimeout(resolve, 800))
      
      // 从列表中移除
      const index = tableData.value.findIndex(item => item.id === row.id)
      if (index > -1) {
        tableData.value.splice(index, 1)
      }
      
      ElMessage.success('删除成功')
    })
  } catch (error) {
    if (error === 'cancel') {
      ElMessage.info('已取消删除')
    }
  }
}

// 模拟API
const fetchData = async (page, size) => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        data: Array.from({ length: size }, (_, i) => ({
          id: (page - 1) * size + i + 1,
          name: `用户${(page - 1) * size + i + 1}`,
          email: `user${(page - 1) * size + i + 1}@example.com`,
          status: ['enabled', 'disabled', 'pending'][Math.floor(Math.random() * 3)]
        })),
        total: 100
      })
    }, 500)
  })
}

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

<style scoped>
.table-container {
  padding: 20px;
  background: #fff;
  border-radius: 8px;
}

.table-header {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.action-buttons {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.table-footer {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
}
</style>

最佳实践建议

1. 根据场景选择方案

  • 简单场景(小表格、简单操作):方案一或方案二
  • 复杂场景(大表格、多操作):方案三
  • 需要复用(多个表格组件):方案四

2. 性能优化技巧

// 1. 使用唯一键,避免重复创建响应式对象
const getLoadingKey = (rowId, action) => `${rowId}_${action}`

// 2. 批量操作时,使用防抖节流
import { debounce } from 'lodash-es'
const handleClick = debounce(async (row) => {
  // ...
}, 300)

// 3. 虚拟滚动配合懒加载

总结

在Vue3中实现表格按钮的loading状态有多种方案,每种方案都有其适用场景:

  • 方案一适合快速开发和简单场景,但要小心数据污染问题
  • 方案二提供了更好的关注点分离,适合中等复杂度应用
  • 方案三通过集中管理提供了最大的灵活性,适合大型应用
  • 方案四通过自定义Hook实现了最佳的可复用性和可测试性