阅读视图

发现新文章,点击刷新页面。

Vue3 组件封装实战 | 从 0 封装一个可复用的表格组件(附插槽 / Props 设计)

一、为什么要封装组件?

在企业级项目中,表格是最常见的 UI 形态之一。几乎每个后台管理系统都有大量的表格页面:用户列表、订单管理、商品管理...如果每个页面都重复写表格逻辑,不仅代码冗余,维护成本也极高。

封装表格组件的价值:

  • 提升开发效率:一次封装,多处使用
  • 统一交互体验:分页、排序、筛选行为一致
  • 降低维护成本:修改逻辑只需改一处
  • 代码复用:避免重复造轮子

二、组件设计思路

2.1 需求分析

一个成熟的表格组件应该具备哪些能力?

// 核心功能需求
1. 数据展示:支持列表数据渲染
2. 列配置:自定义列标题、字段、宽度、对齐方式
3. 分页:支持分页器,可配置每页条数
4. 排序:支持单列排序、多列排序
5. 筛选:支持表头筛选
6. 操作列:编辑、删除等操作按钮
7. 自定义内容:插槽支持个性化渲染
8. 加载状态:显示加载中效果
9. 空状态:无数据时显示占位
10. 选择功能:支持行选择(单选/多选)
11. 展开行:支持展开查看更多信息
12. 固定列:左侧/右侧固定列

2.2 组件设计原则

// 1. 单一职责原则
// 表格组件只负责表格渲染,不关心数据获取

// 2. 可配置原则
// 通过 props 提供灵活的配置选项

// 3. 可扩展原则
// 通过插槽支持自定义内容

// 4. 类型安全
// 使用 TypeScript 定义 Props 和事件

三、基础版本实现

3.1 项目初始化

# 创建项目
npm create vite@latest vue3-table-demo -- --template vue-ts

# 安装依赖
npm install element-plus @element-plus/icons-vue

# 启动项目
cd vue3-table-demo
npm run dev

3.2 基础表格组件

<!-- components/BaseTable.vue -->
<template>
  <div class="base-table">
    <!-- 表格主体 -->
    <el-table
      v-loading="loading"
      :data="data"
      :border="border"
      :stripe="stripe"
      :size="size"
      :empty-text="emptyText"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
    >
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        width="55"
        fixed="left"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        width="55"
        label="序号"
        fixed="left"
      />
      
      <!-- 动态渲染列 -->
      <template v-for="column in columns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
        >
          <template #default="{ row, $index }">
            <slot 
              :name="column.slot" 
              :row="row" 
              :index="$index"
              :prop="column.prop"
            >
              {{ row[column.prop] }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showTooltip"
        />
      </template>
      
      <!-- 操作列(预留插槽) -->
      <el-table-column
        v-if="$slots.action"
        label="操作"
        :width="actionWidth"
        :fixed="actionFixed"
        align="center"
      >
        <template #default="{ row, $index }">
          <slot name="action" :row="row" :index="$index" />
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页器 -->
    <div v-if="showPagination" class="table-pagination">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :page-sizes="pageSizes"
        :total="total"
        :layout="paginationLayout"
        background
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { PropType } from 'vue'

// TypeScript 接口定义
export interface TableColumn {
  prop: string                // 字段名
  label: string               // 列标题
  width?: number | string     // 宽度
  align?: 'left' | 'center' | 'right'  // 对齐方式
  fixed?: boolean | 'left' | 'right'   // 固定列
  sortable?: boolean          // 是否可排序
  slot?: string               // 插槽名称
  formatter?: (row: any, column: any, cellValue: any, index: number) => any  // 格式化函数
  showTooltip?: boolean       // 超出是否显示tooltip
}

// Props 定义
const props = defineProps({
  // 表格数据
  data: {
    type: Array as PropType<any[]>,
    required: true,
    default: () => []
  },
  
  // 列配置
  columns: {
    type: Array as PropType<TableColumn[]>,
    required: true,
    default: () => []
  },
  
  // 总条数(用于分页)
  total: {
    type: Number,
    default: 0
  },
  
  // 是否显示分页
  showPagination: {
    type: Boolean,
    default: true
  },
  
  // 当前页码
  page: {
    type: Number,
    default: 1
  },
  
  // 每页条数
  limit: {
    type: Number,
    default: 20
  },
  
  // 每页条数选项
  pageSizes: {
    type: Array as PropType<number[]>,
    default: () => [10, 20, 50, 100]
  },
  
  // 分页布局
  paginationLayout: {
    type: String,
    default: 'total, sizes, prev, pager, next, jumper'
  },
  
  // 是否显示选择列
  showSelection: {
    type: Boolean,
    default: false
  },
  
  // 是否显示序号列
  showIndex: {
    type: Boolean,
    default: false
  },
  
  // 是否显示边框
  border: {
    type: Boolean,
    default: true
  },
  
  // 是否显示斑马纹
  stripe: {
    type: Boolean,
    default: true
  },
  
  // 表格尺寸
  size: {
    type: String as PropType<'large' | 'default' | 'small'>,
    default: 'default'
  },
  
  // 加载状态
  loading: {
    type: Boolean,
    default: false
  },
  
  // 空数据提示
  emptyText: {
    type: String,
    default: '暂无数据'
  },
  
  // 操作列宽度
  actionWidth: {
    type: [Number, String],
    default: 150
  },
  
  // 操作列是否固定
  actionFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'right'
  }
})

// 事件定义
const emit = defineEmits([
  'update:page',
  'update:limit',
  'selection-change',
  'sort-change',
  'row-click',
  'page-change'
])

// 内部状态
const currentPage = ref(props.page)
const pageSize = ref(props.limit)

// 监听外部变化
watch(() => props.page, (val) => {
  currentPage.value = val
})

watch(() => props.limit, (val) => {
  pageSize.value = val
})

// 分页变化处理
const handleSizeChange = (size: number) => {
  pageSize.value = size
  emit('update:limit', size)
  emit('page-change', { page: currentPage.value, limit: size })
}

const handleCurrentChange = (page: number) => {
  currentPage.value = page
  emit('update:page', page)
  emit('page-change', { page, limit: pageSize.value })
}

// 选择变化处理
const handleSelectionChange = (selection: any[]) => {
  emit('selection-change', selection)
}

// 排序变化处理
const handleSortChange = ({ prop, order, column }: any) => {
  emit('sort-change', { prop, order, column })
}

// 行点击处理
const handleRowClick = (row: any, column: any, event: Event) => {
  emit('row-click', { row, column, event })
}

// 暴露方法给父组件
defineExpose({
  // 清除选择
  clearSelection: () => {
    // 通过 ref 调用 el-table 的方法
  },
  
  // 切换某行的选择状态
  toggleRowSelection: (row: any, selected?: boolean) => {
    // 实现...
  }
})
</script>

<style scoped lang="scss">
.base-table {
  width: 100%;
  
  .table-pagination {
    margin-top: 20px;
    display: flex;
    justify-content: flex-end;
  }
}
</style>

四、增强版封装(企业级)

4.1 高级表格组件

<!-- components/ProTable.vue -->
<template>
  <div class="pro-table">
    <!-- 工具栏 -->
    <div v-if="showToolbar" class="table-toolbar">
      <div class="toolbar-left">
        <slot name="toolbar-left">
          <span class="table-title">{{ title }}</span>
        </slot>
      </div>
      
      <div class="toolbar-right">
        <slot name="toolbar-right">
          <!-- 刷新按钮 -->
          <el-button 
            v-if="showRefresh" 
            :icon="Refresh" 
            circle 
            @click="handleRefresh"
          />
          
          <!-- 密度切换 -->
          <el-dropdown v-if="showDensity" @command="handleDensityChange">
            <el-button :icon="Grid" circle />
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="large">宽松</el-dropdown-item>
                <el-dropdown-item command="default">默认</el-dropdown-item>
                <el-dropdown-item command="small">紧凑</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
          
          <!-- 列设置 -->
          <el-popover
            v-if="showColumnSetting"
            placement="bottom-end"
            :width="200"
            trigger="click"
          >
            <template #reference>
              <el-button :icon="Setting" circle />
            </template>
            
            <div class="column-setting">
              <div class="setting-header">
                <span>列展示</span>
                <el-checkbox 
                  v-model="checkAll" 
                  :indeterminate="isIndeterminate"
                  @change="handleCheckAllChange"
                >
                  全选
                </el-checkbox>
              </div>
              <el-divider />
              <el-checkbox-group v-model="checkedColumns" @change="handleCheckedChange">
                <div v-for="col in allColumns" :key="col.prop" class="setting-item">
                  <el-checkbox :label="col.prop">
                    {{ col.label }}
                  </el-checkbox>
                  <el-icon class="drag-icon"><Rank /></el-icon>
                </div>
              </el-checkbox-group>
            </div>
          </el-popover>
        </slot>
      </div>
    </div>
    
    <!-- 表格主体 -->
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="filteredData"
      :border="border"
      :stripe="stripe"
      :size="tableSize"
      :empty-text="emptyText"
      :row-key="rowKey"
      :expand-row-keys="expandRowKeys"
      :default-sort="defaultSort"
      :span-method="spanMethod"
      :row-class-name="rowClassName"
      :cell-class-name="cellClassName"
      :header-row-class-name="headerRowClassName"
      :header-cell-class-name="headerCellClassName"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
      @row-dblclick="handleRowDblClick"
      @expand-change="handleExpandChange"
    >
      <!-- 展开行 -->
      <el-table-column
        v-if="showExpand"
        type="expand"
        width="50"
      >
        <template #default="{ row }">
          <slot name="expand" :row="row" />
        </template>
      </el-table-column>
      
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        :width="selectionWidth"
        :fixed="selectionFixed"
        :selectable="selectable"
        :reserve-selection="reserveSelection"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        :width="indexWidth"
        :label="indexLabel"
        :fixed="indexFixed"
        :index="indexMethod"
      />
      
      <!-- 动态渲染列(支持拖拽排序) -->
      <template v-for="column in visibleColumns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :sort-method="column.sortMethod"
          :sort-by="column.sortBy"
          :sort-orders="column.sortOrders"
          :resizable="column.resizable !== false"
          :show-overflow-tooltip="column.showTooltip"
        >
          <template #default="{ row, $index }">
            <slot 
              :name="column.slot" 
              :row="row" 
              :index="$index"
              :prop="column.prop"
              :column="column"
            >
              {{ formatCellValue(row, column) }}
            </slot>
          </template>
          
          <template #header="{ column: col, $index }">
            <slot 
              :name="`header-${column.prop}`" 
              :column="col" 
              :index="$index"
              :prop="column.prop"
            >
              {{ column.label }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :sort-method="column.sortMethod"
          :sort-by="column.sortBy"
          :sort-orders="column.sortOrders"
          :resizable="column.resizable !== false"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showTooltip"
        >
          <template #default="{ row, column: col, $index }">
            {{ formatCellValue(row, column) }}
          </template>
        </el-table-column>
      </template>
      
      <!-- 操作列 -->
      <el-table-column
        v-if="hasAction"
        :label="actionLabel"
        :width="actionWidth"
        :min-width="actionMinWidth"
        :fixed="actionFixed"
        :align="actionAlign"
      >
        <template #default="{ row, $index }">
          <slot 
            name="action" 
            :row="row" 
            :index="$index"
          />
        </template>
      </el-table-column>
      
      <!-- 自定义列插槽 -->
      <slot name="append" />
    </el-table>
    
    <!-- 底部区域 -->
    <div class="table-footer">
      <!-- 左侧统计信息 -->
      <div v-if="showSummary" class="footer-left">
        <slot name="summary">
          <span>共 {{ total }} 条记录</span>
          <span v-if="showSelection && selectedRows.length">
            已选择 {{ selectedRows.length }} 条
          </span>
        </slot>
      </div>
      
      <!-- 右侧分页器 -->
      <div v-if="showPagination" class="footer-right">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="pageSizes"
          :total="total"
          :layout="paginationLayout"
          :background="paginationBackground"
          :disabled="paginationDisabled"
          :hide-on-single-page="hideOnSinglePage"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { Refresh, Grid, Setting, Rank } from '@element-plus/icons-vue'
import type { PropType } from 'vue'
import type { TableColumn } from './BaseTable'
import Sortable from 'sortablejs'

// Props 定义(继承 BaseTable 的 props 并扩展)
const props = defineProps({
  // ... 继承 BaseTable 的所有 props
  
  // 表格标题
  title: {
    type: String,
    default: ''
  },
  
  // 是否显示工具栏
  showToolbar: {
    type: Boolean,
    default: true
  },
  
  // 是否显示刷新按钮
  showRefresh: {
    type: Boolean,
    default: true
  },
  
  // 是否显示密度切换
  showDensity: {
    type: Boolean,
    default: true
  },
  
  // 是否显示列设置
  showColumnSetting: {
    type: Boolean,
    default: true
  },
  
  // 行唯一标识
  rowKey: {
    type: String,
    default: 'id'
  },
  
  // 是否显示展开行
  showExpand: {
    type: Boolean,
    default: false
  },
  
  // 展开行的 keys
  expandRowKeys: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 默认排序
  defaultSort: {
    type: Object as PropType<{ prop: string; order: 'ascending' | 'descending' }>,
    default: null
  },
  
  // 合并单元格的方法
  spanMethod: {
    type: Function as PropType<({
      row,
      column,
      rowIndex,
      columnIndex
    }: {
      row: any
      column: any
      rowIndex: number
      columnIndex: number
    }) => number[] | { rowspan: number; colspan: number }>,
    default: null
  },
  
  // 是否显示汇总信息
  showSummary: {
    type: Boolean,
    default: true
  },
  
  // 选择列宽度
  selectionWidth: {
    type: [Number, String],
    default: 55
  },
  
  // 选择列是否固定
  selectionFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'left'
  },
  
  // 行是否可选
  selectable: {
    type: Function as PropType<(row: any, index: number) => boolean>,
    default: null
  },
  
  // 是否保留选择(数据更新后)
  reserveSelection: {
    type: Boolean,
    default: false
  },
  
  // 序号列宽度
  indexWidth: {
    type: [Number, String],
    default: 60
  },
  
  // 序号列标签
  indexLabel: {
    type: String,
    default: '序号'
  },
  
  // 序号列是否固定
  indexFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'left'
  },
  
  // 序号生成方法
  indexMethod: {
    type: Function as PropType<(index: number) => number>,
    default: (index: number) => index + 1
  },
  
  // 操作列标签
  actionLabel: {
    type: String,
    default: '操作'
  },
  
  // 操作列最小宽度
  actionMinWidth: {
    type: [Number, String],
    default: 120
  },
  
  // 操作列对齐方式
  actionAlign: {
    type: String as PropType<'left' | 'center' | 'right'>,
    default: 'center'
  },
  
  // 分页器背景
  paginationBackground: {
    type: Boolean,
    default: true
  },
  
  // 分页器禁用
  paginationDisabled: {
    type: Boolean,
    default: false
  },
  
  // 只有一页时是否隐藏分页器
  hideOnSinglePage: {
    type: Boolean,
    default: false
  },
  
  // 行类名
  rowClassName: {
    type: [String, Function] as PropType<string | (({ row, rowIndex }: { row: any; rowIndex: number }) => string)>,
    default: ''
  },
  
  // 单元格类名
  cellClassName: {
    type: [String, Function] as PropType<string | (({ row, column, rowIndex, columnIndex }: any) => string)>,
    default: ''
  }
})

// 事件定义
const emit = defineEmits([
  // ... 继承 BaseTable 的事件
  'refresh',
  'density-change',
  'column-change',
  'row-dblclick',
  'expand-change'
])

// 表格引用
const tableRef = ref()

// 内部状态
const tableSize = ref<'large' | 'default' | 'small'>(props.size as any)
const selectedRows = ref<any[]>([])
const checkedColumns = ref<string[]>([])
const allColumns = ref<TableColumn[]>([])

// 计算属性:是否有操作列
const hasAction = computed(() => !!props.$slots.action)

// 计算属性:可见列
const visibleColumns = computed(() => {
  if (!checkedColumns.value.length) return allColumns.value
  return allColumns.value.filter(col => checkedColumns.value.includes(col.prop))
})

// 计算属性:过滤后的数据(可用于前端搜索)
const filteredData = computed(() => {
  // 实现前端筛选逻辑
  return props.data
})

// 初始化列配置
onMounted(() => {
  allColumns.value = props.columns.filter(col => !col.hidden)
  checkedColumns.value = allColumns.value.map(col => col.prop)
  initDrag()
})

// 初始化拖拽排序
const initDrag = () => {
  nextTick(() => {
    const settingEl = document.querySelector('.column-setting .el-checkbox-group')
    if (!settingEl) return
    
    new Sortable(settingEl as HTMLElement, {
      animation: 150,
      handle: '.drag-icon',
      onEnd: (evt) => {
        const { oldIndex, newIndex } = evt
        if (oldIndex === newIndex) return
        
        // 重新排序列
        const newColumns = [...allColumns.value]
        const [movedColumn] = newColumns.splice(oldIndex!, 1)
        newColumns.splice(newIndex!, 0, movedColumn)
        allColumns.value = newColumns
        
        emit('column-change', newColumns)
      }
    })
  })
}

// 格式化单元格值
const formatCellValue = (row: any, column: TableColumn) => {
  if (column.formatter) {
    return column.formatter(row, column, row[column.prop], 0)
  }
  return row[column.prop]
}

// 列设置相关
const checkAll = computed({
  get: () => checkedColumns.value.length === allColumns.value.length,
  set: (val) => {
    checkedColumns.value = val ? allColumns.value.map(col => col.prop) : []
  }
})

const isIndeterminate = computed(() => {
  return checkedColumns.value.length > 0 && 
         checkedColumns.value.length < allColumns.value.length
})

const handleCheckAllChange = (val: boolean) => {
  checkedColumns.value = val ? allColumns.value.map(col => col.prop) : []
  emit('column-change', visibleColumns.value)
}

const handleCheckedChange = (value: string[]) => {
  emit('column-change', visibleColumns.value)
}

// 密度切换
const handleDensityChange = (size: string) => {
  tableSize.value = size as any
  emit('density-change', size)
}

// 刷新
const handleRefresh = () => {
  emit('refresh')
}

// 双击行
const handleRowDblClick = (row: any, column: any) => {
  emit('row-dblclick', { row, column })
}

// 展开行变化
const handleExpandChange = (row: any, expandedRows: any[]) => {
  emit('expand-change', { row, expandedRows })
}

// 暴露方法
defineExpose({
  // 清除选择
  clearSelection: () => {
    tableRef.value?.clearSelection()
    selectedRows.value = []
  },
  
  // 切换行选择
  toggleRowSelection: (row: any, selected?: boolean) => {
    tableRef.value?.toggleRowSelection(row, selected)
  },
  
  // 切换所有行选择
  toggleAllSelection: () => {
    tableRef.value?.toggleAllSelection()
  },
  
  // 设置某行展开状态
  toggleRowExpansion: (row: any, expanded?: boolean) => {
    tableRef.value?.toggleRowExpansion(row, expanded)
  },
  
  // 设置当前行
  setCurrentRow: (row: any) => {
    tableRef.value?.setCurrentRow(row)
  },
  
  // 清除排序
  clearSort: () => {
    tableRef.value?.clearSort()
  },
  
  // 清除筛选
  clearFilter: (columnKeys?: string[]) => {
    tableRef.value?.clearFilter(columnKeys)
  },
  
  // 重新布局
  doLayout: () => {
    tableRef.value?.doLayout()
  },
  
  // 滚动到某行
  scrollToRow: (row: any, offset?: number) => {
    // 实现滚动逻辑
  }
})
</script>

<style scoped lang="scss">
.pro-table {
  background-color: #fff;
  border-radius: 4px;
  padding: 16px;
  
  .table-toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    
    .toolbar-left {
      .table-title {
        font-size: 16px;
        font-weight: 600;
        color: #303133;
      }
    }
    
    .toolbar-right {
      display: flex;
      gap: 8px;
    }
  }
  
  .table-footer {
    margin-top: 16px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    
    .footer-left {
      color: #909399;
      font-size: 14px;
      
      span {
        margin-right: 16px;
      }
    }
  }
  
  .column-setting {
    padding: 8px;
    
    .setting-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 8px;
    }
    
    .setting-item {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 4px 0;
      
      &:hover {
        background-color: #f5f7fa;
      }
      
      .drag-icon {
        cursor: move;
        color: #909399;
      }
    }
  }
}
</style>

五、使用示例

5.1 基础用法

<!-- views/UserList.vue -->
<template>
  <div class="user-list">
    <pro-table
      ref="tableRef"
      :data="userList"
      :columns="columns"
      :total="total"
      :loading="loading"
      :show-selection="true"
      :show-index="true"
      :page="page"
      :limit="limit"
      @page-change="handlePageChange"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @refresh="handleRefresh"
    >
      <!-- 自定义状态列 -->
      <template #status="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
      
      <!-- 自定义操作列 -->
      <template #action="{ row }">
        <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
        <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
      </template>
    </pro-table>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ProTable from '@/components/ProTable.vue'
import type { TableColumn } from '@/components/BaseTable'
import { getUserList } from '@/api/user'

// 表格列配置
const columns: TableColumn[] = [
  {
    prop: 'name',
    label: '姓名',
    width: 120,
    sortable: true
  },
  {
    prop: 'age',
    label: '年龄',
    width: 80,
    align: 'center'
  },
  {
    prop: 'email',
    label: '邮箱',
    minWidth: 200,
    showTooltip: true
  },
  {
    prop: 'phone',
    label: '手机号',
    width: 150
  },
  {
    prop: 'status',
    label: '状态',
    width: 80,
    slot: 'status'  // 使用自定义插槽
  },
  {
    prop: 'createTime',
    label: '创建时间',
    width: 180,
    sortable: true,
    formatter: (row: any, column: any, value: string) => {
      return new Date(value).toLocaleString()
    }
  }
]

// 表格数据
const userList = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const limit = ref(20)

// 获取数据
const fetchData = async () => {
  loading.value = true
  try {
    const res = await getUserList({
      page: page.value,
      limit: limit.value
    })
    userList.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

// 分页变化
const handlePageChange = ({ page: newPage, limit: newLimit }: any) => {
  page.value = newPage
  limit.value = newLimit
  fetchData()
}

// 选择变化
const handleSelectionChange = (selection: any[]) => {
  console.log('选中:', selection)
}

// 排序变化
const handleSortChange = ({ prop, order }: any) => {
  console.log('排序:', prop, order)
  // 可以在这里处理排序逻辑
}

// 刷新
const handleRefresh = () => {
  fetchData()
}

// 编辑
const handleEdit = (row: any) => {
  console.log('编辑:', row)
}

// 删除
const handleDelete = (row: any) => {
  ElMessageBox.confirm('确认删除该用户吗?', '提示', {
    type: 'warning'
  }).then(() => {
    // 调用删除接口
    ElMessage.success('删除成功')
    fetchData()
  })
}

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

5.2 高级用法:动态列 + 展开行

<!-- views/OrderList.vue -->
<template>
  <pro-table
    :data="orderList"
    :columns="dynamicColumns"
    :total="total"
    :show-expand="true"
    :show-summary="true"
    :span-method="objectSpanMethod"
  >
    <!-- 展开行内容 -->
    <template #expand="{ row }">
      <div class="order-detail">
        <h4>订单详情</h4>
        <el-descriptions :column="3" border>
          <el-descriptions-item label="商品名称">{{ row.productName }}</el-descriptions-item>
          <el-descriptions-item label="单价">¥{{ row.price }}</el-descriptions-item>
          <el-descriptions-item label="数量">{{ row.quantity }}</el-descriptions-item>
          <el-descriptions-item label="总价">¥{{ row.totalPrice }}</el-descriptions-item>
          <el-descriptions-item label="下单时间">{{ row.orderTime }}</el-descriptions-item>
          <el-descriptions-item label="支付方式">{{ row.payMethod }}</el-descriptions-item>
        </el-descriptions>
      </div>
    </template>
    
    <!-- 自定义操作列 -->
    <template #action="{ row }">
      <el-button type="primary" link @click="viewOrder(row)">查看</el-button>
      <el-button type="success" link @click="processOrder(row)">处理</el-button>
    </template>
  </pro-table>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

// 动态列配置(可以根据权限动态生成)
const columnsConfig = ref([
  { prop: 'orderNo', label: '订单号', width: 180, fixed: 'left' },
  { prop: 'customer', label: '客户', width: 120 },
  { prop: 'amount', label: '金额', width: 120, align: 'right' },
  { prop: 'status', label: '状态', width: 100 },
  { prop: 'payStatus', label: '支付状态', width: 100 },
  { prop: 'deliveryStatus', label: '发货状态', width: 100 },
  { prop: 'createTime', label: '创建时间', width: 180 },
  { prop: 'updateTime', label: '更新时间', width: 180 }
])

// 根据用户权限过滤列
const dynamicColumns = computed(() => {
  const userPermissions = ['orderNo', 'customer', 'amount', 'status']
  return columnsConfig.value.filter(col => userPermissions.includes(col.prop))
})

// 合并单元格
const objectSpanMethod = ({ row, column, rowIndex, columnIndex }: any) => {
  if (columnIndex === 0) {
    if (rowIndex % 2 === 0) {
      return {
        rowspan: 2,
        colspan: 1
      }
    } else {
      return {
        rowspan: 0,
        colspan: 0
      }
    }
  }
}
</script>

六、单元测试

// __tests__/ProTable.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ProTable from '@/components/ProTable.vue'

describe('ProTable.vue', () => {
  const mockColumns = [
    { prop: 'name', label: '姓名' },
    { prop: 'age', label: '年龄' }
  ]
  
  const mockData = [
    { name: '张三', age: 25 },
    { name: '李四', age: 30 }
  ]
  
  it('renders table correctly', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 2
      }
    })
    
    expect(wrapper.find('.pro-table').exists()).toBe(true)
    expect(wrapper.findAll('.el-table__row').length).toBe(2)
  })
  
  it('emits page-change event when pagination changes', async () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 100,
        showPagination: true
      }
    })
    
    // 模拟分页变化
    await wrapper.find('.el-pagination .btn-next').trigger('click')
    
    expect(wrapper.emitted('page-change')).toBeTruthy()
    expect(wrapper.emitted('page-change')?.[0]).toEqual([{ page: 2, limit: 20 }])
  })
  
  it('shows loading state', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: [],
        columns: mockColumns,
        loading: true
      }
    })
    
    expect(wrapper.find('.el-loading-mask').exists()).toBe(true)
  })
  
  it('renders custom slot content', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: [
          { prop: 'name', label: '姓名', slot: 'customName' }
        ]
      },
      slots: {
        customName: '<span class="custom-name">{{ row.name }}</span>'
      }
    })
    
    expect(wrapper.find('.custom-name').exists()).toBe(true)
  })
})

七、性能优化

7.1 虚拟滚动(大数据量)

<!-- 对于大量数据,可以使用虚拟滚动 -->
<template>
  <el-table
    v-loading="loading"
    :data="visibleData"
    :height="tableHeight"
    style="width: 100%"
    @scroll="handleScroll"
  >
    <!-- 列配置 -->
  </el-table>
</template>

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

const props = defineProps({
  data: {
    type: Array,
    default: () => []
  },
  rowHeight: {
    type: Number,
    default: 48
  },
  bufferSize: {
    type: Number,
    default: 10
  }
})

const scrollTop = ref(0)
const tableHeight = ref(600)

// 计算可见范围
const visibleCount = computed(() => Math.ceil(tableHeight.value / props.rowHeight))

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.bufferSize)
})

const endIndex = computed(() => {
  return Math.min(
    props.data.length,
    startIndex.value + visibleCount.value + props.bufferSize * 2
  )
})

const visibleData = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop
}
</script>

7.2 大数据量优化策略

// 1. 使用虚拟滚动
// 2. 按需渲染
// 3. 使用函数式组件
// 4. 避免不必要的响应式
// 5. 使用 computed 缓存计算结果
// 6. 列表项使用唯一的 key
// 7. 使用 v-once 处理静态内容

八、总结与最佳实践

8.1 组件设计要点

  1. Props 设计原则

    • 提供合理的默认值
    • 使用 TypeScript 类型定义
    • 保持 API 简洁但够用
  2. 插槽设计原则

    • 提供足够的自定义能力
    • 作用域插槽传递必要数据
    • 预留扩展位置
  3. 事件设计原则

    • 遵循 v-model 规范
    • 提供完整的事件体系
    • 事件命名清晰规范

8.2 使用建议

// 1. 合理配置列宽度
const columns = [
  { prop: 'name', label: '姓名', width: 120 }, // 固定宽度
  { prop: 'address', label: '地址', minWidth: 200 }, // 最小宽度
  { prop: 'description', label: '描述', width: 'auto' } // 自适应
]

// 2. 使用唯一 rowKey
<pro-table :data="list" row-key="id" />

// 3. 合理使用插槽
<template #status="{ row }">
  <Badge :status="row.status" />
</template>

// 4. 处理加载状态
<pro-table :loading="loading" :data="list" />

// 5. 处理空状态
<pro-table :data="[]" empty-text="暂无数据" />

8.3 扩展思考

  1. 如何支持表格导出?

    • 添加导出按钮和导出方法
    • 支持导出当前页或全部数据
    • 支持导出格式配置(CSV/Excel)
  2. 如何支持表格打印?

    • 添加打印样式
    • 隐藏操作列和按钮
    • 调整列宽适配打印
  3. 如何支持表格列拖动调整宽度?

    • 使用 resizable 属性
    • 保存用户调整后的宽度到 localStorage
  4. 如何支持表格状态持久化?

    • 保存列显示状态
    • 保存排序状态
    • 保存筛选状态

通过合理封装表格组件,可以极大提升开发效率,保证项目代码质量,这也是企业级前端开发的核心能力之一。

前端网络请求实战 | Axios 从入门到封装(拦截器 / 错误处理 / 重试)

一、为什么选择 Axios?

在项目开发中,网络请求是必不可少的一环。虽然浏览器提供了 Fetch API 和 XMLHttpRequest,但 Axios 凭借其强大的功能和友好的 API,成为最受欢迎的请求库。

1.1 Axios 核心优势

// 1. 支持浏览器和 Node.js 环境
// 浏览器:XMLHttpRequest
// Node.js:http 模块

// 2. 自动转换 JSON 数据
axios.get('/api/user').then(res => {
  console.log(res.data) // 自动解析为 JavaScript 对象
})

// 3. 请求拦截和响应拦截
// 4. 取消请求
// 5. 超时处理
// 6. 并发请求
// 7. CSRF 防护
// 8. 上传/下载进度监控

二、Axios 基础入门

2.1 安装与引入

# 使用 npm
npm install axios

# 使用 yarn
yarn add axios

# 使用 CDN
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>

2.2 基本请求方法

import axios from 'axios'

// GET 请求
axios.get('/api/users', {
  params: {
    page: 1,
    limit: 10
  }
})
.then(response => {
  console.log('用户列表:', response.data)
})
.catch(error => {
  console.error('请求失败:', error)
})

// POST 请求
axios.post('/api/users', {
  name: '张三',
  email: 'zhangsan@example.com',
  age: 25
})
.then(response => {
  console.log('创建成功:', response.data)
})

// PUT 请求(更新)
axios.put('/api/users/1', {
  name: '张三丰',
  age: 26
})

// DELETE 请求
axios.delete('/api/users/1')

// PATCH 请求(部分更新)
axios.patch('/api/users/1', {
  age: 27
})

2.3 请求配置详解

axios({
  method: 'post',                    // 请求方法
  url: '/api/users',                  // 请求地址
  baseURL: 'https://api.example.com', // 基础URL
  headers: {                           // 请求头
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token123'
  },
  params: {                            // URL参数
    page: 1,
    limit: 10
  },
  data: {                              // 请求体
    name: '张三'
  },
  timeout: 5000,                       // 超时时间(ms)
  withCredentials: true,                // 跨域请求时携带cookie
  responseType: 'json',                 // 响应数据类型
  maxContentLength: 2000,               // 最大响应长度
  validateStatus: function (status) {   // 定义哪些状态码是成功的
    return status >= 200 && status < 300
  },
  proxy: {                              // 代理配置
    host: '127.0.0.1',
    port: 9000
  }
})

2.4 响应数据结构

axios.get('/api/user').then(response => {
  // response 对象包含:
  console.log(response.data)       // 服务器返回的数据
  console.log(response.status)     // HTTP 状态码
  console.log(response.statusText) // 状态消息
  console.log(response.headers)    // 响应头
  console.log(response.config)     // 请求配置
  console.log(response.request)    // 原生XMLHttpRequest对象
})

三、项目中的 Axios 封装

在实际项目中,我们通常会对 Axios 进行二次封装,统一处理请求配置、拦截器、错误处理等。

3.1 基础封装结构

// service/index.js
import axios from 'axios'

class RequestService {
  constructor() {
    // 创建 axios 实例
    this.service = axios.create({
      baseURL: process.env.VUE_APP_BASE_API || '/api',
      timeout: 10000,
      headers: {
        'Content-Type': 'application/json;charset=UTF-8'
      }
    })
    
    // 初始化拦截器
    this.setupInterceptors()
  }
  
  setupInterceptors() {
    // 请求拦截器
    this.service.interceptors.request.use(
      this.handleRequestSuccess,
      this.handleRequestError
    )
    
    // 响应拦截器
    this.service.interceptors.response.use(
      this.handleResponseSuccess,
      this.handleResponseError
    )
  }
  
  handleRequestSuccess(config) {
    console.log('请求配置:', config)
    return config
  }
  
  handleRequestError(error) {
    console.error('请求错误:', error)
    return Promise.reject(error)
  }
  
  handleResponseSuccess(response) {
    console.log('响应数据:', response)
    return response
  }
  
  handleResponseError(error) {
    console.error('响应错误:', error)
    return Promise.reject(error)
  }
  
  // 请求方法封装
  get(url, params = {}, config = {}) {
    return this.service.get(url, { params, ...config })
  }
  
  post(url, data = {}, config = {}) {
    return this.service.post(url, data, config)
  }
  
  put(url, data = {}, config = {}) {
    return this.service.put(url, data, config)
  }
  
  delete(url, params = {}, config = {}) {
    return this.service.delete(url, { params, ...config })
  }
  
  patch(url, data = {}, config = {}) {
    return this.service.patch(url, data, config)
  }
}

export default new RequestService()

3.2 完整的拦截器实现

// service/interceptors.js
import { message, Modal } from 'ant-design-vue'
import router from '@/router'
import store from '@/store'

// 请求拦截器
export function requestSuccess(config) {
  // 1. 添加 token
  const token = store.state.user.token
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`
  }
  
  // 2. 添加时间戳防止缓存(GET请求)
  if (config.method === 'get') {
    config.params = {
      ...config.params,
      _t: Date.now()
    }
  }
  
  // 3. 请求日志(开发环境)
  if (process.env.NODE_ENV === 'development') {
    console.log('🚀 请求信息:', {
      url: config.url,
      method: config.method,
      params: config.params,
      data: config.data,
      headers: config.headers
    })
  }
  
  return config
}

export function requestError(error) {
  console.error('❌ 请求发送失败:', error)
  message.error('网络请求失败,请检查网络连接')
  return Promise.reject(error)
}

// 响应拦截器
export function responseSuccess(response) {
  // 可以统一处理业务状态码
  const { code, data, message: msg } = response.data
  
  // 根据后端约定的状态码处理
  switch (code) {
    case 200: // 成功
      return data
    case 401: // 未授权
      handleUnauthorized()
      return Promise.reject(new Error('未授权,请重新登录'))
    case 403: // 禁止访问
      message.error('没有权限访问')
      return Promise.reject(new Error('禁止访问'))
    case 500: // 服务器错误
      message.error('服务器错误,请稍后重试')
      return Promise.reject(new Error('服务器错误'))
    default:
      // 其他错误
      message.error(msg || '请求失败')
      return Promise.reject(new Error(msg || '请求失败'))
  }
}

export function responseError(error) {
  // 处理 HTTP 状态码错误
  if (error.response) {
    // 服务器返回了错误状态码
    const { status, data } = error.response
    
    switch (status) {
      case 400:
        message.error(data?.message || '请求参数错误')
        break
      case 401:
        handleUnauthorized()
        break
      case 403:
        message.error('没有权限访问')
        break
      case 404:
        message.error('请求的资源不存在')
        break
      case 500:
        message.error('服务器内部错误')
        break
      case 502:
        message.error('网关错误')
        break
      case 503:
        message.error('服务不可用')
        break
      case 504:
        message.error('网关超时')
        break
      default:
        message.error(`网络错误: ${status}`)
    }
  } else if (error.request) {
    // 请求已发送但没有收到响应
    message.error('服务器无响应,请检查网络')
  } else {
    // 请求配置出错
    message.error('请求配置错误')
  }
  
  return Promise.reject(error)
}

// 处理未授权
function handleUnauthorized() {
  Modal.confirm({
    title: '登录已过期',
    content: '您的登录信息已过期,请重新登录',
    okText: '去登录',
    cancelText: '取消',
    onOk: () => {
      store.dispatch('user/logout')
      router.push('/login')
    }
  })
}

3.3 增强版封装(支持取消请求、重试)

// service/advanced.js
import axios from 'axios'
import qs from 'qs'

class AdvancedRequest {
  constructor() {
    this.service = axios.create({
      baseURL: process.env.VUE_APP_API_URL,
      timeout: 30000,
      paramsSerializer: params => {
        // 处理复杂参数序列化
        return qs.stringify(params, { indices: false })
      }
    })
    
    // 存储取消请求的控制器
    this.pendingRequests = new Map()
    
    this.setupInterceptors()
  }
  
  setupInterceptors() {
    // 请求拦截器
    this.service.interceptors.request.use(
      config => {
        // 添加取消请求功能
        this.addCancelToken(config)
        
        // 请求签名
        if (config.needSign) {
          config.data = this.signRequest(config)
        }
        
        // 加密敏感数据
        if (config.encrypt) {
          config.data = this.encryptData(config.data)
        }
        
        return config
      },
      error => Promise.reject(error)
    )
    
    // 响应拦截器
    this.service.interceptors.response.use(
      response => {
        // 请求完成后移除 pending 记录
        this.removePendingRequest(response.config)
        
        // 处理文件下载
        if (response.config.responseType === 'blob') {
          return this.handleFileResponse(response)
        }
        
        return response.data
      },
      error => {
        // 如果是取消请求,不处理错误
        if (axios.isCancel(error)) {
          console.log('请求已取消:', error.message)
          return Promise.reject(error)
        }
        
        // 移除 pending 记录
        if (error.config) {
          this.removePendingRequest(error.config)
        }
        
        return Promise.reject(error)
      }
    )
  }
  
  // 取消请求管理
  addCancelToken(config) {
    // 避免重复请求
    const requestKey = `${config.method}-${config.url}-${JSON.stringify(config.params)}-${JSON.stringify(config.data)}`
    
    // 如果已有相同请求,取消之前的请求
    if (this.pendingRequests.has(requestKey)) {
      const cancel = this.pendingRequests.get(requestKey)
      cancel('重复请求已取消')
      this.pendingRequests.delete(requestKey)
    }
    
    // 创建新的取消令牌
    config.cancelToken = new axios.CancelToken(cancel => {
      this.pendingRequests.set(requestKey, cancel)
    })
  }
  
  removePendingRequest(config) {
    const requestKey = `${config.method}-${config.url}-${JSON.stringify(config.params)}-${JSON.stringify(config.data)}`
    if (this.pendingRequests.has(requestKey)) {
      this.pendingRequests.delete(requestKey)
    }
  }
  
  // 取消所有请求
  cancelAllRequests() {
    this.pendingRequests.forEach(cancel => cancel('主动取消所有请求'))
    this.pendingRequests.clear()
  }
  
  // 带重试机制的请求
  async requestWithRetry(config, retries = 3) {
    let lastError
    
    for (let i = 0; i < retries; i++) {
      try {
        const response = await this.service(config)
        return response
      } catch (error) {
        lastError = error
        
        // 是否应该重试
        if (this.shouldRetry(error, i, retries)) {
          // 指数退避延迟
          const delay = Math.pow(2, i) * 1000
          console.log(`第${i + 1}次请求失败,${delay}ms后重试...`)
          await this.sleep(delay)
          continue
        }
        break
      }
    }
    
    throw lastError
  }
  
  shouldRetry(error, currentRetry, maxRetries) {
    // 只有特定错误才重试
    if (axios.isCancel(error)) return false
    
    // 网络错误或超时重试
    const retryableErrors = [
      'ECONNABORTED',  // 超时
      'ETIMEDOUT',      // 连接超时
      'ECONNREFUSED',   // 连接被拒绝
      'ECONNRESET',     // 连接重置
      'ENOTFOUND'       // DNS解析失败
    ]
    
    const shouldRetry = (
      currentRetry < maxRetries - 1 &&
      (error.code && retryableErrors.includes(error.code)) ||
      (error.response && error.response.status >= 500)
    )
    
    return shouldRetry
  }
  
  // 文件上传(支持进度)
  uploadFile(url, file, onProgress) {
    const formData = new FormData()
    formData.append('file', file)
    
    return this.service.post(url, formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      onUploadProgress: progressEvent => {
        if (onProgress) {
          const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
          onProgress(percentCompleted)
        }
      }
    })
  }
  
  // 文件下载
  async downloadFile(url, filename) {
    const response = await this.service.get(url, {
      responseType: 'blob'
    })
    
    // 创建下载链接
    const blob = new Blob([response.data])
    const downloadUrl = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = downloadUrl
    link.download = filename || 'download'
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    window.URL.revokeObjectURL(downloadUrl)
  }
  
  handleFileResponse(response) {
    const contentDisposition = response.headers['content-disposition']
    let filename = 'download'
    
    if (contentDisposition) {
      const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
      if (match && match[1]) {
        filename = match[1].replace(/['"]/g, '')
        // 处理中文文件名
        try {
          filename = decodeURIComponent(escape(filename))
        } catch (e) {
          console.error('文件名解码失败', e)
        }
      }
    }
    
    return {
      data: response.data,
      filename,
      type: response.headers['content-type']
    }
  }
  
  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms))
  }
  
  signRequest(config) {
    // 实现请求签名逻辑
    const timestamp = Date.now()
    const nonce = Math.random().toString(36).substring(7)
    const data = { ...config.data, timestamp, nonce }
    
    // 签名计算...
    // data.sign = generateSignature(data)
    
    return data
  }
  
  encryptData(data) {
    // 实现数据加密逻辑
    return data
  }
}

export default new AdvancedRequest()

四、业务层封装

4.1 API 模块化管理

// api/modules/user.js
import request from '@/service'

// 用户相关接口
export const userApi = {
  // 登录
  login(data) {
    return request.post('/auth/login', data, {
      needSign: true  // 需要签名
    })
  },
  
  // 登出
  logout() {
    return request.post('/auth/logout')
  },
  
  // 获取用户信息
  getUserInfo() {
    return request.get('/user/info', {}, {
      retry: 3  // 失败重试3次
    })
  },
  
  // 更新用户信息
  updateUserInfo(data) {
    return request.put('/user/info', data)
  },
  
  // 上传头像
  uploadAvatar(file, onProgress) {
    return request.uploadFile('/user/avatar', file, onProgress)
  },
  
  // 获取用户列表
  getUserList(params) {
    return request.get('/user/list', params, {
      cache: true  // 启用缓存
    })
  },
  
  // 导出用户数据
  exportUsers(params) {
    return request.get('/user/export', params, {
      responseType: 'blob'
    })
  }
}

// api/modules/product.js
export const productApi = {
  getProductList(params) {
    return request.get('/product/list', params)
  },
  
  getProductDetail(id) {
    return request.get(`/product/detail/${id}`)
  },
  
  createProduct(data) {
    return request.post('/product', data)
  },
  
  updateProduct(id, data) {
    return request.put(`/product/${id}`, data)
  },
  
  deleteProduct(id) {
    return request.delete(`/product/${id}`)
  }
}

// api/index.js
export { userApi } from './modules/user'
export { productApi } from './modules/product'

4.2 请求缓存管理

// service/cache.js
class RequestCache {
  constructor() {
    this.cache = new Map()
    this.maxAge = 5 * 60 * 1000 // 默认5分钟
  }
  
  // 生成缓存key
  generateKey(config) {
    return `${config.method}-${config.url}-${JSON.stringify(config.params)}-${JSON.stringify(config.data)}`
  }
  
  // 设置缓存
  set(key, data, maxAge = this.maxAge) {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      maxAge
    })
  }
  
  // 获取缓存
  get(key) {
    const cached = this.cache.get(key)
    if (!cached) return null
    
    // 检查是否过期
    if (Date.now() - cached.timestamp > cached.maxAge) {
      this.cache.delete(key)
      return null
    }
    
    return cached.data
  }
  
  // 清除缓存
  clear() {
    this.cache.clear()
  }
  
  // 清除指定缓存
  delete(key) {
    this.cache.delete(key)
  }
  
  // 清除匹配模式的缓存
  clearPattern(pattern) {
    const regex = new RegExp(pattern)
    for (const key of this.cache.keys()) {
      if (regex.test(key)) {
        this.cache.delete(key)
      }
    }
  }
}

export default new RequestCache()

4.3 在 Vue/React 中使用

// Vue 3 中使用
import { userApi } from '@/api'
import { ref, onMounted } from 'vue'

export default {
  setup() {
    const userList = ref([])
    const loading = ref(false)
    
    const fetchUserList = async () => {
      loading.value = true
      try {
        const res = await userApi.getUserList({
          page: 1,
          limit: 10
        })
        userList.value = res
      } catch (error) {
        console.error('获取用户列表失败:', error)
      } finally {
        loading.value = false
      }
    }
    
    onMounted(() => {
      fetchUserList()
    })
    
    return {
      userList,
      loading,
      fetchUserList
    }
  }
}

// React 中使用
import { useState, useEffect } from 'react'
import { userApi } from '@/api'

function UserList() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(false)
  
  useEffect(() => {
    const fetchUsers = async () => {
      setLoading(true)
      try {
        const data = await userApi.getUserList()
        setUsers(data)
      } catch (error) {
        console.error('Failed to fetch users:', error)
      } finally {
        setLoading(false)
      }
    }
    
    fetchUsers()
  }, [])
  
  return (
    <div>
      {loading ? <Spin /> : (
        <Table dataSource={users} />
      )}
    </div>
  )
}

五、高级功能实现

5.1 请求队列管理

// service/queue.js
class RequestQueue {
  constructor(concurrency = 5) {
    this.concurrency = concurrency
    this.queue = []
    this.running = 0
  }
  
  // 添加请求到队列
  add(request) {
    return new Promise((resolve, reject) => {
      this.queue.push({
        request,
        resolve,
        reject
      })
      this.next()
    })
  }
  
  // 执行下一个请求
  next() {
    while (this.running < this.concurrency && this.queue.length) {
      const { request, resolve, reject } = this.queue.shift()
      this.running++
      
      request()
        .then(resolve)
        .catch(reject)
        .finally(() => {
          this.running--
          this.next()
        })
    }
  }
  
  // 清空队列
  clear() {
    this.queue = []
    this.running = 0
  }
  
  // 获取队列状态
  getStatus() {
    return {
      queueLength: this.queue.length,
      running: this.running,
      concurrency: this.concurrency
    }
  }
}

export default RequestQueue

5.2 请求节流防抖

// service/throttle.js
class RequestThrottle {
  constructor() {
    this.pendingRequests = new Map()
  }
  
  // 防抖:最后一次请求有效
  debounce(key, fn, delay = 300) {
    if (this.pendingRequests.has(key)) {
      clearTimeout(this.pendingRequests.get(key))
    }
    
    const timeout = setTimeout(() => {
      fn()
      this.pendingRequests.delete(key)
    }, delay)
    
    this.pendingRequests.set(key, timeout)
  }
  
  // 节流:限制请求频率
  throttle(key, fn, limit = 1000) {
    const now = Date.now()
    const lastCall = this.pendingRequests.get(key)
    
    if (!lastCall || now - lastCall > limit) {
      fn()
      this.pendingRequests.set(key, now)
    }
  }
  
  // 取消所有待执行的请求
  cancelAll() {
    this.pendingRequests.forEach(timeout => {
      clearTimeout(timeout)
    })
    this.pendingRequests.clear()
  }
}

export default new RequestThrottle()

5.3 断网重连机制

// service/reconnect.js
class ReconnectManager {
  constructor(requestService) {
    this.requestService = requestService
    this.isOnline = navigator.onLine
    this.reconnectAttempts = 0
    this.maxReconnectAttempts = 5
    this.pendingRequests = []
    
    this.initEventListeners()
  }
  
  initEventListeners() {
    window.addEventListener('online', () => {
      this.handleOnline()
    })
    
    window.addEventListener('offline', () => {
      this.handleOffline()
    })
  }
  
  handleOnline() {
    console.log('网络已恢复,开始重连...')
    this.isOnline = true
    this.reconnectAttempts = 0
    
    // 重试所有待处理的请求
    this.processPendingRequests()
  }
  
  handleOffline() {
    console.log('网络已断开')
    this.isOnline = false
  }
  
  async processPendingRequests() {
    while (this.pendingRequests.length > 0) {
      const request = this.pendingRequests.shift()
      try {
        const result = await this.requestService(request.config)
        request.resolve(result)
      } catch (error) {
        request.reject(error)
      }
    }
  }
  
  // 添加请求到待处理队列
  addPendingRequest(config) {
    return new Promise((resolve, reject) => {
      this.pendingRequests.push({
        config,
        resolve,
        reject
      })
      
      // 尝试重新连接
      this.attemptReconnect()
    })
  }
  
  attemptReconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.log('重连失败次数过多,请手动刷新')
      return
    }
    
    this.reconnectAttempts++
    setTimeout(() => {
      if (navigator.onLine) {
        this.handleOnline()
      }
    }, Math.pow(2, this.reconnectAttempts) * 1000)
  }
}

六、错误处理与日志

6.1 统一错误处理

// service/errorHandler.js
class ErrorHandler {
  constructor() {
    this.errorListeners = []
  }
  
  // 处理错误
  handle(error, context = {}) {
    // 格式化错误信息
    const errorInfo = this.formatError(error, context)
    
    // 记录错误日志
    this.logError(errorInfo)
    
    // 触发错误监听器
    this.notifyListeners(errorInfo)
    
    // 根据错误类型进行处理
    this.processByType(errorInfo)
    
    return errorInfo
  }
  
  formatError(error, context) {
    return {
      timestamp: new Date().toISOString(),
      type: this.getErrorType(error),
      message: error.message,
      code: error.code,
      status: error.response?.status,
      url: error.config?.url,
      method: error.config?.method,
      params: error.config?.params,
      data: error.config?.data,
      stack: error.stack,
      context
    }
  }
  
  getErrorType(error) {
    if (error.response) {
      // 服务器返回错误状态码
      const status = error.response.status
      if (status >= 500) return 'SERVER_ERROR'
      if (status === 401) return 'UNAUTHORIZED'
      if (status === 403) return 'FORBIDDEN'
      if (status === 404) return 'NOT_FOUND'
      if (status >= 400) return 'CLIENT_ERROR'
    } else if (error.request) {
      // 请求已发送但没有响应
      return 'NETWORK_ERROR'
    } else {
      // 请求配置错误
      return 'CONFIG_ERROR'
    }
    return 'UNKNOWN_ERROR'
  }
  
  logError(errorInfo) {
    // 开发环境打印到控制台
    if (process.env.NODE_ENV === 'development') {
      console.group('❌ 请求错误')
      console.log('时间:', errorInfo.timestamp)
      console.log('类型:', errorInfo.type)
      console.log('信息:', errorInfo.message)
      console.log('状态码:', errorInfo.status)
      console.log('URL:', errorInfo.url)
      console.log('方法:', errorInfo.method)
      console.log('参数:', errorInfo.params)
      console.log('数据:', errorInfo.data)
      console.trace('堆栈:', errorInfo.stack)
      console.groupEnd()
    }
    
    // 生产环境发送到日志服务
    if (process.env.NODE_ENV === 'production') {
      this.sendToLogService(errorInfo)
    }
  }
  
  sendToLogService(errorInfo) {
    // 发送错误日志到服务器
    fetch('/api/log/error', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(errorInfo),
      keepalive: true // 即使页面卸载也发送
    }).catch(() => {
      // 静默失败
    })
  }
  
  processByType(errorInfo) {
    switch (errorInfo.type) {
      case 'UNAUTHORIZED':
        // 跳转到登录页
        this.redirectToLogin()
        break
      case 'SERVER_ERROR':
        // 显示服务器错误提示
        this.showErrorMessage('服务器开小差了,请稍后重试')
        break
      case 'NETWORK_ERROR':
        // 显示网络错误提示
        this.showErrorMessage('网络连接失败,请检查网络设置')
        break
      default:
        // 显示通用错误提示
        this.showErrorMessage('操作失败,请重试')
    }
  }
  
  addListener(listener) {
    this.errorListeners.push(listener)
  }
  
  notifyListeners(errorInfo) {
    this.errorListeners.forEach(listener => {
      try {
        listener(errorInfo)
      } catch (e) {
        console.error('Error listener failed:', e)
      }
    })
  }
  
  redirectToLogin() {
    // 跳转到登录页
    if (window.location.pathname !== '/login') {
      window.location.href = '/login'
    }
  }
  
  showErrorMessage(message) {
    // 使用UI库的提示组件
    if (window.$message) {
      window.$message.error(message)
    } else {
      alert(message)
    }
  }
}

export default new ErrorHandler()

6.2 请求监控

// service/monitor.js
class RequestMonitor {
  constructor() {
    this.metrics = {
      totalRequests: 0,
      successRequests: 0,
      failedRequests: 0,
      totalTime: 0,
      slowRequests: [],
      errorStats: {}
    }
    
    this.slowThreshold = 3000 // 慢请求阈值(ms)
  }
  
  // 记录请求开始
  startRequest(config) {
    const requestId = this.generateRequestId()
    const startTime = Date.now()
    
    config.metadata = {
      requestId,
      startTime
    }
    
    return config
  }
  
  // 记录请求结束
  endRequest(config, response, error) {
    const endTime = Date.now()
    const startTime = config.metadata?.startTime || endTime
    const duration = endTime - startTime
    
    // 更新总请求数
    this.metrics.totalRequests++
    
    if (error) {
      // 记录失败请求
      this.metrics.failedRequests++
      this.recordError(config, error)
    } else {
      // 记录成功请求
      this.metrics.successRequests++
      this.metrics.totalTime += duration
    }
    
    // 检查慢请求
    if (duration > this.slowThreshold) {
      this.recordSlowRequest(config, duration, error)
    }
    
    // 打印性能日志
    this.logPerformance(config, duration, error)
    
    // 清理metadata
    delete config.metadata
  }
  
  recordError(config, error) {
    const errorType = error.response?.status || 'NETWORK_ERROR'
    this.metrics.errorStats[errorType] = (this.metrics.errorStats[errorType] || 0) + 1
  }
  
  recordSlowRequest(config, duration, error) {
    this.metrics.slowRequests.push({
      url: config.url,
      method: config.method,
      duration,
      timestamp: new Date().toISOString(),
      success: !error,
      error: error?.message
    })
    
    // 保留最近100条慢请求记录
    if (this.metrics.slowRequests.length > 100) {
      this.metrics.slowRequests.shift()
    }
  }
  
  logPerformance(config, duration, error) {
    const status = error ? '❌' : '✅'
    const slow = duration > this.slowThreshold ? '🐢' : ''
    
    console.log(
      `${status} ${slow} [${config.method.toUpperCase()}] ${config.url} - ${duration}ms`
    )
  }
  
  generateRequestId() {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  }
  
  // 获取监控报告
  getReport() {
    const avgTime = this.metrics.successRequests > 0
      ? Math.round(this.metrics.totalTime / this.metrics.successRequests)
      : 0
    
    return {
      ...this.metrics,
      avgTime,
      successRate: this.metrics.totalRequests > 0
        ? `${Math.round((this.metrics.successRequests / this.metrics.totalRequests) * 100)}%`
        : '0%'
    }
  }
  
  // 重置监控数据
  reset() {
    this.metrics = {
      totalRequests: 0,
      successRequests: 0,
      failedRequests: 0,
      totalTime: 0,
      slowRequests: [],
      errorStats: {}
    }
  }
}

export default new RequestMonitor()

七、测试与调试

7.1 单元测试

// service/__tests__/request.test.js
import MockAdapter from 'axios-mock-adapter'
import request from '../index'
import axios from 'axios'

describe('Request Service', () => {
  let mock
  
  beforeEach(() => {
    mock = new MockAdapter(axios)
  })
  
  afterEach(() => {
    mock.reset()
  })
  
  test('should handle GET request successfully', async () => {
    const mockData = { id: 1, name: '张三' }
    mock.onGet('/api/user/1').reply(200, mockData)
    
    const result = await request.get('/api/user/1')
    expect(result).toEqual(mockData)
  })
  
  test('should handle request error', async () => {
    mock.onGet('/api/user/1').reply(500)
    
    await expect(request.get('/api/user/1')).rejects.toThrow()
  })
  
  test('should add token to headers', async () => {
    const token = 'test-token'
    localStorage.setItem('token', token)
    
    mock.onGet('/api/user').reply(config => {
      expect(config.headers.Authorization).toBe(`Bearer ${token}`)
      return [200, {}]
    })
    
    await request.get('/api/user')
  })
  
  test('should handle timeout', async () => {
    mock.onGet('/api/user').timeout()
    
    await expect(request.get('/api/user')).rejects.toThrow('timeout')
  }, 10000)
})

7.2 调试技巧

// 调试配置
if (process.env.NODE_ENV === 'development') {
  // 开启调试模式
  axios.defaults.debug = true
  
  // 拦截所有请求并打印详细信息
  axios.interceptors.request.use(config => {
    console.group(`🌐 请求 [${config.method}] ${config.url}`)
    console.log('参数:', config.params)
    console.log('数据:', config.data)
    console.log('头信息:', config.headers)
    console.groupEnd()
    return config
  })
  
  // 模拟慢网络
  if (process.env.VUE_APP_SLOW_NETWORK) {
    axios.interceptors.request.use(async config => {
      await new Promise(resolve => setTimeout(resolve, 2000))
      return config
    })
  }
  
  // 模拟随机失败
  if (process.env.VUE_APP_RANDOM_FAIL) {
    axios.interceptors.response.use(
      response => response,
      error => {
        if (Math.random() < 0.1) { // 10% 概率失败
          return Promise.reject(new Error('模拟网络错误'))
        }
        return Promise.reject(error)
      }
    )
  }
}

八、最佳实践总结

8.1 项目结构推荐

src/
├── api/
│   ├── modules/
│   │   ├── user.js
│   │   ├── product.js
│   │   └── order.js
│   ├── index.js
│   └── config.js
├── service/
│   ├── index.js           # 请求服务主入口
│   ├── interceptors.js    # 拦截器
│   ├── errorHandler.js    # 错误处理
│   ├── cache.js          # 缓存管理
│   ├── monitor.js        # 监控
│   └── utils.js          # 工具函数
└── utils/
    └── request.js        # 导出封装的请求方法

8.2 配置管理

// service/config.js
const config = {
  development: {
    baseURL: 'http://localhost:3000/api',
    timeout: 10000,
    withCredentials: true
  },
  test: {
    baseURL: 'https://test-api.example.com/api',
    timeout: 15000
  },
  production: {
    baseURL: 'https://api.example.com/api',
    timeout: 30000,
    withCredentials: true
  }
}

export default config[process.env.NODE_ENV || 'development']

8.3 安全建议

// 1. 防止 CSRF 攻击
axios.defaults.xsrfCookieName = 'csrf-token'
axios.defaults.xsrfHeaderName = 'X-CSRF-Token'

// 2. 敏感信息加密
import CryptoJS from 'crypto-js'

function encryptRequest(data) {
  const key = CryptoJS.enc.Utf8.parse(process.env.VUE_APP_SECRET_KEY)
  const encrypted = CryptoJS.AES.encrypt(JSON.stringify(data), key, {
    mode: CryptoJS.mode.ECB,
    padding: CryptoJS.pad.Pkcs7
  })
  return encrypted.toString()
}

// 3. HTTPS 强制
if (window.location.protocol !== 'https:' && process.env.NODE_ENV === 'production') {
  window.location.href = 'https://' + window.location.host + window.location.pathname
}

九、常见面试题

Q1: 如何取消重复请求?

// 使用 CancelToken
const cancelTokenSource = axios.CancelToken.source()

axios.get('/api/user', {
  cancelToken: cancelTokenSource.token
})

// 取消请求
cancelTokenSource.cancel('操作取消')

Q2: 如何实现请求缓存?

// 使用 Map 存储响应结果
const cache = new Map()

async function requestWithCache(url) {
  if (cache.has(url)) {
    return cache.get(url)
  }
  
  const response = await axios.get(url)
  cache.set(url, response.data)
  return response.data
}

Q3: 如何统一处理错误?

// 响应拦截器中统一处理
axios.interceptors.response.use(
  response => response,
  error => {
    if (error.response) {
      switch (error.response.status) {
        case 401:
          // 处理未授权
          break
        case 404:
          // 处理未找到
          break
        case 500:
          // 处理服务器错误
          break
      }
    }
    return Promise.reject(error)
  }
)

Q4: 如何监控请求性能?

// 使用 Performance API
const start = performance.now()

axios.get('/api/data').then(() => {
  const end = performance.now()
  console.log(`请求耗时: ${end - start}ms`)
})

十、总结

Axios 封装的核心要点:

  1. 统一配置:baseURL、超时、请求头等
  2. 拦截器:请求/响应拦截,统一处理 token、日志、错误
  3. 错误处理:分类处理、友好提示、日志记录
  4. 取消请求:防止重复提交、组件卸载时取消
  5. 重试机制:网络错误时自动重试
  6. 缓存管理:减少重复请求
  7. 监控告警:性能监控、错误上报
  8. 测试覆盖:单元测试保证稳定性

通过合理的封装,可以让项目中的网络请求更加健壮、可维护,提升开发效率和用户体验。

❌