Angular OnPush策略下仍触发插值调用,大列表性能优化求助
我最近在做一个需要创建超1000行表格的项目,结果性能拉胯得不行。为了复现问题,我搭了个极简Angular应用——就用Angular CLI生成的app.component组件,里面放了个可折叠div,还有用*ngFor生成的一堆div。
组件的HTML代码是这样的:
<div style="border:1px solid silver" (click)="toggleDiv()">TEST</div> <div #divToToggle [ngStyle]="divStyle"> </div> <div *ngFor="let row of rows">{{getName(row)}}</div>
组件类里我创建了100个带name属性的对象,模板里的getName方法会在控制台打日志,然后返回对象的name属性。另外还有个toggleDiv方法用来控制div的展开和折叠。
现在离谱的地方来了:每次点TEST div触发toggleDiv时,所有行的getName都会被调用一遍!这完全没必要啊,以后加个CSS动画啥的肯定会更卡。而且我明明已经用了ChangeDetectionStrategy.OnPush了,没任何数据变化的情况下,Angular为啥还要跑这些插值操作?
有没有办法能改改这个行为?我想要的是Angular渲染完表格后,除非组件输入参数变了,否则啥都别干。
测试过的Angular版本:4.4.4和5.1.3
组件的TypeScript代码如下:
@Component({ selector: 'app-root', templateUrl: './app.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class AppComponent { rows: any[]; expanded = false; divStyle: any; expandedStyle = { "height": "200px", "background-color": "orange" } collapsedStyle = { "display": "none" } constructor() { this.divStyle = this.expandedStyle; this.expanded = true; this.rows = this.createRows(100); } toggleDiv() { this.expanded = !this.expanded; if (this.expanded) { this.divStyle = this.expandedStyle; } else { this.divStyle = this.collapsedStyle; } } getName(row:any): string { console.log("getName called") return row.name; } private createRows(count:number): any[] { let rows: any[] = []; for (let i=0; i < count; i++) { rows.push({ name: "TEST " + (i+1) }) } return rows; } }
问题出在哪?
你可能以为用了OnPush就万事大吉,但其实OnPush的变更检测还是会在几种情况下触发:组件输入属性变化、组件内部触发了事件(比如你这个click事件)、手动调用ChangeDetectorRef的方法,或者异步操作完成(比如RxJS订阅回调)。
你点击TEST div触发toggleDiv属于组件内部事件,这会直接触发组件的变更检测周期。这时候Angular会遍历模板里的所有绑定——包括插值表达式里的getName方法,所以每次变更检测都会把所有行的getName调用一遍。
解决办法有这几个
1. 别在模板里直接调用方法
模板里的方法每次变更检测都会执行,最好的办法是提前把要显示的值计算好存在数组里。比如修改createRows的时候直接把名称存好,或者在构造函数里预处理:
constructor() { this.divStyle = this.expandedStyle; this.expanded = true; // 直接把需要显示的名称预存在对象里 this.rows = this.createRows(100).map(row => ({...row, displayName: row.name})); }
然后模板改成直接读属性:
<div *ngFor="let row of rows">{{row.displayName}}</div>
这样变更检测时只需要读取属性值,不会再执行额外的方法,性能自然就上去了。
2. 手动接管变更检测
如果实在要保留模板里的方法,或者有复杂逻辑,可以用ChangeDetectorRef手动控制变更检测的时机。先注入这个服务,初始化完成后停止自动检测,只有需要更新的时候再手动触发:
import { ChangeDetectorRef, OnInit } from '@angular/core'; export class AppComponent implements OnInit { constructor(private cdr: ChangeDetectorRef) { // ... 原有代码 } ngOnInit() { // 初始化完成后,关掉自动变更检测 this.cdr.detach(); } toggleDiv() { this.expanded = !this.expanded; this.divStyle = this.expanded ? this.expandedStyle : this.collapsedStyle; // 只在需要更新的时候手动触发一次变更检测 this.cdr.detectChanges(); } // 以后如果rows数据真的变了,再手动触发检测 updateRows(newCount: number) { this.rows = this.createRows(newCount); this.cdr.detectChanges(); } }
这样只有你主动调用detectChanges()的时候才会执行变更检测,其他时候不会触发,getName也就不会被无端调用了。
3. 用纯管道替代方法
把getName的逻辑做成纯管道,纯管道只有当输入参数的引用变化时才会重新计算,变更检测时如果输入没变,会直接返回缓存的结果:
import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'rowName' }) export class RowNamePipe implements PipeTransform { transform(row: any): string { console.log("Pipe called"); return row.name; } }
然后模板里改成用管道:
<div *ngFor="let row of rows">{{row | rowName}}</div>
点击toggleDiv时,rows的引用没变化,所以管道不会重新执行,也就不会打印日志了。
内容的提问来源于stack exchange,提问作者Dieter Rehbein

