Angular中的变化检测是一种用于将应用程序UI的状态与数据的状态同步的机制。当应用程序逻辑更改组件数据时,视图中绑定到DOM属性的值也会更改。变化检测器负责更新视图以反映当前的数据模型。在阅读这篇文章之前,建议先看看我的前两篇与变化检测密切相关的博文,分别是《 揭秘Angular 生命周期函数》和《 Angular 之 zone.js 介绍》。纸上谈兵的成果总是肤浅的,我知道这件事必须要做。为了让读者更容易理解,本文从一个小例子开始,然后逐步展开。示例如下://app.component.tsimport{Component}from'@angular/core';@Component({selector:'app-root',templateUrl:'./app.component.html',styleUrls:['./app.component.css']})exportclassAppComponent{title='aa';handleClick(){this.title='bb';}}//app.componnet.html
{{title}}
例子比较简单,就是给div元素绑定一个点击事件,并且点击元素会改变变量title的值,界面的显示也会随之更新。框架如何知道何时以及如何更新视图?让我们找出来。当我们点击div元素时,handleClick函数将被执行。那么这个函数在Angular应用中是如何触发和执行的呢?如果你看过我之前关于zo??ne.js介绍的文章,你就会知道Angular应用中的点击事件已经被zone.js接管了。根据这个回答,很明显一开始肯定是zone.js触发的,但是这里需要进一步分析直接调用关系,逐层展开。最接近handleClick函数调用的是以下代码:functionwrapListener(listenerFn,...){它是handleClick,但它也是wrapListener函数的参数。例子中元素绑定了点击事件,相关的模板编译产物大概是这样的:AppComponent_Template_div_click_0_listener(){returnctx.handleClick();})}应用第一次加载时,会依次执行renderView,然后执行executeTemplate,然后触发上述模板函数,所以元素的点击功能将一直传递到listenerFn参数。这里我们了解到点击功能的触发源是zone.js,但是真正的点击功能传递是由Angular实现的,那么zone.js和Angular有什么关系呢?zone.js会为每个异步事件安排一个任务。在本文的例子中,调用invokeTask的代码如下:(delegate,current,target,task,applyThis,applyArgs)=>{try{onEnter(zone);returndelegate.invokeTask(target,task,...);}finally{onLeave(zone);}}})}你对这个很熟悉,因为在之前关于zone.js的文章中有类似的代码片段。forkInnerZoneWithAngularBehavior函数由类NgZone的构造函数调用。至此我们介绍了Angular变化检测的主角NgZone,它是对zone.js的简单封装。既然我们知道了例子中点击功能是如何执行的,那么如果在该功能执行后应用数据发生变化,视图如何及时更新呢?我们回到上面提到的forkInnerZoneWithAngularBehavior函数,在tryfinally语句块中,执行invokeTask函数,最后执行onLeave(zone)函数。再往下分析,我们可以看到onLeave函数最后调用了checkStable函数:/**@internal*/constructor(){this._zone.onMicrotaskEmpty.subscribe({next:()=>{this._zone.run(()=>{this.tick();});}});在订阅相关的回调函数中,this.tick()是不是很眼熟?如果你看过我之前关于Angular生命周期函数的文章,那么你肯定会有这样的印象,那就是触发视图更新的关键调用。虽然在介绍生命周期的文章中提到了这个功能,但本文的重点是变更检测,所以虽然功能相同,但侧重点略有变化。this.tick相关的调用顺序大概是这样的:this.tick()->view.detectChanges()->renderComponentOrTemplate()->refreshView()这里refreshView比较重要,单独分析:functionrefreshView(tView,lView,templateFn,context){......if(templateFn!==null){//关键代码1executeTemplate(tView,lView,templateFn,...);}......if(components!==null){//关键代码2refreshChildComponents(lView,components);}}这个过程中会调用refreshView函数两次,第一次进入关键代码2分支,然后依次调用以下函数重新进入refreshView函数:refreshChildComponents()->refreshChildComponents()->refreshComponent()->refreshView()第二次调用refreshView函数是关键代码1分支,即执行了executeTemplate函数。而这个函数最后执行的是模板编译产物中的AppComponent_Template函数:functionAppComponent_Template(rf,ctx){if(rf&1){//条件分支1i0["??elementStart"](0,"div",0);i0["??listener"]("click",functionAppComponent_Template_div_click_0_listener(){returnctx.handleClick();});i0["??text"](1);i0["??elementEnd"]();}if(rf&2){//条件分支2i0["??advance"](1);i0["??textInterpolate"](ctx.title);依赖注入原理推荐阅读之前的文章,限于篇幅不再赘述。此时AppComponent_Template函数执行条件分支2中的代码,而??advance函数的作用是更新相关索引值,确保找到正确的元素。这里我们重点关注??textInterpolate函数,它最终会调用函数??textInterpolate1://键码1constinterpolated=interpolation1(lView,prefix,v0,suffix);if(interpolated!==NO_CHANGE){//关键代码2textBindingInternal(lView,getSelectedIndex(),interpolated);}return??textInterpolate1;}值得注意的是,函数名以数字1结尾,因为还有类似的??textInterpolate2、??textInterpolate3等,Angular内部根据插值表达式的个数调用不同的特殊函数。在本文的例子中,文本节点的插值表达式个数为1,所以实际调用的是??textInterpolate1函数。这个函数主要做了两件事。键码1的作用是比较插值表达式的值是否更新,键码2的作用是更新文本节点的值。我们看一下关键代码1的函数interpolation1,它最终调用了:functionbindingUpdated(lView,bindingIndex,value){constoldValue=lView[bindingIndex];如果(Object.is(oldValue,value)){返回false;}else{lView[bindingIndex]=value;返回真;}}变化检测前文本节点的值称为oldValue,保存在lView中。上一篇文章中提到了lView,忘记的读者可以去看看lView的作用。bindingUpdated首先将新值与旧值进行比较,比较的方法是Object.is。如果旧值未从新值更改,则返回false。如果有变化,更新lView中存储的值并返回true。关键代码2的函数textBindingInternal最终调用了以下函数:isProceduralRenderer(渲染器)?renderer.setValue(rNode,value):rNode.textContent=value;}经过以上过程,当我们点击div元素时,界面显示内容会由aa变为bb,即完成了从应用数据变化到UI状态的同步更新。这是Angular最基本的变更检测流程。限于篇幅,本文给出的例子比较简单,但是Angular还有很多变化没有提到。比如,如果一个应用是由几个组件组成的,如何在父子组件之间进行变化检测,如何通过策略优化变化检测等。这方面有兴趣的朋友可以关注我个人的[朱宇杰的博客],以后会在这里分享更多的前端知识。