1
内容来自于Max Koretskyi aka Wizard的《A gentle introduction into change detection in Angular》

初次相遇

让我们从一个简单的Angular组件开始。他表现应用程序的变化检测。这个时间戳的精度为毫秒。点击triggers按钮触发检测:
1*9H6Gsg92mS4_tnyJG8O9mw.gif

@Component({
    selector: 'my-app',
    template: `
        <h3>
            Change detection is triggered at:
            <span [textContent]="time | date:'hh:mm:ss:SSS'"></span>
        </h3>
        <button (click)="0">Trigger Change Detection</button>
    `
})
export class AppComponent {
    get time() {
        return Date.now();
    }
}

如你所见,这是相当基本的。有一个名为time的getter返回当前时间戳。并且,我将它绑定到HTML中的span元素。
当Angular运行变化检测时,它获取time属性的值,通过日期管道传递它,并使用结果更新DOM。这一切都很正常,但当我打开控制台的时候,我看到了一个错误:ExpressionChangedAfterItHasBeenCheckedError

clipboard.png

事实上,这让我们感到非常惊讶。通常这个错误出现在更加复杂的程序上。但为什么一个如此简单的功能会导致这个错误呢?别担心,我们现在就来查看他的原因。
让我们先从错误消息开始:

Expression has changed after it was checked. Previous value: “textContent: 1542375826274”. Current value: “textContent: 1542375826275”.

它告诉我们,textContent绑定的值是不同的。的确,毫秒不相同。因为Angular通过表达式time | date:'hh:mm:ss:SSS'计算了两次,并比较了结果。它检测到了两次值的差异,这就是导致错误的原因。

但Angular为什么要这样做?或者它什么时候做的?
在我们了解这些问题的答案之前,我们还需要了解另外一些东西。
组件视图和绑定
Angular的变化检测主要有两个部分:

  • 组件视图
  • 相关绑定

每一个Angular的组件都有一个HTML元素。当Angular创建DOM节点并将内容渲染到屏幕上,它需要一个地方来储存DOM节点的引用。为了实现这一目标,Angular内部有一个被称为View的数据结构。它还用于存储对组件实例的引用和绑定表达式之前的值。并且视图和组件之间的关系是一一对应的。下图展示了该关系:

clipboard.png

当编译器分析模板时,它会辨识在变化检测期间可能需要更新的DOM元素属性。每一个这样的属性,编译器都会创建一个绑定。绑定定义要更新的属性名和Angular用来获取新值的表达式。

在我们的例子当中,time属性用于textContent的表达式中。所以,Angular会创建绑定来连接它和span元素。

clipboard.png

实际上,绑定不是包含所有必要信息的单个对象。viewDefinition定义模板元素和要更新的属性的实际绑定。用于绑定的表达式在updateRenderer方法中。

*检查组件视图
如你所知,Angular会对每一个组件执行变化检测。现在我们知道每个组件在Angular内部被称为视图(view),我们可以说Angular对每个视图执行了变化检测。

当Angular检查视图时,它只需运行编译器为视图生成的所有绑定。它计算表达式并将它们的结果与视图上旧值数组中存储的值(oldValues)进行比较。这就是脏检查这个名字的由来。如果检测到差异,它会更新与绑定相关的DOM属性。它还需要将新值放入视图的旧值数组中。就这样。您现在有了更新的用户界面。一旦完成当前组件的检查,它将对子组件重复完全相同的步骤。在我们的应用程序中,在App组件中span元素的属性textContent只有一个绑定。所以在变化检测期间,Angular会读取组件time属性的值,再使用date管道,并将它与视图中存储的先前值进行比较。如果检测到不同,Angular会更新span旧值(oldValues)数组中的textContent属性.

但是错误又从哪里出来的呢?
在开发模式下,每个变化检测周期之后,Angular会同步运行另外一个检查,已确保表达式产生的值与之前变化检测运行期间的值相同。该检查不是原始检查的一部分,它在对整个组件树的检查完成后运行,并执行完全相同的步骤。然而,当这一次变化检测期间,如果检测到不同那个的值,Angular不会去更新DOM,相反的,它会直接抛出错误ExpressionChangedAfterItHasBeenCheckedError

clipboard.png

但是Angular为什么要这样做?
现在我们知道什么时候抛出错误了。但是为什么Angular需要这个检测。假设在变化检测运行期间,又有一些组件的一些属性被更新。此时,表达式产生的新值与用户界面中呈现的值不一样。这个时候Angular应该怎么做?它当然也可以另外再运行一个变化检测周期来使应用程序状态与用户界面同步。但如果在这期间,又有一些属性被更新了呢?看到问题了吗?实际上Angular可能会在变化检测的无限循环中结束。这种情况在AngularJS中经常发生。
为了避免这种事情,Angular强制让数据单向流动。这种在变更检测和结果表达式变更后运行的检查是强制机制。一旦Angular处理了当前组件的绑定,就不能再更新绑定表达式中使用的组件属性。

修复这个错误
为了防止这种错误的发生,我们需要确保在改变检测周期表达式返回的值和检查值相同。在我们的例子当中,我们可以将变化值从time的getter中移除,就像这样:

export class AppComponent {
    _time;
    get time() {  return this._time; }

    constructor() {
        this._time = Date.now();
    }
}

然而,在实际中,time的值永远都不会变化。我们之前了解到,产生错误的检查会在变更检测周期之后同步运行。因此,如果我们异步的去更新它,就不会出现这种错误。所以我们为了每一毫秒去更新一次time的值,我们使用setInterval函数,就像这样:

export class AppComponent {
    _time;
    get time() {  return this._time; }

    constructor() {
        this._time = Date.now();
        
        setInterval(() => {
            this._time = Date.now();
        }, 1);
    }
}

这个实现的确解决了我们最初的问题。但是不幸的是,它又引入了一个新的问题。所有的定时时间,如setInterval,都会触发Angular的变化检测机制。这意味着,如果通过这种方式来实现,我们将会进入一个无线循环的变化检测周期。为了避免触发Angular的变化检测,我们需要一个不会触发Angular变化检测的setInterval。幸运的是,我们刚好有方法来实现这个需求。要了解如何做到这一点,我们需要先了解为什么setInterval会触发Angular的变化检测。

带区域的自动变化检测
和React不同,浏览器的任何异步事件都可以完全自动触发Angular的变化检测。这是通过使用zone.js库实现的,该库引入了zone的概念。与普遍的看法不同,zones并不是Angular变化检测的一部分。事实上,Angular变化检测不需要zones也可以正常工作。该库只是提供一个异步事件的拦截方法(像setInterval),并通知Angular。基于该通知,Angular启动变化检测。

有趣的是,一个网页上可以有许多个不同的zone。其中一个就是NgZone。它是由Angular创建的。这是Angular运行的zone。而且Angular只获取该区域内的事件通知。

clipboard.png

但是,zone.js还提供了一个应用编程接口,可以在Angula zone以外的区域运行一些代码。Angular并不会收到在其他区域发生的异步事件的通知。没有通知就以为着没有变化检测。这个方法是runOutsideAngular,它是由NgZone服务实现的。

以下是使用NgZone实现在Angular zone外执行setInterval

export class AppComponent {
    _time;
    get time() {
        return this._time;
    }

    constructor(zone: NgZone) {
        this._time = Date.now();

        zone.runOutsideAngular(() => {
            setInterval(() => {
                this._time = Date.now()
            }, 1);
        });
    }
}

现在我们不停的更新时间,**但是我们是在Angular zone之外执行的异步操作。这保证了在变化检测和随后的检查期间,time返回相同的值。当Angular在下一个变化检测周期读取time值时,该值将被更新,并且变化将被反映在屏幕上。

使用NgZone在Angular之外运行一些代码以避免触发变化检测是一种常见的优化技术。

Debugging
你可能想知道,是有有什么方法可以查看view和Angular的内部绑定。事实上,@angular/core module中的checkAndUpdateView方法就能做到。它在组件树的每个视图(组件)上运行,并对每一个view执行检查。当我在变更检测方面遇到问题时,我总是开始调试这个函数。

尝试去调试它。找到这个函数并在那里放置一个断点。点击按钮触变化检查,检查view变量。

第一个视图将是宿主视图。这是角力创建的一个根组件,用来托管我们的应用程序组件。我们需要不断执行以到达它的子视图,这将是为我们的应用程序组件创建的视图。探索它!这个组件的属性包含了app 组件实例的引用。nodes属性包含对为app组件模板内的元素创建的DOM节点的引用。oldValues数组保存绑定的表达式的值。

操作顺序
我们在之前了解到,由于单向数据流的限制,您不能在检查组件后的更改检测期间更改组件的某些属性。最常见的情况是,当Angular运行子组件的更改检测时,此更新通过共享服务或同步事件广播进行。但是也可以直接将父组件注入子组件,并在生命周期挂钩中更新父状态。这里有一些代码演示了这一点:

@Component({
    selector: 'my-app',
    template: `
        <div [textContent]="text"></div>
        <child-comp></child-comp>
    `
})
export class AppComponent {
    text = 'Original text in parent component';
}

@Component({
    selector: 'child-comp',
    template: `<span>I am child component</span>`
})
export class ChildComponent {
    constructor(private parent: AppComponent) {}

    ngAfterViewChecked() {
        this.parent.text = 'Updated text in parent component';
    }
}

基本上,我们定义了两个结构简单的基本组件。父组件申明一个text属性并将它绑定。子组件注入了父组件,并在ngAfterViewChecked生命周期钩子中更新父组件的属性。设想一下,我们会在控制台中看到什么?

没错,是我们熟悉的ExpressionChangedAfterItWasChecked错误。这是因为当Angular调用子组件的ngAfterViewChecked时,Angular已经完成了对父组件的检查。但是我们在变化检测之后又更新了父组件的属性。

有趣的是,如果我们现在换一个生命周期钩子执行这个操作呢?比如说ngOnInit。你认为我们还会看到这个错误吗?

export class ChildComponent {
    constructor(private parent: AppComponent) {}

    ngOnInit() {
        this.parent.text = 'Updated text in parent component';
    }
}

很好,这一次错误并没有在。事实上,我们可以把代码放在任何其他钩子中(比如AfterViewInitAfterViewChecked),并且我们不会再控制台看到错误。但这是怎么回事呢?为什么ngAfterViewChecked这么特殊呢?

为了理解这种行为,我们需要知道Angular在变化检测期间执行什么操作以及它们的顺序。而且,我们已经知道在哪里可以找到他们:我之前给你们看过checkAndUpdateView方法。下面是函数主体代码的一部分。


function checkAndUpdateView(view, ...) {
    ...       
    // update input bindings on child views (components) & directives,
    // call NgOnInit, NgDoCheck and ngOnChanges hooks if needed
    Services.updateDirectives(view, CheckType.CheckAndUpdate);
    
    // DOM updates, perform rendering for the current view (component)
    Services.updateRenderer(view, CheckType.CheckAndUpdate);
    
    // run change detection on child views (components)
    execComponentViewsAction(view, ViewAction.CheckAndUpdate);
    
    // call AfterViewChecked and AfterViewInit hooks
    callLifecycleHooksChildrenFirst(…, NodeFlags.AfterViewChecked…);
    ...
}

就如你所看到的,Angular也会触发生命周期钩子来作为变化检测的一部分。有趣的是,当Angular处理绑定时,有些钩子在渲染部分之前调用,有些钩子在渲染部分之后调用。下面的图表演示了当Angular运行父组件的更改检测时会发生什么:

clipboard.png

让我们一步步的来。

  • 首先,它更新子组件的输入绑定。
  • 然后,它再调用子组件的OnInitDocheckOnchanges钩子。这很有意义,因为它刚刚更新了输入绑定,Angular需要通知子组件输入绑定已经初始化。
  • 然后,Angular为当前组件执行渲染。
  • 再之后,Angular在子组件上启动变化检测。这意味着它将基本上在子视图上重复这些操作。
  • 最后,它调用子组件上的AfterViewCheckedAfterViewInit钩子,让它知道它已经被检查过了。

我们注意到,Angular是在处理完父组件的绑定之后才调用子组件的AfterViewChecked生命周期钩子。另一方面,在绑定被处理之前调用OnInit钩子。因此,即使OnInit中的text发生了变化,在接下来的检查中,它仍将保持不变。这解释了为什么ngOnInit钩子没有出现错误这种看似奇怪的行为。谜团解开了!

总结
最后,让我们来总结一下刚刚学到的东西。Angular内部的所有组件都以称为视图的数据结构表示。Angular的编译器解析模板并创建绑定。每个绑定定义要更新的DOM元素的属性和用于获取值的表达式。变更检测期间用于比较的先前值存储在oldValues属性的视图中。在变更检测期间,Angular在绑定上运行,评估表达式,将它们与以前的值进行比较,并在必要时更新DOM。在每个变化检测周期后,Angular会运行检查,以确保组件状态与用户界面同步。此检查是同步执行的,可能会引发expression ExpressionChangedAfterItWasChecked错误。


張怼怼
107 声望43 粉丝