Angular 18+ 高级教程 – 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 (俗称 render)
HTML + binding syntax + ViewModel = DOM
第二是更新 DOM (俗称 refresh)
框架会监听 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、NodeInjector、Dynamic 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 总结
几个名词,几个过程、几个关系链都要搞清楚:
我们写的 HTML 叫 组件 Template
compile 之后,组件 Template 会变成 组件 template 方法,这个方法有分 create mode 和 update mode。
组件 template 方法被存放在 组件 Definition (a.k.a ComponentDef) 里。
main.js 会执行 bootstrapApplication
创建 Root TView、Root LView、
依据 App Definition 创建 App TNode、App TView、App LView。
这 3 个有密切关系,LView[1] === TView、LView[5] === TNode。
许多 TView 资料都引用自 App Definition,比如 TView.template === AppDefinition.template。
执行 App template 方法 by create mode。这时 App 的内容 DOM 就出现了,如果有子组件就递归。
执行 App template 方法 by update mode。这时 ViewModel 和 binding syntax 就更新 DOM 了。
TView 和 LView 的知识比较底层,我们目前懂个大概就可以了。我在后面的相关章节还会提到。
如果有兴趣深入理解可以看下面几篇文章:
Miško Hevery – Ivy’s internal data structures
Angular DI: Getting to know the Ivy NodeInjector
Angular DI: Getting to know the Ivy NodeInjector
Kara Erickson – How Angular works
但我劝你还是顺着本系列教程一步一步走会更好。
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
我们来小总结一下:
每一个 LView 都能链接上其组件的 template 方法,执行 template 方法 by update mode,当前 LView 的 DOM 就更新了。
Angular 利用 Zone.js 监听所有的事件。因为只要有事件发生,ViewModel 就可能被修改,DOM 就可能需要更新。
当 Zone.js 触发后,Angular 会从 Root LView 开始往下遍历每一个 LView,并执行它们的 template 方法 by update mode,这样所有 DOM 就更新了。
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 换性能)
有好几招:
遇到高频事件,关闭 Angular Change Detection 机制,改用 manual DOM manipulation。这招不高明,不过高频事件毕竟罕见,而且往往高频事件就只是为了修改一些 style 而已,所以我觉得这招还是挺实际的,可用。
不监听没有修改 ViewModel 的事件,Zone.js 会监听所有的事件,假如某事件触发后并没有修改任何 ViewModel,那我们就让 Zone.js skip 掉这个事件监听。
不遍历所有的 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. Angular v18 以后,markForCheck 内部会执行 tick (不会立即执行,它有一个 RxJS debounce 概念),早期版本只是 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。
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core'; export const appConfig: ApplicationConfig = {
providers: [provideExperimentalZonelessChangeDetection()]
};
注:要 v18.0 才有 provideExperimentalZonelessChangeDetection 函数哦,v17.1.0 用 ɵprovideZonelessChangeDetection 函数。
更早期的版本用
export const appConfig: ApplicationConfig = {
providers: [{ provide: NgZone, useClass: ɵNoopNgZone }],
};
app.component.ts
import { Component } from '@angular/core'; @Component({
selector: 'app-root',
standalone: true,
imports: [],
styleUrl: './app.component.scss',
template: ` <h1>Hi, {{ name }}</h1> `,
})
export class AppComponent {
name = 'default name'; constructor() {
window.setTimeout(() => {
this.name = 'new name';
}, 3000);
}
}
3 秒后 h1 应该要显示 new name,但是它没有。
因为我们关闭了 NgZone,Angular 监听不到 setTimeout 事件,也就不知道什么时候要 tick,DOM 自然就不会更新了。
手动 tick
constructor() {
const cdr = inject(ChangeDetectorRef);
window.setTimeout(() => {
this.name = 'new name';
cdr.markForCheck();
}, 3000);
}
markForCheck 会一条龙往上把所有祖先 LView 都 markForCheck,与此同时会执行一个 tick (v18 后才会,之前只会 check,我们需要自己 tick),从 Root LView 一条龙往下 refreshView,最终这个 LView 就更新了。
detectChanges 方法
constructor() {
const cdr = inject(ChangeDetectorRef);
window.setTimeout(() => {
this.name = 'new name';
cdr.detectChanges();
}, 3000);
}
用 detectChanges 方法也可以,它内部会立即执行 refreshView
大概长这样
class ChangeDetectorRef {
lView: LView
detectChanges() {
// 1. 从当前 LView 开始往下遍历 refresh 所有的子孙 LView
refreshView(this.lView);
}
}
markForCheck 是 mark all 祖先 LView to checked,然后 debound 一下执行 tick,从 Root LView 开始往下 refreshView。
detechChanges 是立即从当前 LView 开始往下 refreshView。
detechChanges 比较冷门,通常只有在搞 Dynamic Component 时可能会用上。
AsyncPipe
像上面那样,自己调用 markForCheck 很繁琐,而且一不小心就漏写了。
Angular 的 best practice 是让我们用 RxJS 来描述会变化的 state,然后通过 AsyncPipe 把 stream 转换成 value。
这个 AsyncPipe 中还附带了 markForCheck 功能,这样每当 value change 就自动 markForCheck。
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { BehaviorSubject } from 'rxjs'; @Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule],
styleUrl: './app.component.scss',
template: ` <h1>Hi, {{ nameBS | async }}</h1> `,
})
export class AppComponent {
nameBS = new BehaviorSubject('default name'); constructor() {
window.setTimeout(() => {
this.nameBS.next('new name');
}, 3000);
}
}
这样,我们不需要 inject(ChangeDetectorRef) markForCheck 了,换成 BehaviorSubject + AsyncPipe。
Zoneless ChangeDetection (v18)
Angular v18 后,我们终于可以彻底摆脱 Zone.js 了。(其实 v17.1.0 就可以了,只是那时用的是隐藏版本的 ɵprovideZonelessChangeDetection 函数)
首先通过 provideExperimentalZonelessChangeDetection 关闭 Zone.js。
export const appConfig: ApplicationConfig = {
providers: [provideExperimentalZonelessChangeDetection()],
};
provideExperimentalZonelessChangeDetection 函数的源码在 zoneless_scheduling_impl.ts。
里面有 3 个 Provider,一个关闭 NgZone,一个声明 ChangeDetectionScheduler,一个简单的声明 zoneless enabled。
然后去 angular.json 移除 polyfills
重要提醒:
zone.js 会 monkey patch JS native 功能,比如 queueMicrotask 等等,我们把它移除是有可能引发 breaking changes 的哦,自己要小心哦。
不移除 zone.js 单单只设置 provideExperimentalZonelessChangeDetection 也是可以,但这样做感觉会怪怪的。
我给一个具体的 breaking changes 例子:
App Template
<div class="container">
<h1>hello world</h1>
</div>
App 组件
export class AppComponent {
constructor() {
window.setTimeout(() => {
const container = document.querySelector<HTMLElement>('.container')!; // 监听 h1 移除
const mo = new MutationObserver(() => {
console.log('mutation'); // log mutation
});
mo.observe(container, { childList: true, subtree: true }); const h1 = container.querySelector('h1')!;
h1.remove(); // 移除 h1 DOM queueMicrotask(() => console.log('micro')); // log micro
}, 1000);
}
}
问:micro 和 mutation 哪一个先 log?
答:在没有 Zone.js polyfills 的情况下 mutation 会先 log,而在有 Zone.js polyfills 的情况下 micro 会先 log。
好,回到主题,我们上面有提到,在 v18 版本之前,关闭 Zone.js 之后,Angular 是不会自动 tick 的,比如 markForCheck 只是 check 而已,我们还要自己 tick。
但是在 v18 版本后就不同了。
class ChangeDetectionSchedulerImpl 的源码在 zoneless_scheduling_impl.ts
Angular 会在许多地方调用上面这个 notify 方法,它里面就是 setTimeout + tick。setTimeout 的目的是让它在同步时期可以被调用多次,但最终也只是执行一次,类似 RxJS debounce 的概念。
三个知识点
精确的说,Angular 不是 setTimeout + tick,而是 setTimeout 或者 requestAnimationFrame (a.k.a rAF) + tick。
setTimeout 和 requestAnimationFrame 哪一个会先触发不好说,因为这是游览器决定的。
总之 Angular 希望 tick 能尽快的触发 (在游览器下一次渲染前),所以 setTimeout 快 (通常是它快) 就由它执行 tick,如果 setTiemout 比较慢,则有 rAF 执行 tick。
在 tick 执行完 refreshView 之后,是有可能出现 re-tick 的情况的 (什么会导致 re-tick 我们不先不管,等以后学 Lifecycle Hooks 和 Signal 时,自然就会知道了)
重点是执行 re-tick 的时候就不是 setTimeout 和 requestAnimationFrame 了,而是 queueMicrotask
- ChangeDetectionSchedulerImpl 是一个 Root Provider
它实现了抽象类 ChangeDetectionScheduler
我们也可以 inject scheduler 执行 notify 哦,像这样
import {
Component,
inject,
ɵChangeDetectionSchedulerImpl,
ɵNotificationSource
} from '@angular/core'; @Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent { constructor() {
const scheduler = inject(ɵChangeDetectionSchedulerImpl); document.addEventListener('click', () => {
// do something...
scheduler.notify(ɵNotificationSource.Listener);
console.log(scheduler.runningTick); // 查看是否正在 tick 中
console.log(scheduler.pendingRenderTaskId); // 查看是否在等待 ticking (因为 notify 是 setTimeout + tick,timeout 期间就是等待中)
});
}
}
在 markForCheck 方法中会调用 notify。
所以即便在关闭 Zone.js 之后,只要我们调用 markForCheck,它不仅仅会把 LView 设置成 checked 也会执行 setTimeout + tick。
另外,通过 Template Binding Syntax 监听的事件内部也会调用 notify。
里面调用了 markViewDirty,而 markViewDirty 里又调用了 notify 方法。
注:它是先 markViewDirty (背地里 setTimeout + tick) 后才 excuteListerner (我们写的 callback),所以假如我们在 callback 里写 setTimeout 或者 requestAnimationFrame,我们的会比它的晚触发 (也就是在 tick 之后才出发)。
总结
Angular v18 后,我们可以使用 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 如下:
如果性能不是问题, Zone.js + tick 就可以了。我们啥也不需要做。
如果只是一小部分的地方有 critical performance issue,那可以针对性解决。
比如 runOutsideAngular + DOM manipulation,虽然直接操作 DOM 违背了 MVVM 的理念,但是范围可控的话,也是可以考虑的 trade-off 选项。
如果许多地方都性能焦虑,那就把所有组件 ChangeDetectionStrategy 设置成 OnPush。
在组件内,如果是 after DOM event 修改 ViewModel,那我们啥啥也不需要做。
如果是 after 非 DOM event (比如:ajax、setTimeout),那就调用 markForCheck,或者把 variable 变成 RxJS stream + AsyncPipe。
把组件封装的小一点,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 18+ 高级教程 – Component 组件 の Pipe 管道
下一篇 Angular 18+ 高级教程 – Component 组件 の Dependency Injection & NodeInjector
想查看目录,请移步 Angular 18+ 高级教程 – 目录
喜欢请点推荐,若发现教程内容以新版脱节请评论通知我。happy coding
Angular 18+ 高级教程 – Change Detection & Ivy rendering engine的更多相关文章
- [Angular & Unit Testing] Automatic change detection
When you testing Component rendering, you often needs to call: fixture.detectChanges(); For example: ...
- CHANGE DETECTION IN ANGULAR 2
In this article I will talk in depth about the Angular 2 change detection system. HIGH-LEVEL OVERVIE ...
- angular 2 - 006 change detection 脏治检查 - DC
ANGULAR CHANGE DETECTION EXPLAINED 引发脏治检查有三种方式: Events - click, submit, - XHR - Fetching data from a ...
- Siki_Unity_2-9_C#高级教程(未完)
Unity 2-9 C#高级教程 任务1:字符串和正则表达式任务1-1&1-2:字符串类string System.String类(string为别名) 注:string创建的字符串是不可变的 ...
- [Angular Unit Testing] Debug unit testing -- component rendering
If sometime you want to log out the comonent html to see whether the html render correctly, you can ...
- Pandas之:Pandas高级教程以铁达尼号真实数据为例
Pandas之:Pandas高级教程以铁达尼号真实数据为例 目录 简介 读写文件 DF的选择 选择列数据 选择行数据 同时选择行和列 使用plots作图 使用现有的列创建新的列 进行统计 DF重组 简 ...
- ios cocopods 安装使用及高级教程
CocoaPods简介 每种语言发展到一个阶段,就会出现相应的依赖管理工具,例如Java语言的Maven,nodejs的npm.随着iOS开发者的增多,业界也出现了为iOS程序提供依赖管理的工具,它的 ...
- 【读书笔记】.Net并行编程高级教程(二)-- 任务并行
前面一篇提到例子都是数据并行,但这并不是并行化的唯一形式,在.Net4之前,必须要创建多个线程或者线程池来利用多核技术.现在只需要使用新的Task实例就可以通过更简单的代码解决命令式任务并行问题. 1 ...
- 【读书笔记】.Net并行编程高级教程--Parallel
一直觉得自己对并发了解不够深入,特别是看了<代码整洁之道>觉得自己有必要好好学学并发编程,因为性能也是衡量代码整洁的一大标准.而且在<失控>这本书中也多次提到并发,不管是计算机 ...
- 分享25个新鲜出炉的 Photoshop 高级教程
网络上众多优秀的 Photoshop 实例教程是提高 Photoshop 技能的最佳学习途径.今天,我向大家分享25个新鲜出炉的 Photoshop 高级教程,提高你的设计技巧,制作时尚的图片效果.这 ...
随机推荐
- 如何支持同一台电脑上使用不同版本的Node.js版本
在我们实际项目开发过程中,经常不同项目使用的node.js版本会也有所不同,为了方便维护不同版本的项目.可以使用nvm来解决. 1.下载nvm https://github.com/coreybutl ...
- 解决方案 | Adobe Acrobat XI Pro 右键菜单“在Acrobat中合并文件”丢失的最佳修复方法
1.问题 Adobe Acrobat XI Pro右键菜单"转换为Adobe PDF"与"在Acrobat中合并文件" 不见了. 2.解决方案 桌面左下角搜索& ...
- 网易数帆内核团队:memory cgroup 泄漏问题的分析与解决
memory cgroup 泄露是 K8s(Kubernetes) 集群中普遍存在的问题,轻则导致节点内存资源紧张,重则导致节点无响应只能重启服务器恢复:大多数的开发人员会采用定期 drop cach ...
- Django REST framework的10个常见组件
Django REST framework的10个常见组件: 权限组件 认证组件 访问频率限制组件 序列化组件 路由组件 视图组件 分页组件 解析器组件 渲染组件 版本组件
- [oeasy]python0117 文字的演化_埃及圣书体_象形文字_楔形文字
埃及圣书体 回忆上次内容 两河流域 苏美尔文明 所使用的 楔形文字 不是象形文字 添加图片注释,不超过 140 字(可选) 楔形文字的字型 究竟是怎么来的呢? 巴别塔 苏美尔的 ...
- 关于druid与springboot版本问题
datasource: druid: driver-class-name: ${sky.datasource.driver-class-name} url: jdbc:mysql://${sky.da ...
- ios证书免费分享
首先,ios证书能不能分享给别人使用,能否用别人的证书打包呢? 这个问题的答案在技术上是肯定可以的,但是我要解释一下,技术上可以,不代表真的就可以这样做,为什么呢? 首先,假如用别人的苹果开发者账号的 ...
- 汇编+qemu玩转控制台打印
有段时间开始对汇编感兴趣,也因此在写各种不同的demo,现在分享之前学习的成果,需要下载的东西有nasm和qemu-system-i386,看看枯燥的汇编能产生多大的能量. 先来复习一下通用寄存器: ...
- FFmpeg开发笔记(四十三)使用SRS开启SRT协议的视频直播服务
<FFmpeg开发实战:从零基础到短视频上线>一书在第10章介绍了轻量级流媒体服务器MediaMTX,通过该工具可以测试RTSP/RTMP等流媒体协议的推拉流.不过MediaMTX的功能 ...
- 解决Python使用matplotlib绘图时出现的中文乱码问题
原文地址: https://blog.csdn.net/qq_33254766/article/details/120304721 全文略,详细见原文. 解决方法: # 设置字体的属性 # plt.r ...