你需要了解的关于Angular 变更检测的一切

原文传送门
翻译说明: 本文翻译采用意译并对原文进行适当排版以方便阅读。
术语采用加粗斜体表示, 术语第一次出现时其后括号内标注英文术语。


如果你像我一样,想对 Angular 中的 变更检测 (Change Detection) 机制有个全面的了解,那么你将不得不探究源码,因为网络上获取不到足够的信息。大多数文章所提到的是:每个组件 (Component) 都有自己的 变更检测器 (change detector) 来负责检查组件, 但是这些文章不会超过这个范围而且大多数会关注于 不可变对象 (immutables) 以及 变更检测策略 (change detection strategy) 的用例 (use cases)。本文会提供给你需要的信息让你明白:

  • 为什么 使用 不可变对象用例 会有效?
  • 变更检测策略 是如何影响检测(check)的?

不仅如此, 你从本文学到的东西也让你能够靠自己就能针对不同的场景提出性能优化方案。

本文包含两部分。第一部分技术性较强而且包含大量的源码链接, 这部分详细讲述了变更检测机制工作的底层细节, 基于最新的 Angular版本—— 4.0.1。这个版本的变更检测机制的实现方式不同于之前的 2.4.1。 如果你对此感兴趣,可以阅读下这篇在 stackoverflow 上的回答。第二部分讲述了如何在应用中使用变更检测。因为开放API并没有改变, 这部分内容同时适用于之前的 2.4.1 和 最新的 4.0.1版本。

视图 (View) 是核心概念

在很多教程中已经提到:Angular 应用是组件树。然而,在底层上 Angular 使用了叫做 view 的低级抽象。视图和组件之间有直接的联系 —— 每个视图都和组件相对应,反之亦然。每个视图在 component 属性中都保存了与之相关联的组件类实例的引用。所有的操作, 像 属性 (property) 检查和 DOM 更新,都是在视图上进行的, 因此, 从技术上来讲, angular 应用是视图树更准确些, 而组件可以描述为视图的更高级概念。 在这里你可以读到有关视图的描述:

A View is a fundamental building block of the application UI. 
It is the smallest grouping of Elements which are created and destroyed together.
Properties of elements in a View can change, but the structure 
(number and order) of elements in a View cannot. 
Changing the structure of Elements can only be done by inserting, 
moving or removing nested Views via a ViewContainerRef. 
Each View can contain many View Containers.

(译者注: 上述引用翻译
视图是应用程序 UI 的基本构件。视图是最小的元素组,这些元素被一起创建和销毁。视图中元素的属性会改变, 但是视图中元素的结构(数量和排序)却不会改变。 要更改元素的结构,只能通过使用 ViewContainerRef 进行插入、移动或者删除嵌套视图来完成。每个视图可以包含很多视图容器。)

本文中, 我会交替使用 组件视图 和 组件 这2个概念。

这里需要注意的是: 网络上与变更检测相关的所有文章以及 StackOverflow 上的回答都把我在这里描述的视图看做变更检测器对象 或 ChangeDetectorRef。事实上并没有单独的对象来进行变更检测,变更检测就是在视图上运行。

每个视图都通过 nodes 属性链接到它的子视图, 因此可以对子视图执行操作。

视图状态

每个视图都有个 状态, 状态扮演了很重要的角色。因为基于 状态的值, angular 决定对视图以及该视图 所有的子视图 执行变更检测还是跳过。视图有很多 可能的状态, 但是下面这几个状态是和本文相关的:

  • FirstCheck
  • ChecksEnabled
  • Errored
  • Destroyed

如果

  • ChecksEnabledfalse

或者

  • 视图处于 Errored 或者 Destroyed 状态

那么, 对于视图以及该视图所有子视图的变更检测就会跳过。默认所有的视图会被初始化为 ChecksEnabled, 除非使用了 ChangeDetectionStrategy.OnPush。稍后将详细介绍。状态可以被组合, 举个例子, 视图可以同时设置 FirstCheckChecksEnabled 标志位。

Angular 有一堆高级概念来操作视图。我已经在 这里 写了一些。其中一个概念是 ViewRef , 它封装了 底层的组件视图 并且有个恰当命名的方法 detectChanges 。 当异步事件发生时, Angular 会在最顶层的 ViewRef 触发变更检测 , 对最顶层的 ViewRef 执行完变更检测后,对它的子视图执行变更检测

这个 viewRef 就是你可以使用 ChangeDetectorRef token 注入到组件构造器中的东西:

export class AppComponent {
    constructor(cd: ChangeDetectorRef) { ... }

可以从这个类的定义中看到:

export declare abstract class ChangeDetectorRef {
    abstract checkNoChanges(): void;
    abstract detach(): void;
    abstract detectChanges(): void;
    abstract markForCheck(): void;
    abstract reattach(): void;
}
export abstract class ViewRef extends ChangeDetectorRef {
   ...
}

变更检测操作

负责对视图执行变更检测的主要逻辑位于 checkAndUpdateView 函数。它的大部分功能是对 组件视图执行操作。这个函数会被从宿主组件开始的每个组件递归调用, 意思是 当递归数展开时子组件会在下次调用时成为父组件。

当在特定的视图上触发此函数时,会按指定的顺序执行下列操作:

  1. 如果视图是第一次检测, 设置 ViewState.firstChecktrue 。如果之前已经检测过, 则设置为 false
  2. 检查并更新子组件或子指令的输入属性。
  3. 更新 子视图变更检测状态(变更检测策略实现的一部分)
  4. 对嵌套视图 (embedded views) 执行变更检测 (重复列表中的步骤)
  5. 如果绑定改变, 调用 子组件的 OnChanges 生命周期钩子
  6. 调用 子组件的 OnInitngDoCheck(OnInit只会在第一次检测期间调用)
  7. 更新 子视图组件实例的 ContentChildren 查询表
  8. 调用 子组件实例的 AfterContentInitAfterContentChecked 生命周期钩子 (AfterContentInit 只会在第一次检测期间调用)
  9. 如果当前视图 (current view) 组件实例的属性发生改变, 为 当前视图 更新DOM插值
  10. 对子视图 执行变更检测 (重复列表中的步骤)
  11. 更新 当前视图组件实例的 ViewChildren 查询表
  12. 调用 子组件实例的 AfterViewInitAfterViewChecked 生命周期钩子(AfterViewInit 只会在一次检测期间调用)
  13. 禁止 对当前视图的检查 (变更检测策略实现的一部分)

基于上面的操作列表, 有几项需要强调。

第一项: 在子视图被检查前, onChanges 生命周期钩子会在子组件上触发, 即使对子视图的变更检测跳过了。 这是个很重要的信息, 我们将会在本文的第二部分中了解到如何利用这知识。

第二项: 当视图被检查的时候, 视图 DOM 的更新 是变更检测机制的一部分。这意味着如果组件没有被检查, 那么 DOM 就不会更新, 即使用在模板中的属性发生改变。模板在第一次检测前渲染。我所指的 DOM更新事实上是 插值更新。所以, 如果你有

<span>some {{name}}</span>

那么, DOM 元素 span 会在第一次检查前渲染。 在检查期间, 只有 {{name}} 部分会被渲染。

另一个有趣的现象是: 子组件视图的状态会在变更检测期间改变。 我之前提到过, 所有组件视图默认会用ChecksEnabled 初始化 , 但是对于那些使用 OnPush 策略的组件来说, ChecksEnabled 在第一次检测过后会被禁用。(列表中的操作 9):

if (view.def.flags & ViewFlags.OnPush) {
  view.state &= ~ViewState.ChecksEnabled;
}

这意味着在接下来的变更检测中, 这个组件的视图以及所有的子视图会被跳过。关于 OnPush 策略的文档讲到只有绑定发生改变时, 组件才会被检查。 所以为了实现这个目的, 必须通过设置 ChecksEnabled 位开启检查, 也就是下面的代码所做的(操作2):

if (compView.def.flags & ViewFlags.OnPush) {
  compView.state |= ViewState.ChecksEnabled;
}

只有父视图绑定发生改变并且子组件是用ChangeDetectionStrategy.OnPush` 初始化的, 状态才会更新。

最后, 当前视图的变更检测负责开始对子视图的变更检测(操作 8)。就是在这里对子组件视图的状态进行检查, 并且如果 它是 ChecksEnabled, 那么对这个视图进行变更检测。 这里是相关的代码:

viewState = view.state;
...
case ViewAction.CheckAndUpdate:
  if ((viewState & ViewState.ChecksEnabled) &&
    (viewState & (ViewState.Errored | ViewState.Destroyed)) === 0) {
    checkAndUpdateView(view);
  }
}

现在你明白了: 正是视图状态控制了是否对该视图以及它的子视图进行变更检测。那么问题来了—— 我们能不能控制这个状态? 结论是可以, 而这也是本文第二部分所要讲的。

有些生命周期钩子会在DOM更新前(3, 4, 5)调用, 而有的则会在之后(9)调用。所以, 如果你有下面的组件层次: A-> B -> C, 这里是钩子调用以及绑定更新的顺序:

A: AfterContentInit
A: AfterContentChecked
A: Update bindings
    B: AfterContentInit
    B: AfterContentChecked
    B: Update bindings
        C: AfterContentInit
        C: AfterContentChecked
        C: Update bindings
        C: AfterViewInit
        C: AfterViewChecked
    B: AfterViewInit
    B: AfterViewChecked
A: AfterViewInit
A: AfterViewChecked

探索含义

假设我们有下面的组件树:


如同我们上面了解的, 每个组件都和一个组件视图相关联。 每个视图都会用 ViewState.ChecksEnabled初始化, 也就意味着当 angular 执行变更检测时, 组件树中的所有组件都会被检查。

假如我们想禁止对 AComponent 和它的子组件进行变更检测。 这很容易做到 —— 我们仅仅需要将 ViewState.ChecksEnabled 设置为 false。 改变状态是个很低级的操作。所以 angular 提供了一些在视图上可以使用的公共方法。 每个组件可以通过 ChangeDetectorRef token 获取到相关联的视图。 对于这个类, Angular 文档定义了下面的开放(public)接口:

class ChangeDetectorRef {
  markForCheck() : void
  detach() : void
  reattach() : void
  
  detectChanges() : void
  checkNoChanges() : void
}

detach

第一个允许我们操作状态的方法是 detach, 这个方法简单地禁止对当前视图的检查:

detach(): void { this._view.state &= ~ViewState.ChecksEnabled; }

让我们看下如何将它用在代码中:

export class AComponent {
  constructor(public cd: ChangeDetectorRef) {
    this.cd.detach();
  }

这确保了在接下来的变更检测中,从 AComponent 开始的左边分支会被跳过(橘黄色的组件不会被检测)

这里有两点需要注意—— 第一点就是虽然我们更改了 AComponent 的状态, 它的所有子组件同样不会被检查。 第二点是因为左边分支的组件没有进行变更检测, 在这些组件模板中的DOM 同样不会被更新。 这里有个小例子演示:

@Component({
  selector: 'a-comp',
  template: `<span>See if I change: {{changed}}</span>`
})
export class AComponent {
  constructor(public cd: ChangeDetectorRef) {
    this.changed = 'false';

    setTimeout(() => {
      this.cd.detach();
      this.changed = 'true';
    }, 2000);
  }

组件第一次被检查的时候, span 会用文本 See if I change: false渲染。 2 秒后, 当属性changed更新为 true , span 中的文本并没有改变。 然而, 如果我们移除这行 this.cd.detech() 一切如常。

reattach

正如本文第一部分提到的, 如果 AppComponent 的输入绑定 aProp 改变了, AComponentOnChanges 生命周期钩子仍然会被触发。这意味着, 一旦我们知道输入属性发生了改变, 我们可以激活当前组件的变更检测器来执行变更检测, 然后在下一个节拍中 将它分离。 演示片段:

export class AComponent {
  @Input() inputAProp;

  constructor(public cd: ChangeDetectorRef) {
    this.cd.detach();
  }

  ngOnChanges(values) {
    this.cd.reattach();
    setTimeout(() => {
      this.cd.detach();
    })
  }

因为 reattach 简单地 设置 ViewState.ChecksEnabled 位:

reattach(): void { this._view.state |= ViewState.ChecksEnabled; }

这几乎就等同于将 ChangeDetectionStrategy 设置成 OnPush: 在第一轮变更检测运行后禁止检测, 当父组件绑定属性改变时启用, 在变更检测执行后再禁用。

请注意: OnChanges 钩子只会在禁止检测分支的顶级组件上触发, 并不会在禁止分支的所有组件上触发。

markForCheck

reattach 方法只会启用对当前组件的检查, 但是如果它的父组件的变更检测并没有启用的话, 这并不会起作用。这意味着, reattach 方法只会对 禁用分支的顶级组件有用。

我们需要一种方式来启用对所有直到根组件的父组件的检查。有个这样的 方法 markForCheck:

let currView: ViewData|null = view;
while (currView) {
  if (currView.def.flags & ViewFlags.OnPush) {
    currView.state |= ViewState.ChecksEnabled;
  }
  currView = currView.viewContainerParent || currView.parent;
}

你可以从实现中看到, 它简单的向上迭代并启用对所有直到根组件的父组件的检查。

这什么时候有用? 就像 ngOnChanges 一样,即使组件使用 OnPush 策略,ngDoCheck生命周期钩子也会被触发。再次强调, 它只会在禁止检测分支的顶级组件上触发, 而不是禁止检测分支的所有组件。但是, 我们可以使用这个钩子进行自定义逻辑并把我们的组件标记为合乎变更检测周期运行条件。 由于 Angular 只会检查 对象引用, 我们可以实现对某些对象属性的脏检查:

Component({
   ...,
   changeDetection: ChangeDetectionStrategy.OnPush
})
MyComponent {
   @Input() items;
   prevLength;
   constructor(cd: ChangeDetectorRef) {}

   ngOnInit() {
      this.prevLength = this.items.length;
   }

   ngDoCheck() {
      if (this.items.length !== this.prevLength) {
         this.cd.markForCheck(); 
         this.prevLenght = this.items.length;
      }
   }

detectChanges

有种方式可以对当前组件以及它的所有子组件执行一次变更检测。 这可以通过使用 detectChanges 方法 完成. 无论其状态如何,此方法都会对当前组件视图进行变更检测,这意味着对当前组件视图的检查可能依然是禁用的并且组件在接下来的常规变更检测中不会被检查。这里有个例子:

export class AComponent {
  @Input() inputAProp;

  constructor(public cd: ChangeDetectorRef) {
    this.cd.detach();
  }

  ngOnChanges(values) {
    this.cd.detectChanges();
  }

当输入属性改变时, DOM 会被更新, 即使 变更检测器引用仍然是分离的。

checkNoChanges

最后一个可以从 变更检测器获取到的方式可以确保在本轮变更检测中没有变化。 基本上来说, 它会执行本文中提到的操作列表中的 1, 7, 8. 如果发现了绑定改变或者 DOM 应该被更新的情况, 就会抛出异常。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349