如何在依赖Router Event Observable的代码中避免ExpressionChangedAfterItHasBeenCheckedError
问题
我已经阅读多篇关于ExpressionChangedAfterItHasBeenCheckedError的文章,理解其产生原因及临时解决方法,但不知道如何妥善解决当前问题,希望得到建议。
我的代码逻辑是基于Router路径渲染分类列表:当路径为/music/foo时,路由解析器获取Foo的所有子分类并渲染;点击子分类bar后,路径变为/music/foo/bar,路由解析器获取bar的所有子分类。这部分功能正常,但新增的面包屑组件有一个名为path的输入属性,初始值为undefined,之后在Router Events订阅中赋值,代码如下:
export class CategoriesComponent { @ViewChild('table') table: MatTable<Category>; dataSource = new MatTableDataSource<Category>([]); // get media based on finished route event. The data should be there because resolver is blocking media$ = this.router.events .pipe( filter((event: Event) => event instanceof NavigationEnd), startWith(this.router), map(() => this.appState.get<CategoryCollection>('categoryCollection')), tap(categories => { const { path, type } = extractMediaPath(this.router.url); // to prevent expressionhaschanged error until I have a better solution setTimeout(() => { this.path = `${type}${path}`.replace(/\/$/, ''); }); console.log(path); const items = this.sort(categories[type], path); if (items.length && items[0].media) { this.allowAddCategory = false; this.displayedColumns = type === CategoryType.MUSIC ? [...MEDIA_MUSIC_COLUMNS] : [...MEDIA_VIDEO_COLUMNS]; } else { this.allowAddCategory = true; this.displayedColumns = [...CATEGORY_COLUMNS]; } this.dataSource.data = items; }) );
使用setTimeout能临时避免错误,但我并不满意,且this.allowAddCategory = true / false也引发了同样的错误,总不能一直用setTimeout包裹代码。
我尝试理解错误原因并重构代码:media$通过模板中的*ngIf加async管道订阅。显然DOM在Router Events订阅完成前就已渲染,这可能触发了ExpressionChanged错误。但path和allowAddCategory的真实值只能在事件订阅体内计算,之前没有足够信息确定其值。请问针对该场景的最优解决方案是什么?
最优解决方案
1. 将状态转换为可观察对象,通过Async管道绑定
核心思路是把path、allowAddCategory这些需要动态计算的属性,都转化为可观察对象,完全通过Async管道在模板中绑定,避免直接修改组件实例属性导致的变更检测冲突。
修改代码如下:
export class CategoriesComponent { @ViewChild('table') table: MatTable<Category>; dataSource = new MatTableDataSource<Category>([]); // 合并所有需要计算的状态为一个可观察对象 mediaState$ = this.router.events .pipe( filter((event: Event) => event instanceof NavigationEnd), startWith(this.router), map(() => this.appState.get<CategoryCollection>('categoryCollection')), map(categories => { const { path, type } = extractMediaPath(this.router.url); const formattedPath = `${type}${path}`.replace(/\/$/, ''); const items = this.sort(categories[type], path); return { items, path: formattedPath, allowAddCategory: !(items.length && items[0].media), displayedColumns: items.length && items[0].media ? (type === CategoryType.MUSIC ? [...MEDIA_MUSIC_COLUMNS] : [...MEDIA_VIDEO_COLUMNS]) : [...CATEGORY_COLUMNS] }; }), tap(state => { // 仅在这里更新dataSource,因为它不是模板绑定的直接属性 this.dataSource.data = state.items; }) ); }
模板中使用Async管道绑定:
<!-- 面包屑组件 --> <breadcrumb [path]="(mediaState$ | async)?.path"></breadcrumb> <!-- 控制添加按钮显示 --> <button *ngIf="(mediaState$ | async)?.allowAddCategory">添加分类</button> <!-- 表格列绑定 --> <mat-table [dataSource]="dataSource" [columns]="(mediaState$ | async)?.displayedColumns"> <!-- 列定义 --> </mat-table>
2. 利用路由快照或激活路由获取数据,避免依赖Router Events
既然你已经使用了路由解析器,那么解析后的数据会直接存在ActivatedRoute的snapshot或data属性中,可以直接订阅ActivatedRoute.data来获取分类数据,无需监听Router.events,这样能保证数据在组件初始化时就已准备好,避免变更检测时机问题。
示例代码:
export class CategoriesComponent { @ViewChild('table') table: MatTable<Category>; dataSource = new MatTableDataSource<Category>([]); mediaState$ = this.route.data.pipe( map(data => { // 假设解析器把分类数据存在data.categoryCollection中 const categories = data.categoryCollection as CategoryCollection; const { path, type } = extractMediaPath(this.router.url); const formattedPath = `${type}${path}`.replace(/\/$/, ''); const items = this.sort(categories[type], path); return { items, path: formattedPath, allowAddCategory: !(items.length && items[0].media), displayedColumns: items.length && items[0].media ? (type === CategoryType.MUSIC ? [...MEDIA_MUSIC_COLUMNS] : [...MEDIA_VIDEO_COLUMNS]) : [...CATEGORY_COLUMNS] }; }), tap(state => { this.dataSource.data = state.items; }) ); constructor(private route: ActivatedRoute, private router: Router) {} }
3. 手动触发变更检测(过渡方案)
如果暂时无法重构为可观察对象,可以在修改属性后手动触发变更检测,替代setTimeout。但这只是临时方案,长期来看还是推荐前两种方法:
import { ChangeDetectorRef } from '@angular/core'; export class CategoriesComponent { constructor(private cdr: ChangeDetectorRef) {} media$ = this.router.events .pipe( // ... 其他操作符 tap(categories => { const { path, type } = extractMediaPath(this.router.url); this.path = `${type}${path}`.replace(/\/$/, ''); const items = this.sort(categories[type], path); if (items.length && items[0].media) { this.allowAddCategory = false; this.displayedColumns = type === CategoryType.MUSIC ? [...MEDIA_MUSIC_COLUMNS] : [...MEDIA_VIDEO_COLUMNS]; } else { this.allowAddCategory = true; this.displayedColumns = [...CATEGORY_COLUMNS]; } this.dataSource.data = items; // 手动触发变更检测 this.cdr.detectChanges(); }) ); }
内容的提问来源于stack exchange,提问作者Mattijs

