一文弄懂 Angular 变更检测

2020/3/3 11:31:24

本文主要是介绍一文弄懂 Angular 变更检测,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

通过这篇的文章,可以帮助你收获这些知识:

  • Angular 的 DOM 更新是如何做到的?
  • 变更检测解决了什么问题?
  • 更深入了解变更检测,熟悉它在 Angular 源码中的定义以及在 ng-zorro 中的使用
  • 以及一点个人感悟

一、Angular 的 DOM 更新机制

我们先来看一个最简单地 demo


在按钮被点击时,我们改变了组件的 name 属性,就在一瞬间 DOM 中也显示出了改变后的值,这似乎有些“神奇”。


如果紧跟着元素更改的语句之后打印出真实 DOM 中的 innterText,却发现仍然还是旧的值,可是明明视图中的值已经改变了,这两段代码中到底发生了什么呢?如果你也对此也疑惑不已,那么就和我一起来揭晓这个答案。

我们仔细回忆下刚刚发生的事情:

  1. 点击按钮
  2. 值改变

如果使用原生 JS 来编写这段代码,那么点击按钮后的视图肯定不会发生改变,而在 Angular 中却让视图发生了改变。如果你对 Angular 有稍微深入的了解,就会知道一个叫做 zone.js 的库,仔细翻看就会发现,zone.js 对所有可能发生值改变的事件做了一层处理,比如:

  • Events:click,mouseover,mouseout,keyup,keydown 等所有的浏览器事件
  • Timer:setTimeout,setInterval
  • XHR:各类请求等

Angular 还为我们提供了禁用 zone.js 的方法。



禁用 zone 后,当我们再次点击按钮时,视图未更新。

带着好奇心,我们找到 Angular 源码中视图更新的关键代码


这一次我们手动在代码调用这个方法。



果然和预料中的一样!视图更新了,更让人惊喜是,打印出来的 innerText 也更新了!

到这里,我们得出了一个结论,DOM 的更新依赖于 tick() 的触发,zone.js 帮助开发者省去了手动触发的操作

好了,小试牛刀之后,接下里我们就来仔细探究 Angular 视图更新的背后到底发生了什么。

二、窥探变更检测的秘密

1.从一个常见的 Error 说起

我们先来看这样一处错误,在 child 组件的 ngOnInit 中更改了父组件 parent 的 name 值,结果出现了大家一定都遇到过的错误信息



可是这样写并不是每次都会报错,例如我们去掉子组件 child 的输入属性,刷新一下,发现同样的代码却可以运行,父组件的 name 可以被正常更改。


emmm... 陷入沉思...

也许你和刚开始学习 Angular 时的我一样,在 stackoverflow 里搜索这个问题,复制了个自己也不知道为什么能起作用的代码就直接粘贴了上去,后面再遇到这个问题时,继续在 stackoverflow 里搜索和复制粘贴,如此反复...

随着时间的推移,精通各种 CRUD 的你越来越不满足于这种面向 stackoverflow 编程的自己,开始在社区、文档、论坛上不停的查找问题的答案,但是看完他们的回答和文章,好像只知道了有个叫做变更检测的东西,但是具体是怎么导致了这个 bug ,却支支吾吾的说不太清楚,如果你也和我一样对上述经历深有体会,那么就继续向下探寻真相吧!

2.说了半天的变更检测到底是什么?

当我们在 model 中改变数据时,框架层需要知道:

  • model 哪里发生了改变
  • view 中哪里需要更新

React 中的 Virtual Dom 大家一定都不陌生,React 通过对比 DOM 的新状态与旧状态来决定更新哪一部分 dom,而不是更新所有的 dom,这也是 Angular 中变更检测(change detection)的异曲同工之处。

整个 Angular 应用是个组件树,不可能任意一个组件中的改变都触发所有组件的更新,这样效率太低也太耗时,例如用户更改了某个 button 的状态,那么最理想的做法是只更新这个 button 的样式或文字,而不是整个应用全部更新一遍,变更检测的目的也就是为此。

默认情况下(ChangeDetectionStrategy.Default),父组件的变更检测发生时,子组件也会触发变更检测。


(CD 即为 changeDetection )

每次变更检测时,都会比较新旧状态,如果两次变更检测(开发环境下)的结果不一致就会报错,例如:

Expression has changed after it was checked

这也就解释了为什么在子组件中更改了父组件的值会报错。

但是!在前面的两个例子中我们都在子组件中更改了父组件的值,只有第一个报错,第二个是可以正常更新的,如果你也同样很疑惑这中间真正的差异点在哪里,那么接着往下阅读吧~

3.问题的关键 — 检测顺序 Detection Sequence

先上结论:

  1. 更新所有子组件的绑定属性
  2. 调用所有子组件的 OnChanges,OnInit,DoCheck,AfterContentInit 生命周期钩子
  3. 更新当前组件的 DOM
  4. 子组件触发变更检测
  5. 调用所有子组件的的 AfterViewInit 的生命周期钩子

这里我们不关注于太细的细节(不用好奇为什么是这样的顺序,只要记住 Angular 里就是这样设定的就可以了,如果有大佬想谈谈 Angular 在这部分的设计思想,欢迎在评论区留言探讨~)

第一个例子中,父组件 parent 给子组件 child 传入了输入属性 name,且子组件在 ngOnInit 中更新了父组件的 name 属性,也就是说这段代码**违背了检测顺序(**在顺序的第二步中操作了第一步)!

<p>{{ name }}<p>
<child [name]="name"></child>
复制代码

而在第二个例子中,就算子组件在 ngOnInit 中也更新了父组件的 name 属性,但是由于父组件parent 中没有给子组件 child 绑定输入属性 name,不会出现与违背变更检测队列顺序的情况,所以就可以正常运行。

<p>{{ name }}<p>
<child></child>
复制代码

这个时候再去看看 stackoverflow 上的高赞回答 是不是就清晰明了很多,按照上述的检测顺序,我们会发现只要父组件中对子组件做了属性绑定,不管是在 OnChanges,OnInit,DoCheck,AfterContentInit 和 AfterViewInit 中的任意一个声明周期钩子中执行下述代码都会报错。

this.parentCmpt.name = 'child'
复制代码

好了,到这里我们已经明白了这种错误发生的真正原因,但是我还是要提醒一下,这种错误只会在开发环境下触发,生产环境下会调用 enableProdMode() ,变更检测次数会从 2 降到 1,这部分在 Angular 源码当中也有描述。


当然你不能因为这个 bug 就强制在开发环境下使用生产模式...

4.大家常说的 ChangeDetectionStrategy.OnPush 又是什么?

ChangeDetectionStrategy 默认为 Default,也就是父组件的 CD 会触发子组件的 CD,但是很显然有些情况下我们可以自行判断出某些子组件在父组件 CD 时并不用触发,而 OnPush

则是 Angular 为开发者提供的一便捷操作方式。


用动图来表示就是:查看链接

知名的 Angular 开源组件库 ng-zorro 就使用了大量的 OnPush 策略,这也是 Angular 性能优化的方法之一。


三、再深入了解一些

Angular 给每个组件都关联了一份组件视图,通过 ChangeDetectorRef 可以拿到相关联的视图,在定义中我们可以看到:

export declare abstract class ChangeDetectorRef {
    abstract checkNoChanges(): void;
    abstract detach(): void;
    abstract detectChanges(): void;
    abstract markForCheck(): void;
    abstract reattach(): void;
}
复制代码

1.detach 和 reattach

观察下面的动图,被 detached 的组件将不会被检查变更。

reattach 则可以让被 detached 的组件重新可以被检测到变更。

2.markForCheck

reattach 只会重新启用对当前组件的变更检测,但是如果父组件没有启动变更检测,那么 reattach 并不会起作用,而 markForCheck 可以很好地解决这个问题。

这一点在 ng-zorro 的源码中可以了解一二。

例如在 nz-anchor 组件中更改 nz-anchor-link 组件的 active 属性时,由于本身 ChangeDetectionStrategyOnPush ,那么就需要激活 markForCheck 来重新启用检测。具体写法可以查看 github 中的源代码。

用动图来展示则是这样,注意观察设置了 MFC 的前后变化

3.detectChanges

这个方法如同字面意思一样很好理解,就是触发一次变更检测啦,还记得本文中的第一个例子吗,我们不手动触发 tick() ,而是触发 detechtChanges() 也是可以达到效果的。



四、最后

到这里,我相信大家已经基本弄明白了 Angular 变更检测,如果有任何疑问,欢迎在评论区交流讨论~

在撰写这篇文章时,笔者参(fu)考(zhi)了大量的社区文章和讨论,一方面是感慨如此重要的概念在 Angular 中文社区中却只有零星几篇相关介绍的文章,另一方面是看到了虽然国内 Angular 开发者虽然数量远少于 React 和 Vue,却依然非常热情的贡献自己的知识和见解来为 Angular 中文社区添砖加瓦,作为已使用 Angular 半年多的开发者,深深感受到 Google 的工程美学。

大而全且不失优雅,是笔者对 Angular 这款 Web 框架的最大感受,感谢开源社区中的各位开发者们~

对于文中描述错误的地方,还望大佬们批评斧正~





这篇关于一文弄懂 Angular 变更检测的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程