Angular由一个bug说起之二十:Table lazy load:防止重复渲染

目录
- Angular 使用 lazy load 方式渐进加载表格造成的重排重绘问题
- 造成重复渲染的原因
2.1 变更检测机制触发频繁更新
2.2 数据引用变化导致重排重绘
2.3 缺乏 TrackBy
2.4 父组件状态变更波及子组件 - 解决方案
3.1 使用 trackBy 函数优化 *ngFor
3.2 合理使用 OnPush 变更检测策略
3.3 利用 Signals 实现响应式更新
3.4 实现懒加载与虚拟滚动 - 结合实际项目
- 总结与最佳实践建议
在 Angular 应用中,表格(Table)是展示结构化数据最常用的组件之一。随着 Angular 19 引入更强大的响应式编程模型(尤其是 Signals 的全面推广),开发者拥有了更精细的控制能力来优化性能。然而,若未合理使用这些新特性,表格仍可能因重复渲染导致性能瓶颈,尤其在处理大量数据或高频更新场景下。那么为了优化表格的渲染,lazy load 就是一个很好的解决方案。但是在 Angular 环境下,table 配合 lazy load 又会出现新的问题。主要集中在 Angular 的数据更新逻辑和视图的重排重绘上。
Angular 使用 lazy load 方式渐进加载表格造成的重排重绘问题
当我们在 Angular 的 table 组件 引入 lazy load 后常会遇到如下问题:
- 每次从 API 获取新数据,即使已经展示的内容未变,表格整体会闪烁或重新渲染
- 用户执行搜索、排序等操作时,整个表格被重建
- 表格行内包含复杂组件(如按钮、下拉框),每次父组件状态变化都会导致这些子组件重新实例化
- 数据量随着渐进式的加载越来越大(如 1000+ 行)时,页面卡顿、滚动不流畅
这些问题不仅影响用户体验,还可能引发内存泄漏或程序崩溃。
重排重绘的原因
变更检测机制触发频繁更新
Angular 默认使用 Zone.js 监听异步事件(如 HTTP 请求、setTimeout),并在事件完成后触发全局变更检测。这意味着即使只有表格数据变化,整个组件树都可能被检查。
数据引用变化导致重排重绘
在 Angular 中,*ngFor 默认通过对象引用判断是否需要更新 DOM。如果每次赋值都创建新数组(如 this.data = […oldData]),即使内容相同,Angular 也会认为数据已变,从而重建所有行。
// 每次都创建新数组 → 触发全量重渲染
this.data = this.data.map(item => ({ ...item }));
缺乏 TrackBy
未提供 trackBy 函数时,Angular 无法识别哪些行是“相同”的,只能按索引匹配,导致不必要的 DOM 操作。
父组件状态变更波及子组件
若表格组件未使用 OnPush 策略,父组件的任意状态变更(如 loading、theme)都会触发子表格的变更检测,即使其输入未变。
解决方案
对 table 进行分页处理
通过使用 pipe 和指定当前页码的方式将大的数据分页,保证一次渲染的数据不会太多
// html
<table [data]="allData | Pagination : currentPage">
...
</table>
*使用 trackBy 函数优化 ngFor
trackBy 帮助 Angular 识别列表中的唯一项,避免因顺序或引用变化导致的无效重渲染。这样已经加载的数据就不会被渲染。
// component.ts
trackByFn(index: number, item: any): any {return item.id; // 使用唯一 ID 作为标识
}
<!-- template.html -->
<tr *ngFor="let row of data; trackBy: trackByFn"><td>{{ row.name }}</td>
</tr>
根据我们返回的唯一标识,当某行数据真正变化或新增/删除时,才更新对应 DOM。
合理使用 OnPush 变更检测策略
将表格组件的变更检测策略设为 OnPush,使其仅在输入属性引用变化或事件触发时才检查。
@Component({selector: 'app-data-table',templateUrl: './data-table.component.html',changeDetection: ChangeDetectionStrategy.OnPush
})
export class DataTableComponent {@Input() data: any[];
}
注意:使用 OnPush 时,必须确保输入数据是新引用的数据,否则 Angular 无法检测到变化。
利用 Signals 实现响应式更新
Angular 19 的 Signals,它比传统 RxJS 更轻量、更精确。可实现仅在相关数据变化时更新特定部分。
// 使用信号管理表格数据
dataSignal = signal<any[]>([]);
filteredData = computed(() => {return this.dataSignal().filter(item => item.active);
});// 可以在模板中直接绑定信号
// <tr *ngFor="let row of filteredData(); trackBy: trackByFn">
这样做有以下优势:
- 无需手动触发变更检测
- 依赖自动追踪,仅更新受影响的视图
- 与 OnPush 兼容
实现懒加载与虚拟滚动
对于大数据表格,应避免一次性渲染所有行。可结合 @angular/cdk 的 CdkVirtualScrollViewport 实现虚拟滚动。
// 安装:npm install @angular/cdk
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
<cdk-virtual-scroll-viewport itemSize="50" class="table-viewport"><table><tr *cdkVirtualFor="let row of data; trackBy: trackByFn"><td>{{ row.name }}</td></tr></table>
</cdk-virtual-scroll-viewport>
仅渲染可视区域内的行(如 20 行),即使数据有 10,000 条,DOM 节点数量也保持恒定。
但是它有一个很难解决的问题,itemSize 必须是确定值,也就是说 行高 要保持一个静态值不变。否则虚拟滚动无法计算要渲染的行的总高度,会造成滚动时上下跳动的情况。
结合实际项目
我们公司的项目中普遍使用 table 来展示和管理数据,这对性能是很大的一个考验。
一开始我们就使用了分页和虚拟滚动的方式来优化,但是也出现了一些问题。
- 行高不固定,虚拟滚动经常失灵
- group 分组,展开的时候要考虑到分页功能。即便有 trackBy 也需要渲染展开行下面的数据。
结合上述情况,我们使用了 lazy load 的方式:当滚动到底部后再发起请求获取更多数据。
这也有几个问题:
- 数据的数量需要先确定,方便分页
- 监听滚动带来了一些性能损耗(监听函数内部逻辑太复杂)
- 更新数据后 UI 不更新,因为启用 OnPush 后数据的引用没变,更改数据引用又会造成整体的重绘
- 数据在 View 层调用 methods 来处理
针对上述问题我们重构了很多遍 table 组件。
- 首先就是使用 trackBy 控制数据渲染
- 减少外部父组件的数据引用更新(减少 cloneDeep)
- 分页显示的数据更少
- 减少监听滚动事件的代码逻辑
- 弃用 虚拟滚动,因为高度不固定,为了计算出准确高度反而会造成性能损耗
在完成上述操作后我们的 table 组件有了明显的性能提升
下一步我们计划使用 Signals 对数据做细颗粒化管理,明确控制数据的更新,减少数据的备份,降低实现业务的代码复杂度。
总结与最佳实践建议
在 Angular 19 中,我们有了更多的优化选择。但是它们有时也会带来新的问题。正如文中提到的, lazy load 渐进加载表格原本是为提高 table 的性能,但是它同时也存在重排重绘问题。为解决这个问题,以下实践都能有效对 table lazy load 进行优化:
- 始终为 ngFor 提供 trackBy 函数,使用唯一 ID 而非索引
- 对组件启用 OnPush 策略,减少不必要的变更检测
- 优先使用 Signals 管理 table data
- 如果 table row 是固定高度的,应当引入虚拟滚动
- 使用数据分页限制渲染数据的数量
- 避免在模板中调用方法(如 {{ getFullName(row) }}),改用 computed 信号或管道
通过以上措施,可以对 table lazy load 进行优化,提升应用整体性能与用户体验。
