Angular 变更检测 —— 它到底是如何工作的?

原文:https://blog.angular-university.io/how-does-angular-2-change-detection-really-work/

Angular的变更检测机制比起Angular 1 更加透明和容易理解。但是仍然存在许多场景(比如进行性能优化的时候)使得我们有必要知道它背后到底发生了什么。所以,让我们来讨论以下主题以深入理解变更检测:

  • 如何实现变更检测?
  • Angular的变更检测器是什么样子的,我能看见它么?
  • 默认的变更检测机制是如何工作的
  • 开启/关闭变更检测,以及手动触发
  • 避免变更检测循环:生产模式 vs 开发模式
  • OnPush的变更检测模式究竟做了什么?
  • 使用 Immutable.js 简化一个Angular应用的构建
  • 结论

如果你在寻找关于OnPush变更检测的更多信息,可以查看这篇帖子Angular的OnPush变更检测和组建设计-避免常见陷阱

如何实现变更检测?

Angular可以检测到组件数据的变化, 并且根据这些变化自动重新渲染界面。但它是如何在例如点击按钮这种全页面都可能会发生的底层事件后检测到变化的呢?

为了理解它是如何工作的,我们首先要意识到Javascript被设计为,在整个运行时都是可以被重写的。只要你想,String或Number中的方法都可以覆盖掉。

覆盖浏览器的默认机制

Angular会在启动时间增强一些底层的浏览器API,比如浏览器用来注册所有事件,包含点击事件的函数:addEventListener。Angular会用一个等价的新版本去替换addEventListener:

// this is the new version of addEventListener
function addEventListener(eventName, callback) {
     // call the real addEventListener
     callRealAddEventListener(eventName, function() {
        // first call the original callback
        callback(...);     
        // and then run Angular-specific functionality
        var changed = angular2.runChangeDetection();
         if (changed) {
             angular2.reRenderUIPart();
         }
     });
}

这个新版的addEventListener为所有事件添加了更多功能:不只是注册过的回调函数会被调用,Angular也有了机会去执行变更检测并且更新界面。

底层运行时补丁做了些什么?

对于浏览器API的底层补丁是由Angular中叫做Zone.js的库完成的。理解zone是什么是很重要的。

zone只不过是一个可以在多个Javascript VM执行回合后幸存的执行上下文。它是一个我们可以用于给浏览器添加额外的功能的通用的机制,Angular内部用Zones触发更新检测,但是它另一个用途是用来做应用程序性能分析,或者保持对跨多个VM回合的长堆栈跟踪的跟踪。(译者理解:这里的VM回合就是通常所说的js事件循环,

浏览器异步API的支持

以下经常使用的浏览器机制会被添加补丁,以支持变更检测:

  • 所有的浏览器事件(click,mouseover,keyup等)
  • setTimeout和setInterval
  • Ajax请求

事实上,许多其他的浏览器API也会被Zone.js添加补丁以透明的触发Angular的变更检测,例如Websocket,参考Zone.js的测试说明可以看到当前支持的所有API。 这个机制的局限性是如果因为某些原因某个异步的浏览器API不被Zone.js支持了,那么对应的变更检测也不会被触发。例如,IndexedDB的回调。 这解释了变更检测是如何被触发的,但触发后它究竟做了什么呢?

变更检测树

每个Angular组件都有一个相关联的在程序启动时间被创建的变更检测器。举个例子,我们假设有一个TodoItem组件:

@Component({
    selector: 'todo-item',
    template: '<span class="todo noselect" (click)="onToggle()">{{todo.owner.firstname}} - {{todo.description}} - completed: {{todo.completed}}</span>'
})
export class TodoItem {
    @Input()
    todo:Todo;

    @Output()
    toggle = new EventEmitter<Object>();

    onToggle() {
        this.toggle.emit(this.todo);
    }
}

该组件将接收一个Todo对象作为输入,并会在其完成状态属性发生变化时发射事件。为了让这个例子更有趣,这个Todo类包含一个嵌套对象:


export class Todo {
    constructor(public id: number, 
        public description: string, 
        public completed: boolean, 
        public owner: Owner) {
    }
}

我们可以看到Todo拥有一个属性owner,owner本身拥有两个属性:firstname和lastname。

Todo 项目的变更检测器长什么样子呢?

实际上,我们可以在运行时看到这个变更检测器长什么样子!我们只需要添加一些代码在Todo类中,使得某些属性被访问时去触发一个断点 当这个断点被触发,我们可以浏览堆栈trace并看到变更检测的操作:

code

不要担心,你永远不需要debug这些代码!在这之中也没有任何魔法,这不过是程序在启动时构建出来的普通Javascript方法。但它做了什么呢?

默认的变更检测机制是怎么工作的呢?

这个方法和这些有奇怪命名的变量们在一开始看起来可能会十分陌生。但是通过深入挖掘,我们注意到它做的事情很简单:它会比较在模板中每个表达式用到的属性的现值和前值。

如果这个属性值前后不同,它会标记isChanged为true,这就是这样!我们接近了,它用一个方法叫做looseNotIdentical()的方法去做值的比较。这其实就是一个对NaN场景拥有特殊逻辑的===比较方法(查看这里)。

那么对于这个嵌套对象owner呢?

我们可以看到在修改检测的代码中也包含了对嵌套对象owner的修改检测。但只有firstname属性参与了比较,lastname属性则没有。 这是因为在组件模板中并没有使用到姓氏!同理,Todo类中的顶级属性id也没有进行比较。

基于这些,我们可以放心地说:

默认情况下,Angular变更检测机制是通过检查模板表达式中的值是否发生了变化来工作的。所有的组件中都会这么做。

我们也可以得出以下结论:

默认情况下,Angular不会对对象进行深度比较,它只会比较模板中使用到的属性。

为什么默认情况下的变更检测器是这样工作的?

Angular的一个主要目标就是更透明且易用。这样框架的使用者不必花费很长时间去调试框架并了解其内部机制,从而可以能用框架进行高效的开发。 如果你对Angular1很熟悉,回想一下$digest()$apply()以及所有使用或不使用它们时的那些陷阱。Angular的主要目标之一就是避免它们。

为什么不比较引用呢?

事实是Javascript对象是可变的,而Angular希望为此提供开箱即用的支持。 想象一下,如果Angular的默认的变更检测机制将基于组件输入的引用进行比较的而不是默认的机制会发生什么?即使是像TODO这样简单的应用也会变得难以构建:开发者不得不十分小心地创建新的Todo对象,而不是简单地修改属性值。 但接下来我们就会看到,如果我们需要的话,定制Angular的变更检测也是可行的。

性能如何?

注意,待办事项列表组件的修改检测器是显式引用todo属性的。 另一种实现方式是通过组件的属性动态循环遍历,这使得代码更通用而不是特定于某个组件。这样,我们就不必在一开始就为每个组件构建一个变更检测器!所以我们为什么没有这样做呢?

虚拟机内部的速览

所有这些都与Javascript虚拟机的工作方式有关。动态比较属性,尽管编写出来的代码更加通用,但是不能被Javascript VM的just-in-time编译器轻易优化。 这与变更检测器的专用代码不同,变更检测的专用代码会明确地访问组件的所有输入属性。该代码更接近我们手动编写的代码,并且更容易被虚拟机转换为本地代码。

结果是,使用生成的且显式的检测器的变更检测机制非常快(比Angular1快很多),可预测且易于推理。 但是如果我们遇到了一个性能问题,是否有方式优化变更检测呢?

OnPush变更检测模块

如果我们的Todo列表变得非常巨大,我们可以配置TodoList组件只在Todo list变化时更新,可以通过修改组件的变更检测策略为OnPush来实现:

@Component({
    selector: 'todo-list',
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: ...
})
export class TodoList {
    ...
}

现在让我们向应用程序添加一对按钮:一个通过直接修改列表中的首个元素来更改完成状态,另一个会在列表中添加一个待办事项。代码如下:

@Component({
    selector: 'app',
    template: `<div>
                    <todo-list [todos]="todos"></todo-list>
               </div>
               <button (click)="toggleFirst()">Toggle First Item</button>
               <button (click)="addTodo()">Add Todo to List</button>`
})
export class App {
    todos:Array = initialData;

    constructor() {
    }

    toggleFirst() {
        this.todos[0].completed = ! this.todos[0].completed;
    }

    addTodo() {
        let newTodos = this.todos.slice(0);
        newTodos.push( new Todo(1, "TODO 4", 
            false, new Owner("John", "Doe")));
        this.todos = newTodos;
    }
}

现在让我们看看两个新按钮的行为:

  • 第一个按钮“Toggle First Item”不起作用!这是因为toggleFirst()方法直接修改了列表的元素,因为输入属性todos引用本身没有变化,所以TodoList无法检测到这个修改。
  • 第二个按钮正常工作!请注意addTodo()创建了一个todo list的拷贝,将新的事项添加在新的拷贝中,并在最后将成员变量待办事项列表替换为这个拷贝的列表。因为组件检测到了输入属性的引用发生了变化——变成了新的列表,所以修改检测触发了。
  • 在第二个按钮中,如果直接修改当前的todos list的话就不起作用了!我们需要一个新的列表。

OnPush真的仅仅比较输入引用吗?

如果尝试在某个todo item上点击,你会发现它仍然可以正常工作,这和我们刚刚的结论不符!即使你将TodoItem切换为OnPush也一样。这是因为OnPush不仅仅会检查组件的输入:如果一个组件发射了事件,那么变更检测也会被触发。

根据Victor Savkin在他的博客中的这段话:

当使用OnPush检测器时,对于任何一个OnPush组件,框架会在其输入属性变化时、组件发射一个事件时或者一个Observable发射一个事件时触发变更检测。

尽管可以提供更好的性能,但如果与可变对象一起使用,则使用OnPush会代来很高的复杂度成本。它可能会引入难以推理和重现的错误。但是有一种方法可以使OnPush的使用可行。

使用Immutable.js简化Angular应用程序的构建

如果我们仅使用不可变对象和不可变列表构建应用程序,透明地在任何地方使用OnPush且避免陷入变更检测bug的风险中就成为了可能,这是因为对于不可变对象,修改数据的唯一方法是创建一个新的不可变对象并替换先前的对象。对于不可变的对象,我们可以保证:

  • 一个新的不可变对象将始终触发OnPush更改检测
  • 我们不能因为忘记创建对象的新副本而意外地发生错误,因为修改数据的唯一方法就是创建新对象。 选择不可变模式的一个不错的选择是使用Immutable.js库。该库提供了用于构建应用程序的不可变的原始类型,例如不可变对象(Map)和不可变列表。 该库也可以以类型安全的方式使用,请查看此先前文章以获取有关如何执行此操作的示例。

避免变更检测循环:生产模式 VS 开发模式

Angular变更检测与Angular 1不同的重要特性之一是它强制执行单向数据流:当我们的控制器类上的数据更新时,变更检测将运行并更新视图。

尽管视图的更新本身并不会触发进一步的更改,但之后的其他修改却会触发对视图的进一步更新,所以Angular 1引入了消化循环。

如何在Angular中触发变更检测循环?

一种方法是使用生命周期回调。例如,在TodoList组件中,我们可以触发对另一个组件的回调,从而更改其中一个绑定:

ngAfterViewChecked() {
    if (this.callback && this.clicked) {
        console.log("changing status ...");
        this.callback(Math.random());
    }
}

一个错误消息将显示在控制台中:

EXCEPTION: Expression '{} in App@3:20' has changed after it was checked

仅当我们在开发模式下运行Angular时,才会引发此错误消息。如果启用生产模式会怎样?


enableProdMode();

@NgModule({
    declarations: [App],
    imports: [BrowserModule],
    bootstrap: [App]
})
export class AppModule {}

platformBrowserDynamic().bootstrapModule(AppModule);

在生产模式下,异常不再抛出,该问题将无法检测到。

变更检测问题是否经常发生?

我们确实需要竭尽所能触发变更检测循环。但是以防万一,我们最好总是在开发阶段使用开发模式,这样就可以避免这个问题。

这项保障是以Angular始终运行两次更改检测为代价的,第二次检测的目的就是为了避免此类场景。在生产模式下,修改检测则只会执行一次。

开启/关闭变更检测,以及手动触发

在某些特殊情况下,我们确实希望关闭变更检测。想象一下一种情况,其中大量数据通过Websocket从后端到达。我们可能只想每5秒更新一次UI的特定部分。为此,我们首先将变化检测器注入组件中:

constructor(private ref: ChangeDetectorRef) {
    ref.detach();
    setInterval(() => {
      this.ref.detectChanges();
    }, 5000);
  }

如我们所见,我们分离了变更检测器,这会导致变更检测功能被关闭。然后,我们只需每5秒通过调用detectChanges()手动触发它即可。

结论

Angular默认更改检测机制实际上与Angular 1类似:它比较浏览器事件之前和之后的模板表达式的值,以查看是否有所更改。它会作用在所有组件上。但是也有一些重要的区别: 第一点是不存在修改检测循环(在Angular 1中称为消化循环)。这使得仅通过查看模板和控制器就来推断出每个组件。 另一个区别是,由于构建了变更检测器的方式,检测组件变更的机制要比之前快得多。 最后一点和Angular1中不同的是,变更检测机制可以被自定义的。

我们是否真的需要深入了解变更检测器?

对于95%的应用和用例,我们可以自信地说,Angular的变更检测都能良好的工作,并且对于它我们并不需要了解得太多。即便如此,能够理解它的工作原理依然是非常游泳的,有几个原因:

  • 首先它能帮助我们理解一些开发模式下的错误信息,例如变更检测循环有关的问题。
  • 它帮助我们阅读错误堆栈跟踪,那些突然蹦出来的zone.afterTurnDone()看起来终于清晰了;
  • 如果性能很重要(不过你真的确定不在那些巨型数据表格上使用分页机制吗?),了解关于变更检测有助我我们做性能优化。
# fe  angular 

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×