Angular是怎么工作的

这篇文章用来记录 angular 创建阶段(angular creation),angular是如何把ts组件解释成浏览器原始的js代码的,以及变更检测阶段都做了什么。
原视频How Angular works | Kara Erickson

我们从一个简单的例子开始,全文会围绕这个例子展开:

1
2
3
4
5
6
7
8
9
10
11
@Component({
selector: 'app-root',
template: `
<img src="car.jpg">
<h1> {{ header}} </h1>
<info-card [name]="name"></info-card>
<footer></footer>
`,
styleUrls: ['./admin.component.scss']
})
class AppComponent { }

在编译之后,ts会降级成js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class AppComponent { }
AppComponent.ngComponentDef = defineComponent({
selectors: [[`app-root`]],
factory: function() {
return new AppComponent();
},
template: function(renderFlags, context) {
if (renderFlags & RenderFlags.Create) {
elements(0, 'img', ['src, 'cat.jpg\']);
elementStart(1, 'h1');
text(2);
elementEnd();
element(3, 'info-card');
element(4, 'footer');
}
if(renderFlags & RenderFlags.Update) {
advance(2);
textInterpolate1(" ", ctx.header, " ");
advance(1);
property("name", ctx.name);
}
},
directives: [ InfoCard, Footer ],
})

这是已经clean后的js,实际是这样的…

经过webpack包装之后还是能看出有很大相似😅😅😅,只需要记住上面代码就好了。

Appication Bootstrap

下文部分小节是已第一人称带入的,文中的”我们”可以指代 angular,angular团队,或者Kara Erickson的团队。

Angular 应用程序引导时,会经历三个阶段:

  • 模块设置(Module setup)— Module bootstrap
  • 视图创建(View creation)— Component bootstrap
  • 变更检测(Change detection)— Component bootstrap

而这篇文章主要讲 组件构建(component bootstrap);
angular 组件构建是从根开始的,如果你不指定根组件,angular实际上也不知道从哪开始。至于根模块,angular一般指向的都是AppModule,当然也可是其他名字。根组件呢,就在根模块里的 @NgModule 装饰器的 bootstrap 中:

1
2
3
4
@NgModule({
bootstrap: [AppComponent]
})
export class AppModule { }

在开始设置跟组件时,必须做一些特殊的事:

  1. Locate root element 找到根元素

代码段:selectors: [['app-root']]
也就是寻找根元素app-root,所以在 index.html 中添加了 <app-root> 标签,这个标签帮框架确认了根元素位置,以便把 angular 应用程序把所有元素附加到 <app-root> 元素中,确保在编译时这个组件选择器被编译。 在框架从组件定义中获取到选择器后,就知道选择哪个选择器了,angular就是这样定位根元素的。

  1. Instantiate root component 实例化根组件
1
2
3
factory: function() {
return new AppComponent();
},

angular编译器生成了一个 factory 函数,factory就是用来实例化组件的。

  1. Render the root component 渲染根组件

渲染根组件也就是调用模板函数(template function)

1
2
3
4
5
6
7
8
9
10
11
12
13
template: function(renderFlags, context) {
if (renderFlags & RenderFlags.Create) {
elements(0, 'img', ['src, 'cat.jpg']);
elementStart(1, 'h1');
text(2);
elementEnd();
element(3, 'info-card');
element(4, 'footer');
}
if(renderFlags & RenderFlags.Update) {
// ...
}
}

在工厂函数调用之后,会创建app component,然后在创建模式下(RenderFlags.Create),调用模板函数来创建 info-cardfooter 等组件。

1
2
3
App.ngComponentDef.template(create, app);
InfoCard.ngComponentDef.template(create, info);
Footer.ngComponentDef.template(create, footer);

如果想创建整个应用程序或者整个应用程序的Dom,我们要做的就是在组件树中调用所有这些模板函数。
而这些模板函数的都指向一个函数 Instructions ,它是由 Angular 实现的,这个函数可以粗略分为两个阵营

  • Creation Instructions(上文提到的元素、文本,模板创建指令)
  • Update Instructions (更新元素、模板,指令)

Creation Instructions

Create elements & Set up attributes

首先开始介绍创建模式是怎么工作的。
模板函数的结构实际反映了 HTML 的模板结构,回看上面的代码段。传入elements函数的入参,分别是标签的名称、源属性和值。这么做是为了在模板中更轻松且准确的创建DOM,Angular实际上不只是采用模板的方式,要知道模板的优势要比HTML强,模板不仅仅是它的“内部HTML”,模板可以在它基础之上做更多的操作。
Creation阶段是在逐一创建所有元素,仔细观察结构中的元素,就会发现它的结构很复杂。

1
elements(0, 'img', ['src, 'cat.jpg']);  // <img src="car.jpg">

再来看看 element 函数的实现:

1
2
3
4
5
6
7
8
function element(index, tag, attrs) {
const el = documnet.createElement(tag);
const parent = getCurrentParent();
setAttributes(el, attrs);
parent.appendChild(el);
const lView = getLView();
lView[index] = el;
}

我们逐行解读这到底干了什么。

Register listeners

第一行,实际上 angular 创建Dom的API应该是大家有使用过的,如document.createElement函数。随之是获取父元素当做根元素,添加任意哈希值(个人理解这么做是为了添加一个唯一标识,方便定位),并附加到它的父元素(2~5行)。同时把要创建的元素添加到了 lViewlView是一个类似数组的数据结构(在change detection相关代码中会十分常见),这么做的目的是为了方便跟踪定位元素,方便以后变更检测中正确的更新这些元素。所以我们需要一些方法来访问它们,比如常见的Dom API就能获取到相应的元素,但这相当昂贵且缓慢,而 Angular有自己的方法来跟踪这些元素。
angular的方案是存储这些元素在内部数组被称为LView,也被称为逻辑视图,angular为每一个模板实例或者组件实例创建一个 LView实例,所以每个组件都有自己的存储集。
图2:LView 运行时内部结构
LView里除了有其Dom元素,angular还把绑定值等东西放在了LView,这样我们就可以检查旧的绑定值和新的绑定值,我们还把指令实例放在LView,这样我们就可以在以后对它们设置输入。
每个组件都有自己的数据来源,​在你创建每个元素时,我们把他添加到这个数组中,所以如果我们回到我们的模板,在创建的过程中,将每个元素加入到这个数组。试想下,我们正在逐行执行它,创建img标签,添加到正确的位置,然后我们会存储到LView,随后创建h1info-cardfooter标签,添加到LView中。

Match disrectives

实际上 **instructions**一直在执行和匹配指令,因此在编译时,编译器会分析你的ng模块和组件装饰器,它将根据你的ng模块找出哪些指令和组件。一旦它知道所有这些指令是什么,它就会在组件定义中打印出这个指令列表,因此对于 app component ,它可以访问 info card 指令组件和 footer 组件。这使我们的工作更加容易,因为在运行时,你可以不必担心模块范围或任何其他东西,我们知道我们的可用指令列表来自组件定义,所以如果我把注册表放在右边,你就可以直观地看到我们在创建所有这些元素时 我们也在将指令与我们正在创建的元素相匹配。匹配顺序可以参考下图:

我们会根据注册表的指令集,逐个匹配选择器,直到匹配正确。

Instantiate directives

在这一步,我们需要实例化我们找到的指令或组件,你可能认为只需要调用下new关键字,就完成实例化操作了,但这没有考虑到依赖注入,这些组件或指令可能有N个参数,可能在组件树的任何地方,我们所做的是调用 components factory,这与实例化组件所用的components factory一样, 所以info-card工厂函数是这个样子:

1
2
3
4
5
factory: function() {
return new InfoCard(
directiveInject(Info);
)
}

它会像你所期望的那样调用info-card,但它有这个directive inject指令,这是支持依赖注入的**instructions**,这才是真正要去获取的信息。

Perform DI

在angular中依赖注入的要点是将directive注入**instructions**来获取token,它首先检查的是directive injector tree,随后会定位directive injector所在位置,我们会跟踪您在directivescomponents定义中添加的所有指令和组件本身。因此每次您有一个包含directives的节点时,我们都会创建一个指令注入器,所以它就变成了这个指令注入器树。我们会找到离我们请求最近的节点的指令注入器并输入它,然后我们会一直走到路由,如果它不存在,我们就会回退到module injector模板注入器,模块注入器是我们跟踪你在模块上设置providers的地方,如果没有找到它,我们会把它全部返回,或者判断它是否是可选的。
对于例子中的Info serviceprovider,假设我们在app module提供了它,那么当我们创建info-card时,你知道我们调用指令注入,会先检查我们当前所在节点,所以会检查info-card是否存储这个指令注入器,这时会有两种情况 是 or 否,否则会移动到下个有指令注入器的父元素 app-root,也就是当前的app component,查看是否提供了Info service,没有,所以这时我们返回到模块注入器,很明显 app module 提供了这个。

1
2
3
providers: [
{provide: Info, useClass: HermesInfo}
],

Create child components

我们可以通过info Service(provide里的info)服务正确地创建infoCardDirective指令,一旦我们创建了info card实例,我们就会将它保存在同一个LView中,就像我们前面提的,我们需要定位指令实例,稍后设置Inputs,以便使用contacts调用其生命周期钩子,我们用指令做了很多事情,所以必须得跟踪指令。由于你可以在同一个节点上设置多个Directive指令,所以我们也会在之后检查注册表中其他指令,以便稍后匹配Footer,并将其添加到同一个LView中。你可能会想,这个指令匹配过程视乎很贵,你可以有N个模块,甚至其他导入模块,这么想没什么问题,所以定向匹配不是我们想要的,每个组件不止匹配一次,我们只想在初始化组件时才去匹配,所以我们怎么让它工作的?

基本上我们通过共享数据结构来使它工作的,我们为每个组件实例创建 LView逻辑视图,我们还有一个 TView或者说模板视图, 我们为每个模板函数或组件创建一个视图,这么说可能够直白。举个例子:如果你有一个有10个实例的组件,angular将创建10个LView和一个TView,所有的实例将共享TView,我们使其工作的方法是,我们将TView存储在组件的def上,所以下次我们创建这个组件实例时,我们可以查看def查找TVIew并查看我们初始化时存储的所有信息,并利用这些信息。

接下来看下在实践中是怎么这样的,首先我们有指令实例,当我们将指令实例推送到LView时,我们也把它们的定义以相同的索引推送到TView,与创建DOM元素类似,我们也将TNode模板节点以相同的索引推送到TViewTNode本质上是有关每个Dom元素的元数据,所以它将创建包含标签名称等信。在这个例子中,关于指令匹配的信息,在TNode上我们有一个指令索引列表或一系列与该节点匹配的指令索引。
例如:对于info-card来说,当我们第二次通过这个app component Template时,我们将获得info-card元素,我们会检查是否有一个我们已经创建的T标记,TNode有关于这个节点的指令存储位置等信息,它会指向InfoCard.ref,然后我们就可以立即去实例化infoCard,而不需要做任何匹配,这使得后续组件实例变得非常快。每个组件的创建过程几乎都是这样,所以试想一下info-cardfooter在有相同过程,它使我们重复调用变更检测。

Change Detection

在这个过程中,检查绑定值,并将值重排到视图中。

变更检测是我们检测所有绑定值的过程,如果它更改了,我们将重新运行视图来反映更新的值。你可能认为变更检测与视图创建非常相似,我们可以继续调用组件树下的所有模板函数,但这次我们没有使用不是使用创建标志RenderFlags.Create,而是使用更新标志RenderFlags.Update,所以当你从上到下调用所有这些模板函数,这就是变化检测的本质。
上已经解释过了Create阶段,现在我们可以只关注模板函数的Update阶段。

通过上图可以看到,name属性绑定是一对一的,但我们不再创建元素了,所以我们不一定对每个元素都有一对一的映射,有些元素会有多个绑定,有些元素没有绑定,所以当我们生成绑定指令时,为了跟踪哪个元素,我们还有这些前进指令(advance instructions下文统称前进指令),这些指令告诉我们如何在节点和绑定之间进行协商。
不妨想想右边是LView这个数组,我们将存储我们的元素,这些0到6 LView的索引和索引中的元素,我们需要跟踪的有两个关键点,一是当前节点,也就是我们所在节点上实际想要更新属性的节点,二是当前绑定,当前绑定跟踪我们在L视图中的位置,基本上我们需要跟踪所有旧值,通过每次运行变更检测,我们可以知道它是否发生了变化,所以我们将旧的绑定值保存在更新DOM元素之后的LView中,我们有一种计数器来跟踪节点在LView中位置,你会看到它是如何在一秒内工作的。

现在假设我们执行了第一条advance instructions,它所做的就是告诉我们第一条绑定是在两个节点之下,它会增加当前节点计数器,直到 到达所在的两个节点之下的节点,所以现在我们在文本节点上,然后我们运行文本插值指令textInterpolate1,这所做的基本上就是 检查你传入的标题变量,上下文设置的标题与你在当前索引处绑定的值相对照,而现在它是空的,第一次我们假设值已经改变了,所以我们将继续更新DOM中的文本绑定,我们也会为下次运行变化检测时缓存该值,然后当前绑定增加一位,这样我们就不会继续覆盖到当前的值,于是我们就完成了对当前节点的绑定。再运行第二条advance instructions,这就告诉框架下一个绑定是在一个节点之下的节点,于是命中了info-card,然后我们执行属性绑定指令,不管你现在在哪个节点上,你都要检查name属性,这是第一次调用传入的值​,假设它已经改变了,因此我们要在这里更新绑定值,但name属性不是元素attributes而是@Input指令 ,所以我们不想去设置元素上的name属性,我们想在info-card组件上设置它,还记得我们在某个节点和节点上的所有指令之间做过映射吗,所以我们可以很简单地推断出这是哪一个组件,并直接在上面设置@Input输入,所以下一次你通过变化检测时,将对照我们刚刚保存的这些值进行检查,所以无论headername的新值是什么,都将对照headername的旧值再次检查。
你可能已经注意到了一件事,那就是变更检测将在一个视图中从上到下运行,因为我们保持了我们的排序,你知道模板中的组件树是从上到下检查的,组件视图中的所有节点也是从上到下检查的,所以它是非常可预测的。

Ivy Change Detection

  • Component tree checked from top to bottom(检查组件树)
  • All nodes checked form top to bottom(检查所有节点)
1
2
3
4
<div [one]="one"></div>
{{ two }}
<comp [three]="three"></comp>
<comp [four]="four"></comp>

假设你有这个模板,其中有DIV和文本绑定,期望它会按照你看到的1234的顺序逐行执行,但实际上略有不同,我不知道你们是否能猜到真实的顺序是什么,你可以尝试脑补一下~

Pre-Ivy Change Detection

  • Component tree checked from top to bottom
  • Nodes checked top to bottom for directive inputs
  • Nodes checked top to bottom for property or text bindings
1
2
3
4
<comp [three]="three"></comp>
<comp [four]="four"></comp>
<div [one]="one"></div>
{{ two }}

结果是3412,这是因为我们在4之前会做两次单独的传递,对指令输入做一次完整的传递,然后对元素和文本绑定做另一次完整的传递,从实现的角度来看这是有道理的,但从用户的角度来看,让人感到莫名其妙,为什么不是从上到下来检查的。
这就是我们修复这个的一个原因,我们让你的模板函数如何运行或你的模板如何被检查变得更加可预测,但我想指出的是,虽然我们会保证节点之间的绑定顺序,但不保证特定节点内的绑定顺序,有一个很好的理由,这是为了保留在一个节点上优化绑定的权利。

1
2
3
4
5
6
<comp [prop1]="prop1"
id="id-{{id}}"
[attr.attr1]="attr1"
[prop2]="prop2"
[attr.attr2]="attr2">
</comp>

看上面的comp组件,我们有一堆属性绑定和一堆attributes,我们可以在模板函数中生成这样的东西,我们为每一个绑定的指令都按照你写的顺序来写,就像你期望的那样,但是这很重复,你有两个property instructions,还有attribute instructions,我们有什么办法可以优化这个,我们可以使用属性链。如果我们可以在节点内稍微改变顺序并移动它们,这样我们就可以从下面获取属性绑定把它链接到顶部的属性绑定上。由于property instructions返回的是自身,property函数返回的是自身实例,因此我们可以链接到你知道的propertyattribute上,并将这些额外的函数 函数名称保存在内存中,这看起来像是小的优化,但实际上在大型模板上它实际上有很大作用。

1
2
3
4
5
property('prop1', ctx.prop1);
propertyInterpolate1('id-', ctx.id, '');
attribute('attr1', ctx.attr1);
property('prop2', ctx.prop2);
attribute('attr2', ctx.attr2);
1
2
3
4
5
property('prop1', ctx.prop1)
.('prop2', ctx.prop2);
propertyInterpolate1('id-', ctx.id, '');
attribute('attr1', ctx.attr1);
.('attr2', ctx.attr2);

Lifecycle Hooks

还有一件事我没有谈到,那就是生命周期钩子,你可能想知道这些钩子是什么时候触发的,它们实际上是作为变化检测的一部分来执行的,我只是为了简单起见省略了它,但现在我们要回到过去,丰富我们之前了解的内容。
你可能十分熟悉生命周期钩子,但我还是要快速巴巴一下。前三个ngOnChangesngOnInitngDoCheck 这些都是在为特定指令设置@Inputs后运行的钩子,ngAfterViewInitngAfterViewChecked通常在View检查之后发生,ngAfterContentInitngAfterContentCheckedContent检查后发生,contentChilden检查在Content检查后,但你可能不知道的是 它们的执行方式略有不同。

前三个钩子我们是按Node节点执行的,所以我们会逐个Node节点运行,而其他的钩子是逐个View视图运行的,所以我们会一次对视图运行所有的钩子,而不是一次对某个特定的节点运行所有的钩子,这意味着所有按节点运行的钩子在变化时都只影响它自己。​我们必须在执行模板函数时实际运行,那是我们实际检查节点的时候。

如果我们回顾到Change Detection本质,所有的生命周期钩子都将在advance instruction前进指令中执行,所以除了更改你知道的节点之外,我们也在执行生命周期钩子,所以当 前进指令运行完成,我们就知道我们已经完成了该节点的所有输入和绑定。这就是我们如何知道我们可以前进的原因,就这样一直重复直到我们执行完所有生命周期钩子。当我们执行完 advance(2),下一步到 textInterpolate1 instruction,我们检查文本绑定,和之前一样更新值,然后进入下一个前进指令,在这一步已经完成了文本节点的所有绑定,显然这不起作用,我们不会为任何文本节点提供任何生命周期钩子,但如果这是个普通元素,advance(1)就是该文本执行生命周期钩子的地方。然后检查一下最后一条指令,这时已经没有更多的前进指令了,因为我们已经完成了,所以我们只是执行那些没有绑定的东西的生命周期钩子。

After Template Function Invocation

在模板函数被调用后,我们实际上做了一些更多事情

  • Refresh embedded views (刷新嵌入视图),这些是通过ngFor ngIf等指令添加的。
  • Flush all content hooks (刷新ngAfterContentInitngAfterContentChecked钩子),View hooks 触发后要触发 Content hooks,这是一步是为了一次刷新特定视图的所有 Content hooks。
  • Set host bindings 设置主机绑定
  • Check child components 再次检查所有子组件
  • Flush all view hooks 刷新所有 View hooks,视图检查是从下到上的,所以必须先检查直系节点。

最后

读到这希望现在的你已经对angular是如何工作的有了更好的理解,我们说了一大堆,希望你还没有被劝退,现在我们快速回顾下我们在视图创建中所看到的。


到这里就结束了,水平有限还请担待,在读完这些内容后,如果感觉有出入,可以联系我。
希望有所收获,感谢。

感兴趣推荐去看原片~(文章头部引用)