这篇文章由推特上的一个人提出的问题而引出:使用NgDoCheck
生命周期钩子手动比较值来代替推荐的async
管道方式是否具有意义?这是一个很好的问题。
这篇文章将首先展示如何进行手动变更检测,一旦我们拥有了这些知识,然后我们将讨论这两种解决方案的性能影响。
OnPush components
在angular中我们有一个非常常用的性能优化技术,那就是添加ChangeDetectionStrategy.OnPush
到组件的装饰器中。假设我们有两个继承的组件:
@Component({
selector: 'a-comp',
template: `
<span>I am A component</span>
<b-comp></b-comp>
`
})
export class AComponent {}
@Component({
selector: 'b-comp',
template: `<span>I am B component</span>`
})
export class BComponent {}
这样,angular在一个单独的时间里总是对A和B运行变更检测。如果对B组件添加OnPush
策略:
@Component({
selector: 'b-comp',
template: `<span>I am B component</span>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {}
angular只会在B组件的 input
绑定改变 的时候对其进行变更检测。像这个例子中它没有任何的输入绑定,这个组件将只会在初始化的时候检测一次。
手动触发变更检测
是否有方法来强制对B进行变更检测呢?是的,我们可以注入changeDetectorRef
并使用它的markForCheck
来表明这个组件是需要被检测的,因为B组件总是会调用NgDoCheck
钩子,所以我们可以这里调用这个方法:
@Component({
selector: 'b-comp',
template: `<span>I am B component</span>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
constructor(private cd: ChangeDetectorRef) {}
ngDoCheck() {
this.cd.markForCheck();
}
}
现在当它的父组件A被检测的时候B总是会被检测了。
输入绑定
前面已经说过OnPush
组件只有在绑定改变的时候才执行变更检测,那么让我们来看一个输入绑定的例子:
@Component({
selector: 'b-comp',
template: `
<span>I am B component</span>
<span>User name: {{user.name}}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
@Input() user;
}
在父组件A我们定义一个对象并且实现一个changeName
方法来更新对象的name属性:
@Component({
selector: 'a-comp',
template: `
<span>I am A component</span>
<button (click)="changeName()">Trigger change detection</button>
<b-comp [user]="user"></b-comp>
`
})
export class AComponent {
user = {name: 'A'};
changeName() {
this.user.name = 'B';
}
}
如果你运行这个例子,它首先会打印:
User name: A
但是当我们点击按钮改变name属性的值,name并没有更新在屏幕上。我们都知道这是为什么,angular对输入属性执行浅比较并且user的引用并没有改变。
我们可以手动比较name的改变以便执行变更检测:
@Component({
selector: 'b-comp',
template: `
<span>I am B component</span>
<span>User name: {{user.name}}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
@Input() user;
previousName = '';
constructor(private cd: ChangeDetectorRef) {}
ngDoCheck() {
if (this.previousName !== this.user.name) {
this.previousName = this.user.name;
this.cd.markForCheck();
}
}
}
现在你可以运行这个代码来查看屏幕的变化了。
异步更新
现在让我们实现一个更加复杂的例子,我们使用RXJS来做一个异步提交的值,这里使用BehaviorSubject
,因为我们需要一个初始化的值。
@Component({
selector: 'a-comp',
template: `
<span>I am A component</span>
<button (click)="changeName()">Trigger change detection</button>
<b-comp [user]="user"></b-comp>
`
})
export class AComponent {
stream = new BehaviorSubject({name: 'A'});
user = this.stream.asObservable();
changeName() {
this.stream.next({name: 'B'});
}
}
我们将在子组件接收这个user对象的流,我们需要订阅这个流以便知道值是否更新,通常的方式是使用Async
管道。
Async 管道
这里是B组件的一个实现:
@Component({
selector: 'b-comp',
template: `
<span>I am B component</span>
<span>User name: {{(user | async).name}}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
@Input() user;
}
这里是demo,但是是否存在另一种不使用管道的方法呢?
手动检查并执行变更检测
是的,我们可以手动比较值并且按需执行变更检测,就像之前的例子,我们可以使用NgDoCheck
钩子:
@Component({
selector: 'b-comp',
template: `
<span>I am B component</span>
<span>User name: {{user.name}}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
@Input('user') user$;
user;
previousName = '';
constructor(private cd: ChangeDetectorRef) {}
ngOnInit() {
this.user$.subscribe((user) => {
this.user = user;
})
}
ngDoCheck() {
if (this.previousName !== this.user.name) {
this.previousName = this.user.name;
this.cd.markForCheck();
}
}
}
不过,理想情况下,我们希望将比较和更新逻辑从NgDoCheck中移出并将其放入订阅回调中,因为那是新值可用的时候:
export class BComponent {
@Input('user') user$;
user = {name: null};
constructor(private cd: ChangeDetectorRef) {}
ngOnInit() {
this.user$.subscribe((user) => {
if (this.user.name !== user.name) {
this.cd.markForCheck();
this.user = user;
}
})
}
}
真正有趣的是到底Async
管道在后台做了什么:
@Pipe({name: 'async', pure: false})
export class AsyncPipe implements OnDestroy, PipeTransform {
constructor(private _ref: ChangeDetectorRef) {}
transform(obj: ...): any {
...
this._subscribe(obj);
...
if (this._latestValue === this._latestReturnedValue) {
return this._latestReturnedValue;
}
this._latestReturnedValue = this._latestValue;
return WrappedValue.wrap(this._latestValue);
}
private _subscribe(obj): void {
...
this._strategy.createSubscription(
obj, (value: Object) => this._updateLatestValue(obj, value));
}
private _updateLatestValue(async: any, value: Object): void {
if (async === this._obj) {
this._latestValue = value;
this._ref.markForCheck();
}
}
}
所以哪种方式更快?
目前为止我们知道了可以用手动变更检测来代替async
管道,那么让我们来回答最初提出的问题,哪个更快?
这依赖于你怎样去比较它们,但是在其他所有条件都相同的情况下,手动方法会更快。我不认为这种差异会是明显的,这只是为什么手动方法可以更快的一些示例。
就内存而言,您无需创建Pipe类的实例。就编译时间而言,编译器不必花时间解析管道特定的语法并生成管道特定的输出。在运行时方面,使用异步管道在组件上运行的每次更改检测为您节省了几个函数调用。例如,这是为带有管道的代码生成的updateRenderer
函数的代码:
function (_ck, _v) {
var _co = _v.component;
var currVal_0 = jit_unwrapValue_7(_v, 3, 0, asyncpipe.transform(_co.user)).name;
_ck(_v, 3, 0, currVal_0);
}
如你所见,异步管道代码对管道实例调用transform
方法来获取新值。管道将返回从订阅中收到的最新值。
将其与为手动方法生成的普通代码进行比较:
function(_ck,_v) {
var _co = _v.component;
var currVal_0 = _co.user.name;
_ck(_v,3,0,currVal_0);
}
这些是Angular在检查B组件时执行的功能。
一些更有趣的事情
不像输入绑定执行浅比较,async
的实现根本就不执行比较,它视每一个新的提交作为一个更新,即使新值与前一个提交值相等。这里是一个例子A组件提交一个相同的对象,但是angular仍然会对B组件执行变更检测:
export class AComponent {
o = {name: 'A'};
user = new BehaviorSubject(this.o);
changeName() {
this.user.next(this.o);
}
}
这意味着使用async
管道的组件将会在每一个新值提交时被标记为需要检测的,angular将会在下一次运行变更检测时对其进行检测,尽管值并没有改变。
这又牵扯到什么呢?在我们的例子中我们只对user对象中的name属性感兴趣,因为我们在模版中使用到了它,我们不关心整个user对象或者说它的引用可能改变的事实,如果name属性是相同的,我们不必要去重新渲染整个组件,但是async
管道不能够避免这个问题。
NgDoCheck
本身并不是没有问题的,作为一个生命周期钩子,它只会在父组件被检查的时候触发,若它的其中一个父组件使用OnPush
模式并且在变更检测周期中不被检测,那么它就不会被触发,所以你通过一个服务接收一个新值的时候不能依赖它去触发变更检测。在这种例子中,我们已经展示了将markForCheck
放在订阅回调中来解决这个问题。
结论
基本上,手动比较可以使你更好的控制变更检测。你可以定义组件何时需要进行检测。这与许多其他工具相同——手动控制可为你提供更大的灵活性,但你必须知道自己在做什么。为了获得这些知识,我鼓励你在学习和阅读上投入时间和精力。
如果你担心NgDoCheck
会被频繁调用或者它会比管道transfrom
调用的更加频繁,这是不必须的。第一我们在以上解决方案中展示了不使用生命周期钩子的异步流的解决方式。第二,钩子只会在父组件被检测的时候会被调用,如果父组件不被检测那么钩子是不会被调用的。对于管道,由于浅比较和流中引用可能改变,你将会有相同数量或者更多的对管道transform
的调用。