【译】浅谈Angular中的变化检测

关于变化检测机制,zones和ExpressionChangedAfterItHasBeenCheckedError错误的综述


若是您更喜欢看视频的话,请点击这里javascript

本文删去了译者认为与主题无关的内容,您能够点击查看原文java


初次相遇

下面是一个简单的Angular组件,它在应用中发生变化检测时将时间渲染到屏幕上。时间戳的精度是毫秒。点击按钮触发变化监测:node

组件的代码实现以下:typescript

@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不容许空的表达式,因此我在click的回调中放了一个0浏览器

这里体验这个组件。当Angular运行变化检测时,它将time属性的值传给date管道,并使用返回的结果更新DOM。看起来彷佛没有什么不对的。然而,当我打开控制台的时候却看到了ExpressionChangedAfterItHasBeenCheckedError 错误。数据结构

ExpressionChangedAfterItHasBeenCheckedError

这使人吃惊。通常来讲,这个错误出如今复杂得多的应用中。那咱们是怎么在这么简单的一个功能中触发了这个错误?别担忧,咱们如今来调查一下。app

先看一下报错的信息。异步

表达式在被检查以后发生了变化。以前的值:“textContent: 1542375826274”。 如今的值:“textContent: 1542375826275”。ide

它告诉咱们,表达式产生的被绑定到textContent上的值改变了。能够看到毫秒的数值确实不同了。因此Angular将time | data: 'hh:mm:ss:SSS'表达式计算了两次而且将结果进行了比较。Angular检测到了两个值不一样,这就是报错的缘由。

可是为何Angular要对值进行比较?它在何时作了这件事?

这些问题激发了个人好奇心,并最终使我深刻到变化检测的内部原理。由于为了找到这些问题的答案,我必须开始调试。我不停地调试,再调试。好吧。。。我想我大概花了一两个月 😅。咱们先从第二个问题开始,这个错误在何时被抛出的。但我要先分享一些个人发现,这些发现能够帮助咱们理解上面的错误。

组件视图和数据绑定

在Angular的变化检测中有两个主要的构成元素:

  • 一个组件的视图
  • 相关的数据绑定

Angular中的每一个组件都有一个由HTML元素构成的模板。Angular建立了DOM节点以便将模板中的内容渲染到屏幕上,它须要有一个地方存储这些DOM节点的引用。为此,在Angular内部有一个称为视图的数据结构。它也被用来存储组件实例的引用以及绑定表达式以前的值。组件和视图之间是一对一的关系。下面是图示:

组件和视图

编译器在分析模板时,它会识别可能须要在变化检测期间被更新的DOM元素的属性。编译器为每一个这样的属性建立一个绑定。数据绑定定义了须要更新的属性名称和Angular用于获取新值的表达式。

在咱们的例子中,time属性被用在textContent属性的表达式中。因此Angular建立了绑定并将它关联到span元素:

数据绑定

在实际的实现中,绑定不是一个有着全部必须信息的单独的对象。一个viewDefinition为模板元素和须要更新的属性定义了绑定。用于绑定的表达式被置于updateRenderer函数中。

检查组件视图

如你所知,在Angular中,每一个组件都会执行变化检测。咱们如今已经知道组件在内部被表达为视图,所以咱们能够说每一个视图都会执行变化检测。

当Angular检查一个视图时,它只会运行全部编译器为视图生成的绑定。它对表达式求值而后将它们的结果存在视图的oldValues数组中。这就是脏检查名字的由来。若是它检测到了变化,它就会更新与绑定相关的DOM属性。而且它须要将这个新的值放入视图的oldValues数组。以后你就获得了一个更新过的UI。一旦Angular完成了当前组件的检测,它会递归地去检查子组件。

在咱们的应用中,只有一个绑定,链接到App组件中的span元素的textContent属性。因此在变化检测期间,Angular读取了组件类的time属性的值,并将其应用到date管道上,而后将返回值与储存在视图中的旧值相比较。若是它检测到不一样,Angular会更新spantextContent属性和oldValues数组。

可是咱们的错误是从哪里跑出来的?

在开发模式下,每一次变化检测循环以后,Angular同步地运行另外一次检查以确保表达式生成的值与以前在变化检测中的相同。这个检查不是原始变化监测循环的一部分。它在整个组件树的变化检查结束以后执行彻底相同的步骤。然而,在这一次检查中,当Angular检测到了变化时不会更新DOM。相反,它会抛出ExpressionChangedAfterItHasBeenCheckedError 错误。

Detecg changes

为何

咱们如今知道了这个错误在何时会被抛出。**可是为何Angular须要作此次检查?**好吧,想象一下,组件类中的某些属性在变化检测运行期间已经被更新了。而结果是,表达式产生了与咱们渲染到UI中的值不一致的新值。那么Angular作了什么?它固然能够再运行一次变化检测以同步应用状态和UI。但假如在这个过程当中,某些属性再次被更新了呢?看到了吗?Angular可能会在无限的变化检测循环中崩溃。事实上,这在AngularJS中常常发生。

为了不这种状况,Angular强制实行了被称为单项数据流的模式。而且在变化检测以后运行的检查和由此产生的ExpressionChangedAfterItHasBeenCheckedError 错误是强制的机制。一旦Angular处理完了当前组件的绑定,你就不能再更新绑定表达式中使用的属性。

修复错误

为了阻止这个错误,咱们须要确保表达式在变化检测期间与随后的检查中返回的值是相同的。在咱们的例子中,咱们能够经过将求值部分移除timegetter来作到这一点:

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

    constructor() {
        this._time = Date.now();
    }
}
复制代码

但这样作的话,getter time返回的值始终都是同样的。咱们仍然须要更新这个值。咱们在以前了解到产生错误的检查在变化检测循环以后当即同步运行。那若是咱们异步地去更新它,就能够避免这个错误。因此咱们可使用setInterval函数每隔1ms就更新该值。

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

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

这个方法解决了咱们最初的问题。但不幸的是,它带来了新的问题。全部的计时器,像setInterval,都会触发Angular的变化检测。这意味着使用了这种方法,咱们会陷入无穷无尽的变化检测循环中。**为了不这个问题,咱们须要一种不会触发变化检测的方式来运行setInterval。**咱们很幸运,确实有这样的一种方式。首先咱们须要理解为何在Angular中setInterval会触发变化检测,才能知道怎么去达到咱们的目的。

zones提供的自动变化检测

与React相反,Angular中的变化检测能够彻底自动地由浏览器中的任何一个异步事件触发。经过使用zone.js这个库,这种触发变化监测的方式得以实现,同时引入了zones的概念。与通常的见解相反,zones不是Angular变化检测机制的一部分。事实上,Angular的运行并不须要它们。这个库仅仅提供了一种拦截异步事件的方法,好比setInterval,而且通知Angular发生了异步事件。Angular基于这个通知来运行变化检测。

有趣的是,在一个网页中,你能够有不少不一样的zones。其中一个是NgZone。它在Angular启动的时候被建立。Angular应用就运行在这个zone中。只有在zone中发生的异步事件才会通知Angular。

zones

可是,zone.js也提供了一个API,以便在Angular zone以外的zone中运行某些代码。其余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以外。这保证了变化检测期间和接下来的检查中getter time返回相同的值。此外,Angular在下一次变化检测中读取到的time的值将会被更新而且变化会被反映在屏幕上。

使用NgZone来在Angular以外运行某些代码以免触发变化检测是一种经常使用的优化技巧。

调试

你也许想知道是否有办法看到Angular中的视图和绑定。事实上,确实有。在@angular/core模块中有一个名为checkAndUpdateView的函数。它遍历组件树中的视图(组件)并对每一个视图执行检测。当我遇到与变化检测相关的问题是,我老是从这个函数开始调试。

本身尝试使用这个demo去进行调试。打开控制台,找到那个函数并打上断点。点击按钮触发变化监测。审查view变量。下面的动图是个人演示。

调试

第一个view会成为宿主视图。它是Angular建立的一个根组件,用来托管app组件。咱们须要恢复执行,以得到它的子视图,也就是咱们AppComponent的视图。去探索它吧。component属性存放了App组件的实例。node属性存放了DOM节点的引用,这些DOM节点是为App组件的模板中的元素建立的。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生命周期钩子时,父级App组件的绑定已经被检查过了。但咱们在检查以后更新了父组件中的text属性。

不过这里有一个有趣的地方。假如我换一个钩子呢?也就是说,在ngOnInit中去作这件事。你以为咱们还会看到这个错误吗?

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

    ngOnInit() {
        this.parent.text = 'Updated text in parent component';
    }
}
复制代码

这一次不会再报错了请查看demo。事实上,咱们能够把这段代码放到任何其余的钩子中(不包括AfterViewInitAfterViewChecked),就不会在控制台中看到这个错误。那么这里发生了什么?为何ngAfterViewChecked钩子如此特殊?

为了理解这个行为,咱们须要知道Angular在变化检测期间执行了什么操做而且是以什么顺序执行的。咱们已经知道该去哪里找到答案:我以前展现过的checkAndUpdateView函数。下面是该函数体里面的一部分代码:

function checkAndUpdateView(view, ...) {
    ...       
    // 更新子视图(组件)和指令中的绑定,
    // 若是有须要的话,调用NgOnInit, NgDoCheck and ngOnChanges钩子
    Services.updateDirectives(view, CheckType.CheckAndUpdate);
    
    // DOM更新,为当前视图(组件)执行渲染
    Services.updateRenderer(view, CheckType.CheckAndUpdate);
    
    // 在子视图(组件)中执行变化检测
    execComponentViewsAction(view, ViewAction.CheckAndUpdate);
    
    // 调用AfterViewChecked和AfterViewInit钩子
    callLifecycleHooksChildrenFirst(…, NodeFlags.AfterViewChecked…);
    ...
}
复制代码

如你所见,Angular会在变化检测期间触发生命周期钩子。**有趣的是当Angular在处理绑定时,一些钩子在渲染以前被调用,一些钩子在渲染以后被调用。**下面这张图演示了在Angular为父组件运行变化检测期间发生了什么:

发生了什么

让咱们一步步地来理清它。首先,它为组件更新输入绑定。以后它又调用了组件上的OnInitDoCheckOnchanges钩子。这一步是有意义的,由于它刚刚更新了输入绑定因此Angular须要通知子组件输入绑定已经被初始化了。而后Angular为当前组件执行渲染。在这以后,它为子组件运行变化检测。这意味着它会在子视图中重复这些操做。最后,它调用了子组件上的AfterViewCheckedAfterViewInit钩子让其知道已经被检查了。

在这里咱们能够注意到Angular在处理了父组件的绑定以后以后调用子组件的AfterViewChecked生命周期钩子。另外一方面,OnInit钩子在绑定被处理以前调用。因此即便在OnInit中改变了text的值,在随后的检查中它仍然是相同的。这就解释了在ngOnInit中不会有错误的奇怪行为。谜底揭晓🤓。

总结

如今咱们总结一下刚刚学到的东西。Angular中的全部组件在内部都被表示为一种叫视图的数据结构。Angular的编译器解析模板并建立绑定。每个绑定定义了一个要更新的DOM元素的属性和用于求值的表达式。视图中的oldValues属性存储了在变化检测中被用于比较的旧值。在变化检测期间,Angular遍历全部绑定,对表达式求值,将它们与旧值比较,若是有必要的话就更新DOM。每一个变化检测循环以后,Angular运行一次检查以确保组建的状态与用户界面同步。此次检查是同步运行的而且可能会抛出ExpressionChangedAfterItWasChecked错误。


推荐

这5篇文章将会使你成为Angular变化检测上的专家

若是你正在找寻关于Angular中变化监测的更深刻的解释,这篇文章会是一个好的起点。它收集了一些有关变化检测的深度好文,例如zones,DOM更新机制,单项数据流和ExpressionChangedAfterItWasChecked错误。

相关文章
相关标签/搜索