通过这篇的文章,可以帮助你收获这些知识:
我们先来看一个最简单地 demo
在按钮被点击时,我们改变了组件的 name 属性,就在一瞬间 DOM 中也显示出了改变后的值,这似乎有些“神奇”。
如果紧跟着元素更改的语句之后打印出真实 DOM 中的 innterText,却发现仍然还是旧的值,可是明明视图中的值已经改变了,这两段代码中到底发生了什么呢?如果你也对此也疑惑不已,那么就和我一起来揭晓这个答案。
我们仔细回忆下刚刚发生的事情:
如果使用原生 JS 来编写这段代码,那么点击按钮后的视图肯定不会发生改变,而在 Angular 中却让视图发生了改变。如果你对 Angular 有稍微深入的了解,就会知道一个叫做 zone.js 的库,仔细翻看就会发现,zone.js 对所有可能发生值改变的事件做了一层处理,比如:
Angular 还为我们提供了禁用 zone.js 的方法。
禁用 zone 后,当我们再次点击按钮时,视图未更新。
带着好奇心,我们找到 Angular 源码中视图更新的关键代码
这一次我们手动在代码调用这个方法。
果然和预料中的一样!视图更新了,更让人惊喜是,打印出来的 innerText 也更新了!
到这里,我们得出了一个结论,DOM 的更新依赖于 tick() 的触发,zone.js 帮助开发者省去了手动触发的操作。
好了,小试牛刀之后,接下里我们就来仔细探究 Angular 视图更新的背后到底发生了什么。
我们先来看这样一处错误,在 child 组件的 ngOnInit 中更改了父组件 parent 的 name 值,结果出现了大家一定都遇到过的错误信息
可是这样写并不是每次都会报错,例如我们去掉子组件 child 的输入属性,刷新一下,发现同样的代码却可以运行,父组件的 name 可以被正常更改。
emmm... 陷入沉思...
也许你和刚开始学习 Angular 时的我一样,在 stackoverflow 里搜索这个问题,复制了个自己也不知道为什么能起作用的代码就直接粘贴了上去,后面再遇到这个问题时,继续在 stackoverflow 里搜索和复制粘贴,如此反复...
随着时间的推移,精通各种 CRUD 的你越来越不满足于这种面向 stackoverflow 编程的自己,开始在社区、文档、论坛上不停的查找问题的答案,但是看完他们的回答和文章,好像只知道了有个叫做变更检测的东西,但是具体是怎么导致了这个 bug ,却支支吾吾的说不太清楚,如果你也和我一样对上述经历深有体会,那么就继续向下探寻真相吧!
当我们在 model 中改变数据时,框架层需要知道:
React 中的 Virtual Dom 大家一定都不陌生,React 通过对比 DOM 的新状态与旧状态来决定更新哪一部分 dom,而不是更新所有的 dom,这也是 Angular 中变更检测(change detection)的异曲同工之处。
整个 Angular 应用是个组件树,不可能任意一个组件中的改变都触发所有组件的更新,这样效率太低也太耗时,例如用户更改了某个 button 的状态,那么最理想的做法是只更新这个 button 的样式或文字,而不是整个应用全部更新一遍,变更检测的目的也就是为此。
默认情况下(ChangeDetectionStrategy.Default
),父组件的变更检测发生时,子组件也会触发变更检测。
(CD
即为 changeDetection
)
每次变更检测时,都会比较新旧状态,如果两次变更检测(开发环境下)的结果不一致就会报错,例如:
Expression has changed after it was checked
这也就解释了为什么在子组件中更改了父组件的值会报错。
但是!在前面的两个例子中我们都在子组件中更改了父组件的值,只有第一个报错,第二个是可以正常更新的,如果你也同样很疑惑这中间真正的差异点在哪里,那么接着往下阅读吧~
先上结论:
这里我们不关注于太细的细节(不用好奇为什么是这样的顺序,只要记住 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 就强制在开发环境下使用生产模式...
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; } 复制代码
观察下面的动图,被 detached
的组件将不会被检查变更。
而 reattach
则可以让被 detached
的组件重新可以被检测到变更。
reattach
只会重新启用对当前组件的变更检测,但是如果父组件没有启动变更检测,那么 reattach
并不会起作用,而 markForCheck
可以很好地解决这个问题。
这一点在 ng-zorro 的源码中可以了解一二。
例如在 nz-anchor 组件中更改 nz-anchor-link 组件的 active 属性时,由于本身 ChangeDetectionStrategy
为 OnPush
,那么就需要激活 markForCheck 来重新启用检测。具体写法可以查看 github 中的源代码。
用动图来展示则是这样,注意观察设置了 MFC 的前后变化
这个方法如同字面意思一样很好理解,就是触发一次变更检测啦,还记得本文中的第一个例子吗,我们不手动触发 tick()
,而是触发 detechtChanges()
也是可以达到效果的。
到这里,我相信大家已经基本弄明白了 Angular 变更检测,如果有任何疑问,欢迎在评论区交流讨论~
在撰写这篇文章时,笔者参(fu)考(zhi)了大量的社区文章和讨论,一方面是感慨如此重要的概念在 Angular 中文社区中却只有零星几篇相关介绍的文章,另一方面是看到了虽然国内 Angular 开发者虽然数量远少于 React 和 Vue,却依然非常热情的贡献自己的知识和见解来为 Angular 中文社区添砖加瓦,作为已使用 Angular 半年多的开发者,深深感受到 Google 的工程美学。
大而全且不失优雅,是笔者对 Angular 这款 Web 框架的最大感受,感谢开源社区中的各位开发者们~
对于文中描述错误的地方,还望大佬们批评斧正~