组件运行展示

一、前言
表格组件是 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 统计各部门和状态的数量。
十一、总结
表格组件的深度使用需要关注以下几点:
- 选择状态管理:使用 Map 保持跨页选择状态,使用 trackByFn 追踪行标识
- 排序和过滤:正确的操作顺序是先排序,再过滤,最后分页
- 列管理:使用列配置对象实现动态列可见性控制
- 性能优化:使用 trackByFn、getter 缓存、避免模板中调用方法
- 用户体验:提供全选、批量操作、数据导出等功能
掌握这些技巧,你就能开发出高效、易用的表格组件,提升用户体验和开发效率。
相关资源: