Angular 17+ 高级教程 – Change Detection & Ivy rendering engine

前言

不熟悉 Angular 的朋友可能不了解 Change Detection 和目前当火的 Signal 之间的关系,以至于认为现在应该要学习新潮流 Signal 而不是已经过时的 Change Detection。

其实这个想法是完全错误的。Change Detection 和 Signal 的底层都是 Ivy rendering engine,它们的知识比例大约是 Ivy 95%,Change Detection 2.5%,Signal 2.5% 。

Ivy 是一定要学的,Signal 和 Change Detection 可以选其中一个,但它们才 2.5% 而且,有什么好选的,倒不如把它们通通学全。

本篇会教 Ivy rendering engine 和 Chagne Detection,Signal 以后会另外教。

 

MVVM 的难题

什么是 MVVM

MVVM 框架的开发方式是这样的:

写 HTML

写 ViewModel

在 HTML 里加入 binding syntax。

在 HTML 里加入 listening syntax,在事件发生时修改 ViewModel。

MVVM 的宗旨是 "不要直接操作 DOM"。所以上面我们完全没有任何 DOM manipulation。

框架会替我们做 2 件是:

第一是创建 DOM

HTML + binding syntax + ViewModel = DOM

第二是更新 DOM

框架会监听 ViewModel 的变化,然后通过 HTML 中的 binding syntax 找到对应的 Node(节点) 做更新。

MVVM 的难题

MVVM 框架有两大难题。

第一个是:框架如何监听到 ViewModel 的变化?

第二个是:如何做到局部更新?(只更新被修改的部分)

 

Angular 渲染机制(基础)

开篇时,我就介绍了 Angular Compilation,我们写的 HTML 最终都会变成 JS 代码。

app.component.html

h1、button 在编译后会变成这样

elementStart、elementEnd、textInterpolate 这些代码都是 DOM manipulation,比如 createElement、appendChild、assign textContent 等等。

Angular 把这些操作分为 2 段,第一段负责创建 DOM,第二段负责更新 DOM。

也就是说,只要执行第一段代码,这个组件的 DOM 就做出来了,再执行第二段代码,binding 的资料就更新进 DOM 了。

另外 Angular 还会监听 ViewModel 变化,每当 ViewModel 被修改,Angular 就会再执行第二段代码,DOM 就更新了。这大致就是 Angular 渲染的机制和过程。

小知识:如果 HTML 里没有任何 binding syntax,Angular compiler 是不会生成任何更新 DOM 代码的哦。

 

Angular View (视图) の TView、LView、TNode、RNode 初探

本来我是想 skip 掉这 part 的,因为这些知识有点过于底层了。

不过为了深入理解 Angular 的 Change Detection、NodeInjectorDynamic Component

我觉得还是有必要了解这些底层知识的。

什么是 Template (模板)?

Template (模板) 就是对 HTML 的封装。

Web Components = Custom Elements + Shadow DOM + Template

Template 是组件三特性中的其中一个。

Angular 万物皆是组件,所以在 Angular 项目中,所有的 HTML 都是 Template。

上图是 hello-world.component.html,它是 HelloWorld 组件的 Template。

什么是 View (视图)?

View 是一个抽象的容器,里面包裹着一个或多个 nodes (节点),有点类似 DocumentFragment

那为什么要将 nodes 包起来呢?因为这些 nodes 有一些共同性,或者是为了要分组做管理。

我们来看一个 step by step 非 Angular 的 Web Components 例子,体会一下。

下图是一个 Template

<body>
  <template>
    <h1>Hello World</h1>
    <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Corporis, quos!</p>
  </template>
</body>

<template> 不会被游览器渲染,所以目前屏幕是空白的。

那要怎样使用 Template 呢?答案是用 Custom Element。

定义 HelloWorld 组件

class HelloWorldComponent extends HTMLElement {
  constructor() {
    super();
    const template = document.querySelector<HTMLTemplateElement>('template')!;
    const view = template.content.cloneNode(true);
    this.append(view);
  }
}
customElements.define('hello-world', HelloWorldComponent);

在组件里拿 Template clone 出 View 然后 append。

<body>
  <template>
    <h1>Hello World</h1>
    <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Corporis, quos!</p>
  </template>
  <hello-world></hello-world>
  <hello-world></hello-world>
</body>

效果

为什么组件内用 Template clone 出来的一组 nodes 要被称为 View 呢?

因为它们有共同性。让我们引入 Shadow DOM 概念。

假如,现在从 body query “h1”,会得到 2 个 h1。它们来自 HelloWorld 组件内的 nodes。

console.log(document.body.querySelectorAll('h1').length); // 2

加入 Shadow DOM 概念。

class HelloWorldComponent extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'closed' });
    const template = document.querySelector<HTMLTemplateElement>('template')!;
    const view = template.content.cloneNode(true);
    shadow.append(view);
  }
}

现在再 query "h1" 结果是 0 个。

console.log(document.body.querySelectorAll('h1').length); // 0

下图 2 个框就是 2 个 View,它的特点就是与世隔绝。

所以,我们可以这样去形容 -- 组件的 View 是与世隔绝的,我们无法从外部 query 到 View 里面的 nodes。

什么是 RNode 和 RElement?

RNode 全称是 Render Node,RElement 全称 Render Element。

它们是 Angular 对 DOM Node 和 HTMLElement 的接口。Angular 不想直接依赖 DOM,所以它搞了这两个接口。

如果环境是游览器,那最终实现这两个接口的就是 DOM Node 和 HTMLElement。

什么是 TNode?

TNode 全称是 Template Node。顾名思义,它是节点的模型,用于生产出 RNode,就像 Template 生产出 View 那样。

什么是 TView?

TView 全称是 Template View。顾名思义,Template 意味着它也是个模型。

View 意味着它是一组 nodes 的 frame。合在一起大致意思就是一个 nodes frame 的模型。

按推理,一组 TNode 会形成一个 TView,然后 TView 用于生产 RView。

这个推理只对了一半,TView 确实包裹着一组 TNode,但 TView 并不生产 RView,它生产的是 LView。

什么是 LView?

LView 全称是 Logical View。它有点像 React 的 Virtual DOM。

Angular 搞了一个中间层做管理,TView 生产出 LView,而 LView 则用来控制 RView。

LView 是 Change Detection 的主角,要深入理解 Change Detection,LView 是必备知识。

 

Angular bootstrapApplication の TView、LView、TNode、RNode 的创建过程

光看 Definition 我相信大家依然是云里雾里的,我们来看一个具体的例子。

Template 阶段

下图是 HelloWorld 组件 Template

下图是 App Template

里面包含 2 个 HelloWorld 组件的使用。

好,就是这么简单的例子。

Compilation 阶段

下图是 HelloWorld 组件经过编译 (compile) 之后的组件 Definition (a.k.a ComponentDef)。

原本的 Template 变成了一堆的函数调用,例如:ɵɵelementStart、ɵɵtext。

这些函数就是用来创建 TView、TNode、LView、RNode 的哦。

下图是 App Definition。

bootstrapApplication 阶段 の 创建 DOM

上面只是组件的 Definition,只是定义而已,不是具体执行,那谁去拿这些 Definition 来执行呢?

答案是 bootstrapApplication 函数。

ng build --configuration=development

Angular 的入口是 main.js

main.js 里有一行代码调用了 bootstrapApplication 函数。

意思就是启动 App 组件咯。(注:下面我们只看过程,不看源码,源码有机会我们再逛)

bootstrapApplication 首先会创建一个 Root LView。

它是整个 Application 最 top 一层的 LView。有点像 body 的感觉。

要创建 LView 前提是要先有 TView。所以 Angular 会先创建 Root TView 然后用它生产出 Root LView。

目前的结构是下图这样

注:TView 是一个 JS 对象,LView 是一个 Array (我们也可以把它当成对象来看待)。总之,它们就是用来存资料而已。

接着就是开始处理 App 组件。

App 组件是一个 Node。所以创建 App TNode,把 TNode 放到 Root TView 里。

App RNode 不需要从 TNode 生产,因为它已经存在 index.html。

你可能会想,既然不需要生产 RNode,那为什么还需要创建 TNode?

因为 TNode 存放的资料不仅仅是为了生产 RNode,还有维护 RNode,所以 TNode 是一定需要的。同样的道理,要维护 LView 也必须要有 TView。

目前的结构是这样

App 是组件,它有 Template,Template 里有 nodes,所以还得继续往下处理 App Template。

做法是一样的,创建 App TView 和 App LView。

目前的结构是这样

这里有两个重点:

1. Root LView 里的 App RNode 换成了 App LView,因为它不是一个单纯的 Node,它是组件。

2. Root LView 和 App LView 是父子关系。LView 和 LView 之间是有 Hierarchical 概念的。

但是,Root TView 和 App TView 则不是父子关系哦。因为它们只是模型,关系是建立在它们生产出来的 LView 上而已。(提醒:一个 TView 可以生产出多个 LView)

接着创建 App Template 内的 Node。

一个 p,一个 text,两个 HelloWorld 组件。

现在 App LView 里有 2 个 HelloWorld RNode,由于它们也是组件,所以还得继续往下处理这两个 HelloWorld RNode。

创建 1 个 HelloWorld TView 和 2 个 HelloWorld LView。(注:TView 是模型,所以只需要一个,LView 则是依据 Node,有多少 Node 就生产多少个,而且它们和 parent LView 要有关联哦)

接着去每一个 HelloWorld LView 创建它们的 TNode 和 RNode。

一个 h1,一个 text,一个 p,一个 text。

至此,TView、LView、TNode、RNode 全部创建完毕。

上面这些便是 bootstrapApplication 过程中 “创建 DOM“ 环节所做的事。这时 document 里就有 nodes 了 (上面哪些 RNode 就是 Node、Text、HTMLElement 这些来的)。

但是,如果 Template 有 binding syntax,这时 ViewModel 的资料是还没有放进 node 的哦,因为还有一个 "更新 DOM" 环节还没有跑。

bootstrapApplication 阶段 の 更新 DOM

上面例子中,Template 里没有任何 binding syntax,所以 compile 后的 HelloWorld template 方法没有关于更新 DOM 的代码。

我们补上一个。

value 是组件属性,也就是所谓的 ViewModel。

compile 后的 HelloWorld template 方法长这样。

注意看,在 create mode 位置 1 的 text 是 empty string。

到了 update mode,第一行代码 ɵɵadvance(1) 是移动到位置 1 的 text,第二行代码 ɵɵtextInterpolate1 是把 ViewModel 更新进 text。

ctx 是组件实例,也就是所谓的 ViewModel。

好,现在我们回到 bootstrapApplication 创建 DOM 过程的结尾,现在 LView Tree 已经创建好了,DOM Tree 也创建好了。

这时,Angular 会从 Root LView 开始往下遍历每一个 LView,然后执行它们的 template 方法 by update mode(也就是更新 DOM 啦)。

至此,第一次渲染就算是完成了。

接下来就等待每一次 ViewModel 发生变化,Angular 又再从 Root LView 往下遍历,执行它们的 template 方法 by update mode,这样 DOM 就更新了。

小知识:

所有组件 template 里的小代码(比如 ɵɵtextInterpolate1)内部都会先判断 ViewModel 前后的 value 是否不一样,如果不一样才做 DOM 更新。

这是一个小小的性能优化,因为 Angular 每一次想 "更新 DOM" 最小单位就是一个 LView,所以在每一个小节点更新前还需要再判断一次。

LView Tree vs DOM Tree

LView Tree 是用来维护和更新 DOM Tree 的。

LView Tree 和 DOM Tree 的树形结构不一定是一致的,在 Content Projection 和 Dynamic Component 的情况,这两个通常是不一致的。

Angular 绝大部分的逻辑都是依据 LView Tree 来走的,所以我们要多多关注 LView Tree。

 

如何查看 LView 和 TView?

export class HelloWorldComponent {
  value = '!!';

  constructor() {
    const viewRef: any = inject(ChangeDetectorRef);
    console.log(viewRef._lView);
  }
}

在组件 inject ChangeDetectorRef(a.k.a ViewRef)通过访问私有属性 _lView 就可以拿到组件的 LView 了。

还有一个方式是通过 Chrome DevTools(好像是需要搭配 Angular DevTools 才行哦)

选择一个组件内的 node (注意:不是选组件本身哦,而是选组件内的 node)

比如我想获取 HelloWorld LView 那我就选 <app-hello-world> 里面的其中一个 node,比如 <h1>。

如果我想获取 App LView 则是选择 <app-root> 里面的其中一个 node,比如 <app-hello-world>。

然后输入 $0.__ngContext__

$0 是获取当前 node,.__ngContext__ 是 Angular 的一个私有属性,value 是 LContext,它里面有一个 lView 属性,这就是 HelloWorld LView 了。

想查看 LView 的 TView 可以这样写

$0.__ngContext__.lView[1]

LView 是一个 Array,位置 1 存放的便是其 TView 对象。

 

LView 和 TView 保存了什么资料?

虽然 LView 和 TView 保存的资料和本篇要教的 Change Detection 没多大关系,但既然都讲到这里了,就顺便了解一下呗。

LView 保存的资料

下面这个是 App 组件的 LView 资料

它虽然是 Array,但其实更像是 Object 多一点。

源码在 /packages/core/src/render3/interfaces/view.ts

这些资料会用在许许多多地方。

我讲一些比较有名的:

[HOST] 是这个 LView 的 RNode,比如 App LView 的 host 是 <app-root> HTMLElement。

[TVIEW] 是这个 LView 的 TView。

[PARENT] 是 parent LView,比如 HelloWorld LView 的 parent 是 App LView,App LView 的 parent 是 Root LView。

Root LView 的特别之处是它没有 RNode 也没有 parent。

[CONTEXT] 是组件实例 (instance)。

[DECLARATION_VIEW]、[DECLARATION_COMPONENT_VIEW]、[DECLARATION_LCONTAINER]、[EMBEDDED_VIEW_INJECTOR] 这几个是 Dynamic Component 会用到的,之后章节会教。

[HYDRATION] 这个是 for SSR(Server-side Render)用的,之后章节会教。

[QUERIES] 是用于 @ViewChildren (query element),之后章节会教。

Array 0 – 24 位就是上面这些资料,25 开始就是模板内容的资料。

从 25 开始,一个 p RNode,一个 text RNode, 两个 HelloWorld LView。

TView 保存的资料

非常多资料,但我熟悉的没有几个 😜

type 分成 3 种

Root 表示是 Root TView

Component 表示是组件 TView 比如 App TView 和 HelloWorld TView

Embedded 用于 <ng-template> 这个之后章节会教。

template 方法就是组件 Definition 的 static 属性 ɵcmp.template (a.k.a 组件 template 方法)

注:Root TView 是没有 template 方法的哦,它是 null,因为它不是组件,没有 Definition。

data 这个应该是最重要的了,它对应 LView array 的每一个位置。

前面 0 – 24 都是 null,25 开始就对应 LView 的 25 开始。

一个 p TNode,一个 text TNode, 两个 HelloWorld TNode(注意:这些都是 TNode 哦,即便是 HelloWorld 组件,这里也是 TNode,而不是 HelloWorld TView 哦)

TNode 长这样

 

TView 和 LView 总结

几个名词,几个过程、几个关系链都要搞清楚:

  1. 我们写的 HTML 叫 组件 Template

  2. compile 之后,组件 Template 会变成 组件 template 方法,这个方法有分 create mode 和 update mode。

  3. 组件 template 方法被存放在 组件 Definition (a.k.a ComponentDef) 里。

  4. main.js 会执行 bootstrapApplication

  5. 创建 Root TView、Root LView、

  6. 依据 App Definition 创建 App TNode、App TView、App LView。

    这 3 个有密切关系,LView[1] === TView、LView[5] === TNode。

    许多 TView 资料都引用自 App Definition,比如 TView.template === AppDefinition.template。

  7. 执行 App template 方法 by create mode。这时 App 的内容 DOM 就出现了,如果有子组件就递归。

  8. 执行 App template 方法 by update mode。这时 ViewModel 和 binding syntax 就更新 DOM 了。 

TView 和 LView 的知识比较底层,我们目前懂个大概就可以了。我在后面的相关章节还会提到。

如果有兴趣深入理解可以看下面几篇文章:

Miško Hevery – Ivy’s internal data structures

被删 – Ivy编译器的视图数据和依赖解析

被删 – Angular冷知识--布隆过滤器

Angular DI: Getting to know the Ivy NodeInjector

Angular DI: Getting to know the Ivy NodeInjector

Trotyl Yu – Angular Ivy 概览

Kara Erickson – How Angular works

Angular 源码

但我劝你还是顺着本系列教程一步一步走会更好。

 

Angular Detect Change 的思路

上面 bootstrapApplication 跑完,DOM 就成型了,ViewModel 的资料也进去了。

接下来需要监听 ViewModel 的变化,然后做局部 DOM 更新。

Angular 又是怎么做到的呢?

在 JavaScript,要监听 variable value change 并不容易。

let value = 'a';
value.onChange((before, after) => {
  console.log('value changed', [before, after]); // ['b', 'a']
})
value = 'b';

上面这段代码是不成立的。

常规思路

通常我们能想到 2 种方式去去解决这个问题。

第一种是把 value 变成一个对象,因为对象可以搞 Proxy。通过拦截 setter 我们就能监听到了 value 每次的变化。

第二种是把 value 变成函数。函数调用也可以很容易加上监听代码。

两种做法都严重污染了代码的可观性,但这似乎是 JS 语言本身的局限,也只能这样了。

第一种方式比较面向对象,第二种则比较函数式。

所以 React 选了第二种方式。

而按理说,Angular 应该会选择第一种,毕竟 Angular 的 ViewModel 本来就是对象丫。

但是它...没有。

有人问过 Angular 团队为什么? 大神只是回了句:"we don't like"。

我想那是因为,单单把组件实例变成 Proxy 并不足够解决问题,因为开发者可能会有嵌套的对象值。而框架显然不能暗地里把这些对象都变成 Proxy。

Angular 的思路

Angular 的想法是这样的 (2015 年的想法,那时候 es6 Proxy 还没有普及)。

既然 ViewModel 的改变不容易被监听,那何不再往前一步,监听改变 ViewModel 的事件呢? 

毕竟在游览器,你要修改一个变量,你得执行 JS 啊,而要执行 JS 你必须把它放入 event loop 啊。

不管你是透过 addEventListener、setTimeout、fetch callback、等等,你总要有个头,那我们就监听这个头就好了。

但是...这个头好像也不能被监听吧...

于是 Angular 团队(旧团队,新的不会这样了)发挥了他们独有的魅力。当遇到难题时,先把难题放大,然后想一个很大的解决方法,最终大材小用的去解决这个小问题,与此同时贡献一个伟大的功能给其它人用。

Zone.js 就是这样诞生的。它可以拦截所有游览器事件,比如 addEventListener、setTimeout、fetch、等等。它确实是一个伟大的功能,但很遗憾,最终没有被 ECMA 采纳

这也是为什么 Angular 现在要转向 Signal 的重要原因之一。

 

更新 DOM when Change Detected

第一步,通过 Zone.js 监听所有可能导致 ViewModel 变化的事件(几乎是所有事件吧...)

第二步,当事件发生,像 bootstrapApplication “更新 DOM” 环节那样,从 Root LView 开始往下一个一个 LView 去更新 DOM。

这 2 步操作,对于开发者来说是完全无感的,我们不需要去 setup Zone.js 也不需要去调用 "更新 DOM",Angular 封装了这一切。

 

Zone.js + 更新 DOM

我们来小总结一下:

  1. 每一个 LView 都能链接上其组件的 template 方法,执行 template 方法 by update mode,当前 LView 的 DOM 就更新了。

  2. Angular 利用 Zone.js 监听所有的事件。因为只要有事件发生,ViewModel 就可能被修改,DOM 就可能需要更新。

  3. 当 Zone.js 触发后,Angular 会从 Root LView 开始往下遍历每一个 LView,并执行它们的 template 方法 by update mode,这样所有 DOM 就更新了。

  4. Angular 把遍历所有 LView 做 DOM 更新这个过程封装在一个叫 tick 的方法里,bootstrapApplication 更新阶段和 Zone.js 触发后都是调用了这个方法。

    这个方法还是公开的,我们也可以调用哦。

    updateAllLView() {
      const appRef = inject(ApplicationRef);
      appRef.tick();
    }

    在组件注入 ApplicationRef,tick 方法就在这个对象里。

 

refreshView 函数

refreshView 是 Change Detection 的核心函数,它大概长这样

function refreshView(lView: LView) {
  // 1. 执行 LView 的 template 方法 by update mode 来更新 LView 里的 DOM 
  lView[TView].template('update mode');

  // 2. refresh 子孙 LView
  for (const childLView of lView.children) {
    // 3. refresh 之前先做一个判断是否需要 refresh
    if (childLView.needRefresh) {
      // 4. 这里就递归了,如果没有因为 !needRefresh 中断的话, 它会遍历 refresh 完所有子孙 LView。
      refreshView(childLView);
    }
  }
}

refreshView(helloWorldLView);

代码里头 lView[TView].template 指的是 HelloWorld Definition 的 template 方法,它被存放在 HelloWorld TView 里头了。

执行完这个 template 方法,这个 LView 里的 RNode 就都更新好了。

tick 方法

Angular 没有公开 refreshView 函数,我们无法调用它,但我们可以通过其它接口,间接调用它。

ApplicationRef.tick 内部就是调用了 refreshView。它大概长这样

class ApplicationRef {
  rootLView: LView
  tick() {
    // 1. 从 Root LView 开始往下遍历 refresh 所有的子孙 LView
    refreshView(this.rootLView);
  }
}

 

Performance Issue

tick 是很费性能的。它会遍历所有的 LView,遍历所有的 binding,这数量非常的大。

另外 Zone.js 监听所有的事件也是很恐怖的。

试想想,假如我写了一个 mousemove event,或者 scroll event。

那 Zone.js 得触发多少次,每一次就是一个 tick 调用啊。

再说,事件发生也不一定就会改变 ViewModel 啊,但 Angular 无法知道到底有没有改变 ViewModel,所以它只能每一次都调用 tick,这不浪费吗?

再说,假设我只改了一个组件的 ViewModel,但 Angular 不知道啊,所以它依然得遍历所有的 LView,这不浪费吗?

所以,当有高频触发事件,或者页面有很多 binding syntax 的时候,Angular 的性能问题就越加明显了。

 

性能优化

高频事件 + 遍历很多 LView = 性能灾难

既然清楚知道问题所在,那优化也就不是什么难事了。(虽然不难,但烦啊...根本是拿 DX 换性能)

有好几招:

  1. 遇到高频事件,关闭 Angular Change Detection 机制,改用 manual DOM manipulation。这招不高明,不过高频事件毕竟罕见,而且往往高频事件就只是为了修改一些 style 而已,所以我觉得这招还是挺实际的,可用。

  2. 不监听没有修改 ViewModel 的事件,Zone.js 会监听所有的事件,假如某事件触发后并没有修改任何 ViewModel,那我们就让 Zone.js skip 掉这个事件监听。

  3. 不遍历所有的 LView,只遍历某一些 LView。比如,假如只有 HelloWorld 组件的 ViewModel 有变化,那就只 refresh HelloWorld LView,其余 LView 都不 reflesh。

这第三招是 Angular 主要的优化手段,目前 Angular 能通过一些潜规则配置,大幅度减少需要遍历的 LView,但还不能做到极致,需要等以后 Signal-based component 问世才能做到极致了。

有了上面这几招性能优化,性能就不再是问题了。当然,它们的代价也不少,下面我们来看看具体的实现代码吧。

 

性能优化 の ChangeDetectionStrategy.OnPush

上面我们说 tick 会遍历所有的 LView,这是有前提的,只有当所有组件 ChangeDetectionStrategy 都是 Default 的情况下才成立。

如果某些组件 ChangeDetectionStrategy 设置成 OnPush 那就不能这么简单理解了。

我们仔细看看 tick 的整个遍历过程,看它是如何判断一个 LView 是否需要 refresh。

LView check 标签

tick 会从 Root LView 开始往下遍历,当遍历到一个 LView 时,首先它会看这个 LView 是否有 check 标签。

如果有,那这个 LView 就需要 refresh,如果没有,那就看是否满足其它条件(下面会讲)。

那这个 check 标签是怎么来的呢?

1. DOM event

假设,在 HelloWorld 组件里有一个 DOM event 触发,这时 Angular 会把 HelloWorld LView markForCheck(打上 check 标签)

除此之外,HelloWorld LView 的祖先 LView 全部都会被 markForCheck,一条龙到顶。

经过这轮 markForCheck 后,Zone.js 才会调用 tick。

2. manual markForCheck

export class HelloWorldComponent {
  value = '!!';
  cdr = inject(ChangeDetectorRef);

  ngOnInit(): void {
    setTimeout(() => {
      this.value = '!!!!'; // change ViewModel
      this.cdr.markForCheck(); // manual markForCheck
    }, 5000);
  }
}

Angular 的潜规则是只有 DOM event 会自动 markForCheck,其余 setTimeout、ajax 通通都不会。我们可以通过 ChangeDetectorRef.markForCheck 方法手动 markForCheck。

同样的,只要子 LView markForCheck,其祖先所有 LView 也都会被 markForCheck,一条龙到顶。

@Input value changed

除了 LView check 标签以外,当 tick 遍历时,发现 LView 组件的 @Input 属性值有变化,那它也需要 refreshView。

它的值对比方式是 input previous value ===  input current value。

如果我们希望借助这个潜规则 refresh LView 的话,那 @Input 属性值最好是能通过 === 来检测出变化。比如使用 immutable 对象。

ChangeDetectionStrategy

假若看了 LView check 标签、@Input value changed 都不需要 refreshView,那最后就看 ChangeDetectionStrategy。

每一个组件都有一个 ChangeDetectionStrategy(检测策略)设置。

如果 ChangeDetectionStrategy 是 Default 那就 refreshView。

如果 ChangeDetectionStrategy 是 OnPush 那就不 refreshView,与此同时这个 LView 旗下的子孙 LView 也一概不遍历,不 refreshView 了。

DoCheck + markForCheck

Angular 提供了一个组件生命周期钩子 -- DoCheck 让我们有机会在 tick 遍历 LView 的期间,决定一个 LView 是否要 refresh。

它的机制是这样的:

tick 后,从 Root LView 开始遍历检查子孙 LView 是否要 refresh。

但在检查之前,需要先调用 LView 组件的 DoCheck 方法。

在这个方法里,我们有机会通过各种逻辑去决定这个 LView 是否要 refresh。

export class HelloWorldComponent implements DoCheck {
  cdr = inject(ChangeDetectorRef);

  ngDoCheck(): void {
    // 如果我们希望 refreshView,那这里就 markForCheck
    this.cdr.markForCheck();
  }
}

注意事项

1. markForCheck 只是把 LView 打上 check 标签,它不会触发 tick。

2. tick 不一定会触发所有的 DoCheck,因为只要某一层的 LView 没有 refresh,那它旗下的子孙 LView 连遍历都不会有,它们的 DoCheck 自然也就不会被调用了。

 

性能优化 の ngZone.runOutsideAngular

NgZone 是 Angular 对 Zone.js 的封装版。

by default,所有 event 都被 Zone.js 拦截,都会触发 tick。

如果我们有一个事件,它没有修改 ViewModel,那我们可以把它排除在 Zone.js 的监听里。

export class AppComponent implements OnInit {
  ngZone = inject(NgZone);
  host: HTMLElement = inject(ElementRef).nativeElement;
  ngOnInit(): void {
    this.ngZone.runOutsideAngular(() => {
      // 下面这个 mousemove 事件不会被 Zone.js 监听到
      this.host.addEventListener('mousemove', () => {
        // 这里不会触发 tick,我们需要手动更新 DOM
        console.log('do DOM manipulation');
      });
    });
  }
}

 

代码学习

上面讲了那么多理论,这里我们来看看具体代码,感受一下呗。

创建测试项目

ng new simple-test --skip-tests --style=scss --ssr=false --routing=false

关闭 NgZone

到 app.config.ts,关闭 NgZone。

export const appConfig: ApplicationConfig = {
  providers: [{ provide: NgZone, useClass: ɵNoopNgZone }],
};

Angular 目前没有提供一个 right way 让 Standalone Component 可以关掉 NgZone。相关 Issue

NgModule 的方式就有,我们顺便逛一下源码

下面是基于 NgModule 的方式

getNgZone 时会因为 options 设置 'noop' 而返回 NoopNgZone 实例,也就是 "没有 NgZone 的意思"。

bootstrapApplication 则是通过 DI 注入 NgZone。

所以唯一的方法就是我们修改 NgZone 的 Provider,于是有了这句

providers: [{ provide: NgZone, useClass: ɵNoopNgZone }]

Angular 没有直接 public NoopNgZone,它换了一个别名

所以最后是用 ɵNoopNgZone。

app.component.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule],
  styleUrl: './app.component.scss',
  template: `
    <h1>Hi, {{ name }}</h1>
    <button (click)="rename()">change name</button>
  `,
})
export class AppComponent {
  name = 'default name';

  rename(): void {
    this.name = 'new name';
  }
}

效果

点击 change name button,value 没有自动更新。

这是因为我们关闭了 NgZone,Angular 监听不到任何事件也就不知道什么时候要 tick,DOM 自然就不会更新了。

手动 tick

private cdr = inject(ChangeDetectorRef);
private appRef = inject(ApplicationRef);
rename(): void {
  this.name = 'new name';
  this.cdr.markForCheck();
  this.appRef.tick();
}

相等于开启 Zone.js 后的效果。

detectChanges 方法

private cdr = inject(ChangeDetectorRef);
rename(): void {
  this.name = 'new name';
  this.cdr.detectChanges();
}

inject ChangeDetectorRef,调用 detectChanges 方法。detectChanges 内部也是调用了 refreshView。

它大概长这样

class ChangeDetectorRef {
  lView: LView
  detectChanges() {
    // 1. 从当前 LView 开始往下遍历 refresh 所有的子孙 LView
    refreshView(this.lView);
  }
}

和 tick 的区别是,tick 是从 Root LView 开始往下,detectChanges 是从当前 LView 开始往下。

 

AsyncPipe

像上面那样,自己调用 markForCheck 很繁琐,而且一不小心就漏写了。 

Angular 的 best practice 是让我们用 RxJS 来描述会变化的 state,然后通过 AsyncPipe 把 stream 转换成 value。

这个 AsyncPipe 中还附带了 markForCheck 功能,这样每当 value change 就自动 markForCheck。

 

Zoneless ChangeDetection

Angular v17.1.0 后,我们终于可以摆脱 Zone.js 了。

首先通过 ɵprovideZonelessChangeDetection 关闭 Zone.js。

export const appConfig: ApplicationConfig = {
  providers: [ɵprovideZonelessChangeDetection()],
};

ɵprovideZonelessChangeDetection 函数的源码在 zoneless_scheduling_impl.ts

里面有 2 个 Provider,一个关闭 NgZone 一个声明 ChangeDetectionScheduler。

我们上面有说过,关闭 Zone.js 之后,Angular 就不会自动 tick 了,我们不仅仅要 markForCheck 还必须手动 tick。

但是在 v17.1.0 版本后就不同了。

class ChangeDetectionSchedulerImpl 的源码在 zoneless_scheduling_impl.ts

Angular 会在许多地方调用这个 notify 方法,它里面就是 setTimeout + tick。setTimeout 的目的是让它在同步时期可以被调用多次,但最终也只是执行一次。

注:setTimeout 和 requestAnimationFrame 的执行顺序是不固定的,所以 Angular 用了 Promise.race 哪一个先触发就用哪一个,还挺聪明的呢。

在 markForCheck 方法中会调用 notify。

所以即便在关闭 Zone.js 之后,只要我们调用 markForCheck,它不仅仅会把 LView 设置成 checked 也会执行 setTimeout + tick。

另外,通过 Template Binding Syntax 监听的事件内部也会调用 notify。

里面调用了 markViewDirty,而 markViewDirty 里又调用了 notify 方法。

总结

Angular v17.1.0 后,我们可以使用 Zoneless ChangeDetection。Angular 不再利用 Zone.js 监听所有的变化然后执行 tick,

取而代之的是通过 markForCheck,wrapListener 等等的时机执行 tick。

最大的区别是,原本 setTimeout,ajax callback 这些都会被 Zone.js 自动 tick,现在必须手动 markForCheck 才会 tick 了。

不过如果是从 OnPush 切换到 Zoneless 就没有什么区别,因为 OnPush 本来就需要 markForCheck。

 

Angular Best Practice (before Signals)

目前 Angular 最完整的 best practice 如下:

  1. 如果性能不是问题, Zone.js + tick 就可以了。我们啥也不需要做。

  2. 如果只是一小部分的地方有 critical performance issue,那可以针对性解决。

    比如 runOutsideAngular + DOM manipulation,虽然直接操作 DOM 违背了 MVVM 的理念,但是范围可控的话,也是可以考虑的 trade-off 选项。

  3. 如果许多地方都性能焦虑,那就把所有组件 ChangeDetectionStrategy 设置成 OnPush。

    在组件内,如果是 after DOM event 修改 ViewModel,那我们啥啥也不需要做。

    如果是 after 非 DOM event (比如:ajax、setTimeout),那就调用 markForCheck,或者把 variable 变成 RxJS stream + AsyncPipe。

  4. 把组件封装的小一点,Angular 以 LView 作为一个更新单位,哪怕 LView 里面只需要更新一个 DOM binding,但它依然会遍历完 LView 里所有的 binding 做检查。

    所以 LView 大不是好事,小而多则不要紧,因为大部分的 LView 经过潜规则都会被排除在 tick 遍历外。(注:当然,如果你把 LView 拆分后导致它们之间需要额外沟通,那就不划算了)

 

Angular Best Practice (after Signals)

Signals 以后会教,这里先大致说一些相关的点。

在 Signal 推出之前,Angular 最大的瓶颈是它没有办法直接监听到 ViewModel 的变化。它只能监听导致 ViewModel 变化的事件,然后通过遍历 LView 来实现 DOM 更新,最小的颗粒度只能是一个 LView。

而 Signal 可以监听到 ViewModel 的变化,所以它的颗粒度很小,一个 binding 对应一个 DOM element,这样也可以更新到。

当然,Signal 强制把 variable 变成方法的这种写法,打破了原本写代码的方式,所以虽然很厉害但对开发者来说也是一种取舍,只能说 anything has price。

 

不错的文章

无意间看到这篇还不错的文章,写的挺细的,先留起来或许以后可以慢慢看。

Medium – A change detection, zone.js, zoneless, local change detection, and signals story 

 

目录

上一篇 Angular 17+ 高级教程 – Component 组件 の Pipe 管道

下一篇 Angular 17+ 高级教程 – Component 组件 の Dependency Injection & NodeInjector

想查看目录,请移步 Angular 17+ 高级教程 – 目录

 

 

 

 

 

还有 Signal 值在变更时会调用 markAncestorsForTraversal 函数,这个函数内也会调用 notify 方法。(Signal 下一篇会教)

 

 

总结

Angular v17.1.0 后,我们可以使用 Zoneless ChangeDetection。Angular 不再利用 Zone.js 监听所有的变化然后执行 tick,

取而代之的是通过 markForCheck,wrapListener,Signal markAncestorsForTraversal 等等的时机执行 tick。

 

posted @ 2023-04-15 20:12  兴杰  阅读(650)  评论(0编辑  收藏  举报