DevUI高频组件(表格组件)深度用法与避坑指南

组件运行展示

一、前言

表格组件是 Web 应用中最常见的高频组件,几乎每个管理系统都离不开它。然而,很多开发者只会基础使用,导致代码冗余、性能低下、用户体验差。本文通过 DevUI DataTable 的实战案例,深入讲解表格组件的进阶用法和常见坑点。


二、核心概念:checkOptions 深度使用

2.1 什么是 checkOptions?

checkOptions 是 DataTable 组件的核心配置对象,用于控制行选择的行为。最关键的属性是 trackByFn,它决定了选择状态的追踪方式。

typescript 复制代码
// checkOptions 配置 - 深度使用的核心
checkOptions: any = {
  trackByFn: (row: Employee) => row.id
};

说明trackByFn 函数返回每行的唯一标识符(通常是 ID)。Angular 使用这个标识符来追踪行的选择状态,即使数据重新排序或过滤后,选中状态也能正确保持。这是避免选择状态丢失的关键。

2.2 跨页选择状态保持

常见坑点:切换分页后,之前选中的行状态丢失。

typescript 复制代码
// 跨页选中存储
selectedIdsMap = new Map<number, Employee>();

loadPageData(): void {
  // 应用过滤
  let filteredData = this.applyFilters();
  
  // 更新总数
  this.pager.total = filteredData.length;
  
  // 分页
  const start = (this.pager.pageIndex - 1) * this.pager.pageSize;
  const end = start + this.pager.pageSize;
  this.dataSource = filteredData.slice(start, end).map(item => ({
    ...item,
    $checked: this.selectedIdsMap.has(item.id)
  }));
  this.updateCheckAllStatus();
}

说明 :使用 Map 数据结构存储所有选中行的 ID,而不是仅存储当前页的选择状态。加载新页面时,通过 this.selectedIdsMap.has(item.id) 检查该行是否曾被选中,从而恢复选择状态。这样即使用户切换分页、排序或过滤,跨页选择状态也能完整保持。

2.3 全选/半选状态管理

常见坑点:全选功能不支持禁用行,导致禁用行也被选中。

typescript 复制代码
// 全选/取消全选
onCheckAllChange(checked: boolean): void {
  this.pageAllChecked = checked;
  this.dataSource.forEach(row => {
    if (!row.disabled) {
      row.$checked = checked;
      if (checked) {
        this.selectedIdsMap.set(row.id, row);
      } else {
        this.selectedIdsMap.delete(row.id);
      }
    }
  });
  this.halfChecked = false;
}

// 更新全选状态
updateCheckAllStatus(): void {
  const checkableRows = this.dataSource.filter(row => !row.disabled);
  const checkedRows = checkableRows.filter(row => row.$checked);
  
  if (checkedRows.length === 0) {
    this.pageAllChecked = false;
    this.halfChecked = false;
  } else if (checkedRows.length === checkableRows.length) {
    this.pageAllChecked = true;
    this.halfChecked = false;
  } else {
    this.pageAllChecked = false;
    this.halfChecked = true;
  }
}

说明 :在全选逻辑中,必须检查 !row.disabled 来跳过禁用行。同时,计算全选状态时,只考虑可选择的行(非禁用行),这样才能正确显示全选、半选、未选状态。


三、排序功能的正确实现

3.1 三态排序机制

常见坑点:只支持升序和降序,无法取消排序。

typescript 复制代码
// 表头排序处理
onHeaderSort(field: string): void {
  // 如果点击同一列,切换排序方向;否则重新排序
  if (this.sortField === field) {
    if (this.sortDirection === 'ASC') {
      this.sortDirection = 'DESC';
    } else if (this.sortDirection === 'DESC') {
      this.sortDirection = null;
      this.sortField = '';
    } else {
      this.sortDirection = 'ASC';
    }
  } else {
    this.sortField = field;
    this.sortDirection = 'ASC';
  }

  // 执行排序
  if (this.sortDirection && this.sortField) {
    const direction = this.sortDirection;
    this.allData.sort((a: any, b: any) => {
      const valA = a[field];
      const valB = b[field];
      
      if (typeof valA === 'number') {
        return direction === 'ASC' ? valA - valB : valB - valA;
      }
      return direction === 'ASC' 
        ? String(valA).localeCompare(String(valB))
        : String(valB).localeCompare(String(valA));
    });
  }
  
  this.pager.pageIndex = 1; // 排序后回到第一页
  this.loadPageData();
}

说明 :实现三态排序需要追踪当前排序字段和方向。点击同一列头时循环切换:升序 → 降序 → 无排序。注意要区分数字和字符串排序方式,数字用减法,字符串用 localeCompare。排序后必须重置分页到第一页,避免用户看到空白页面。

3.2 排序与过滤的配合

常见坑点:排序后应用过滤,导致排序结果被破坏。

typescript 复制代码
// 应用过滤条件
applyFilters(): Employee[] {
  return this.allData.filter(item => {
    // 搜索过滤
    if (this.searchText) {
      const searchLower = this.searchText.toLowerCase();
      const matchesSearch = 
        item.name.toLowerCase().includes(searchLower) ||
        item.position.toLowerCase().includes(searchLower);
      if (!matchesSearch) return false;
    }

    // 部门过滤
    if (this.departmentFilter && item.department !== this.departmentFilter) {
      return false;
    }

    // 状态过滤
    if (this.statusFilter && item.status !== this.statusFilter) {
      return false;
    }

    return true;
  });
}

loadPageData(): void {
  // 应用过滤
  let filteredData = this.applyFilters();
  
  // 更新总数
  this.pager.total = filteredData.length;
  
  // 分页
  const start = (this.pager.pageIndex - 1) * this.pager.pageSize;
  const end = start + this.pager.pageSize;
  this.dataSource = filteredData.slice(start, end).map(item => ({
    ...item,
    $checked: this.selectedIdsMap.has(item.id)
  }));
  this.updateCheckAllStatus();
}

说明 :关键是操作顺序:先对 allData 排序,再在 loadPageData 中应用过滤。这样排序结果不会被过滤破坏。过滤函数应该是纯函数,只负责筛选数据,不修改原数据。


四、搜索和过滤的最佳实践

4.1 多条件过滤实现

typescript 复制代码
// 搜索和筛选
searchText: string = '';
departmentFilter: string = '';
statusFilter: string = '';
departments: string[] = [];
statuses: string[] = ['在职', '试用期', '离职'];

onSearchChange(): void {
  this.pager.pageIndex = 1;
  this.loadPageData();
}

onDepartmentFilterChange(): void {
  this.pager.pageIndex = 1;
  this.loadPageData();
}

onStatusFilterChange(): void {
  this.pager.pageIndex = 1;
  this.loadPageData();
}

resetFilters(): void {
  this.searchText = '';
  this.departmentFilter = '';
  this.statusFilter = '';
  this.pager.pageIndex = 1;
  this.loadPageData();
}

说明 :每次过滤条件改变时,必须重置分页到第一页,否则用户可能看到空白页面。提供 resetFilters 方法让用户一键清除所有过滤条件,提升用户体验。

4.2 避免重复过滤

常见坑点:在模板中频繁调用过滤函数,导致性能问题。

typescript 复制代码
// 获取选中行数
get selectedCount(): number {
  return this.selectedIdsMap.size;
}

说明 :在模板中调用 applyFilters() 会导致每次变化检测都重新过滤数据,性能极差。应该在组件中创建 getter 直接访问属性,避免重复计算。


五、列可见性控制

5.1 动态列配置

typescript 复制代码
// 列配置 - 深度使用:列可见性控制
columns: ColumnConfig[] = [
  { field: 'id', label: 'ID', visible: true, sortable: true },
  { field: 'name', label: '姓名', visible: true, sortable: true },
  { field: 'department', label: '部门', visible: true, sortable: false },
  { field: 'position', label: '职位', visible: true, sortable: false },
  { field: 'salary', label: '薪资', visible: true, sortable: true },
  { field: 'joinDate', label: '入职日期', visible: true, sortable: true },
  { field: 'status', label: '状态', visible: true, sortable: false },
  { field: 'actions', label: '操作', visible: true, sortable: false }
];

toggleColumnVisibility(field: string): void {
  const column = this.columns.find(col => col.field === field);
  if (column) {
    column.visible = !column.visible;
  }
}

isColumnVisible(field: string): boolean {
  const column = this.columns.find(col => col.field === field);
  return column ? column.visible : true;
}

getVisibleColumns(): ColumnConfig[] {
  return this.columns.filter(col => col.visible);
}

说明 :使用 ColumnConfig 接口定义列的元数据,包括字段名、标签、可见性和是否可排序。通过 isColumnVisible 在模板中条件渲染列,通过 getVisibleColumns 获取可见列列表,用于导出等操作。

5.2 在模板中使用列可见性

html 复制代码
<!-- 复选框列 -->
<d-column
  *ngIf="isColumnVisible('checkbox')"
  field="$checked"
  header=""
  [width]="'50px'">
</d-column>

<!-- ID 列 -->
<d-column
  *ngIf="isColumnVisible('id')"
  field="id"
  header="ID"
  [width]="'80px'"
  [sortable]="true">
  <ng-template let-rowItem>
    {{ rowItem.id }}
  </ng-template>
</d-column>

说明 :使用 *ngIf="isColumnVisible('columnName')" 条件渲染列。这样用户可以通过复选框动态显示或隐藏列,提升表格的灵活性。


六、行内编辑的实现

6.1 编辑状态管理

typescript 复制代码
// ========== 行内编辑相关方法 ==========

editRow(row: Employee): void {
  row.editing = true;
}

saveRow(row: Employee): void {
  row.editing = false;
  // 同步到 allData
  const index = this.allData.findIndex(item => item.id === row.id);
  if (index > -1) {
    this.allData[index] = { ...row };
  }
}

cancelEdit(row: Employee): void {
  row.editing = false;
  this.loadPageData(); // 重新加载恢复原数据
}

说明 :使用 editing 属性标记行是否处于编辑模式。保存时需要同步到 allData 以保证数据一致性。取消编辑时重新加载页面数据,丢弃用户的修改。

6.2 在模板中切换编辑模式

html 复制代码
<!-- 姓名列 -->
<d-column
  *ngIf="isColumnVisible('name')"
  field="name"
  header="姓名"
  [width]="'120px'"
  [sortable]="true">
  <ng-template let-rowItem>
    <span *ngIf="!rowItem.editing">{{ rowItem.name }}</span>
    <d-text-input *ngIf="rowItem.editing" [(ngModel)]="rowItem.name"></d-text-input>
  </ng-template>
</d-column>

<!-- 操作列 -->
<d-column
  *ngIf="isColumnVisible('actions')"
  field="actions"
  header="操作"
  [width]="'150px'">
  <ng-template let-rowItem>
    <div class="actions">
      <ng-container *ngIf="!rowItem.editing">
        <d-button bsStyle="text" (click)="editRow(rowItem)">编辑</d-button>
        <d-button bsStyle="text" class="danger-btn" (click)="deleteRow(rowItem)">删除</d-button>
      </ng-container>
      <ng-container *ngIf="rowItem.editing">
        <d-button bsStyle="text" (click)="saveRow(rowItem)">保存</d-button>
        <d-button bsStyle="text" (click)="cancelEdit(rowItem)">取消</d-button>
      </ng-container>
    </div>
  </ng-template>
</d-column>

说明 :在模板中根据 editing 状态显示不同的内容。非编辑模式显示文本和编辑/删除按钮,编辑模式显示输入框和保存/取消按钮。使用 [(ngModel)] 实现双向绑定,用户输入的内容实时同步到数据对象。


七、批量操作和数据导出

7.1 批量删除

typescript 复制代码
batchDelete(): void {
  if (this.selectedCount === 0) return;
  
  const selectedIds = new Set(this.selectedIdsMap.keys());
  this.allData = this.allData.filter(item => !selectedIds.has(item.id));
  this.pager.total = this.allData.length;
  
  // 调整页码
  const maxPage = Math.ceil(this.pager.total / this.pager.pageSize);
  if (this.pager.pageIndex > maxPage) {
    this.pager.pageIndex = Math.max(1, maxPage);
  }
  
  this.clearSelection();
  this.loadPageData();
}

说明 :批量删除时,先将选中的 ID 转换为 Set 以提高查询效率,然后过滤 allData 删除这些行。删除后需要调整分页:如果当前页码超过最大页码,应该跳转到最后一页。最后清空选择状态并重新加载页面数据。

7.2 数据导出

typescript 复制代码
exportToCSV(): void {
  const filteredData = this.applyFilters();
  const headers = this.getVisibleColumns().map(col => col.label).join(',');
  const rows = filteredData.map(item => {
    return this.getVisibleColumns().map(col => {
      const value = (item as any)[col.field];
      // 处理包含逗号的值
      return typeof value === 'string' && value.includes(',') 
        ? `"${value}"` 
        : value;
    }).join(',');
  });

  const csv = [headers, ...rows].join('\n');
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
  const link = document.createElement('a');
  const url = URL.createObjectURL(blob);
  link.setAttribute('href', url);
  link.setAttribute('download', `employees_${new Date().getTime()}.csv`);
  link.style.visibility = 'hidden';
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
}

exportToJSON(): void {
  const filteredData = this.applyFilters();
  const dataToExport = filteredData.map(item => {
    const obj: any = {};
    this.getVisibleColumns().forEach(col => {
      obj[col.label] = (item as any)[col.field];
    });
    return obj;
  });

  const json = JSON.stringify(dataToExport, null, 2);
  const blob = new Blob([json], { type: 'application/json;charset=utf-8;' });
  const link = document.createElement('a');
  const url = URL.createObjectURL(blob);
  link.setAttribute('href', url);
  link.setAttribute('download', `employees_${new Date().getTime()}.json`);
  link.style.visibility = 'hidden';
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
}

说明 :导出时只导出过滤后的数据和可见列,这样用户看到什么就导出什么。CSV 导出需要处理包含逗号的值,用双引号包围。使用 Blob API 和动态创建 <a> 标签实现文件下载,下载完成后清理 DOM 和 URL 对象。


八、性能优化

8.1 trackByFn 优化列表渲染

typescript 复制代码
trackByFn(_index: number, item: Employee): number {
  return item.id;
}

说明 :在 *ngFor 中使用 trackByFn 告诉 Angular 如何追踪列表项。不使用 trackByFn 时,Angular 会根据对象引用判断是否需要重新渲染,导致整个列表重新渲染。使用 trackByFn 返回唯一标识符后,Angular 只会更新改变的项,大幅提升性能。

8.2 checkOptions 中的 trackByFn

typescript 复制代码
checkOptions: any = {
  trackByFn: (row: Employee) => row.id
};

说明 :DataTable 的 checkOptions.trackByFn 用于追踪行的选择状态。即使行的其他属性改变,只要 ID 相同,选择状态就能正确保持。这是避免选择状态丢失的关键。

8.3 避免在模板中调用方法

常见坑点:在模板中频繁调用方法导致性能问题。

typescript 复制代码
// 获取选中行数
get selectedCount(): number {
  return this.selectedIdsMap.size;
}

说明:模板中的表达式会在每次变化检测时重新计算。如果是方法调用,会导致方法被频繁执行,性能极差。应该使用 getter 直接访问属性,避免重复计算。


九、常见坑点总结

坑点 原因 解决方案
切换分页后选择状态丢失 只存储当前页的选择状态 使用 Map 存储所有选中行的 ID
全选包含禁用行 没有检查 disabled 属性 在全选逻辑中跳过禁用行
排序后过滤结果错乱 操作顺序错误 先排序 allData,再过滤,最后分页
性能下降 频繁调用过滤函数 使用 getter 缓存结果
列表重新渲染 没有使用 trackByFn 在 *ngFor 中添加 trackByFn
编辑后数据不同步 没有同步到原数据 保存时更新 allData
导出数据不完整 导出全部数据而不是过滤后的 导出前应用过滤条件

十、完整示例

10.1 组件初始化

typescript 复制代码
ngOnInit(): void {
  this.initData();
}

initData(): void {
  const departments = ['技术部', '产品部', '设计部', '市场部', '人事部', '财务部'];
  const positions = ['工程师', '高级工程师', '技术经理', '产品经理', '设计师', '总监'];
  const statuses = ['在职', '试用期', '离职'];

  this.departments = departments;

  this.allData = Array.from({ length: 53 }, (_, i) => ({
    id: i + 1,
    name: `员工${i + 1}`,
    department: departments[i % departments.length],
    position: positions[i % positions.length],
    salary: Math.floor(Math.random() * 30000) + 10000,
    joinDate: `2023-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
    status: statuses[i % statuses.length],
    disabled: i % 10 === 0 // 每10条设置一条为禁用
  }));

  this.pager.total = this.allData.length;
  this.loadPageData();
}

说明 :在 ngOnInit 中初始化数据。生成 53 条测试数据,包括各种部门、职位和状态。设置每 10 条数据中的第一条为禁用状态,用于测试禁用行的处理。

10.2 统计功能

typescript 复制代码
// ========== 统计相关方法 ==========

getTotalSalary(): number {
  return this.applyFilters().reduce((sum, item) => sum + item.salary, 0);
}

getAverageSalary(): number {
  const filtered = this.applyFilters();
  return filtered.length > 0 ? Math.round(this.getTotalSalary() / filtered.length) : 0;
}

getDepartmentStats(): { [key: string]: number } {
  const stats: { [key: string]: number } = {};
  this.applyFilters().forEach(item => {
    stats[item.department] = (stats[item.department] || 0) + 1;
  });
  return stats;
}

getStatusStats(): { [key: string]: number } {
  const stats: { [key: string]: number } = {};
  this.applyFilters().forEach(item => {
    stats[item.status] = (stats[item.status] || 0) + 1;
  });
  return stats;
}

说明 :统计功能基于过滤后的数据,这样用户看到的统计数据与表格显示的数据一致。使用 reduce 计算总和,使用对象作为 map 统计各部门和状态的数量。


十一、总结

表格组件的深度使用需要关注以下几点:

  1. 选择状态管理:使用 Map 保持跨页选择状态,使用 trackByFn 追踪行标识
  2. 排序和过滤:正确的操作顺序是先排序,再过滤,最后分页
  3. 列管理:使用列配置对象实现动态列可见性控制
  4. 性能优化:使用 trackByFn、getter 缓存、避免模板中调用方法
  5. 用户体验:提供全选、批量操作、数据导出等功能

掌握这些技巧,你就能开发出高效、易用的表格组件,提升用户体验和开发效率。


相关资源

相关推荐
500841 天前
鸿蒙 Flutter 超级终端适配:多设备流转与状态无缝迁移
java·人工智能·flutter·华为·性能优化·wpf
song5011 天前
鸿蒙 Flutter 应用签名:证书配置与上架实战
人工智能·分布式·python·flutter·华为·开源鸿蒙
gis分享者1 天前
2023A卷,找出通过车辆最多颜色
华为·od·2023·颜色·a·车辆
L、2181 天前
状态共享新范式:在 Flutter + OpenHarmony 应用中实现跨框架状态同步(Riverpod + ArkState)
javascript·华为·智能手机·electron·harmonyos
极客范儿1 天前
华为HCIP网络工程师认证—数据链路层与MAC地址
网络·华为
中国云报1 天前
从数据贯通到智能重构:华为工业AI平台的进化之路
人工智能·华为·重构
晚霞的不甘1 天前
[鸿蒙2025领航者闯关]鸿蒙实战进阶:多端协同与性能优化实践心得
华为·性能优化·harmonyos
爱吃大芒果1 天前
GitCode口袋工具的部署运行教程
flutter·华为·harmonyos·gitcode
爱吃大芒果1 天前
Flutter基础入门与核心能力构建——Widget、State与BuildContext核心解析
flutter·华为·harmonyos