原文链接:
所有现代前端框架都是用组件来合成 UI,这样很自然就会产生父子组件层级,这就需要框架提供父子组件通信的机制。同样,Angular 也提供了两种方式来实现父子组件通信:输入输出绑定和共享服务。对于 我更喜欢输入输出绑定方式,然而对于 我使用共享服务方式。
本文主要介绍输入输出绑定方式,特别是当父组件输入绑定值变化时,Angular 如何更新子组件输入值。如果想了解 Angular 如何更新当前组件 DOM,可以查看 ,这篇文章也会有助于加深对本文的理解。由于我们将探索 Angular 如何更新 DOM 元素和组件的输入绑定属性,所以假定你知道 Angular 内部是如何表现组件和指令的,如果你不是很了解并且很感兴趣,可以查看 , 这篇文章主要讲了 Angular 内部如何使用指令形式来表示组件。而本文对于组件和指令两个概念互换使用,因为 Angular 内部就是把组件当做指令。
模板绑定语法
你可能知道 Angular 提供了 —— []
,这个语法很通用,它可以用在子组件上,也可以用在原生 DOM 元素上。如果你想从父组件把数据传给子组件 b-comp
或者原生 DOM 元素 span
,你可以在父组件模板中这么写:
import { Component } from '@angular/core';@Component({ moduleId: module.id, selector: 'a-comp', template: ``})export class AComponent { AText = 'some';}复制代码
你不必为原生 DOM 元素做些额外的工作,但是对于子组件 b-comp
你需要申明输入属性 textContent
:
@Component({ selector: 'b-comp', template: 'Comes from parent: { {textContent}}'})export class BComponent { @Input() textContent;}复制代码
这样当父组件 AComponent.AText
属性改变时,Angular 会自动更新子组件 BComponent.textContent
属性,和原生元素 span.textContent
属性。同时,还会调用子组件 BComponent
的生命周期钩子函数 ngOnChanges
(注:实际上还有 ngDoCheck
,见下文)。
你可能好奇 Angular 是怎么知道 BComponent
和 span
支持 textContent
绑定的。这是因为 Angular 编译器在解析模板时,如果遇到简单 DOM 元素如 span
,就去查找这个元素是否定义在 ,从而知道它是 HTMLElement 子类,textContent
是其中的一个属性(注:可以试试如果 span
绑定一个 [abc]=AText
就报错,没法识别 abc
属性);如果遇到了组件或指令,就去查看其装饰器 @Component/@Directive
的元数据 input
属性里是否有该绑定属性项,如果没有,编译器同样会抛出错误:
Can’t bind to ‘textContent’ since it isn’t a known property of …复制代码
这些知识都很好理解,现在让我们进一步看看其内部发生了什么。
组件工厂
尽管在子组件 BComponent
和 span
元素绑定了输入属性,但是输入绑定更新所需要的信息全部在父组件 AComponent
的组件工厂里。让我们看下 AComponent
的组件工厂代码:
function View_AComponent_0(_l) { return jit_viewDef1(0, [ jit_elementDef_2(..., 'b-comp', ...), jit_directiveDef_5(..., jit_BComponent6, [], { textContent: [0, 'textContent'] }, ...), jit_elementDef_2(..., 'span', [], [[8, 'textContent', 0]], ...) ], function (_ck, _v) { var _co = _v.component; var currVal_0 = _co.AText; var currVal_1 = 'd'; _ck(_v, 1, 0, currVal_0, currVal_1); }, function (_ck, _v) { var _co = _v.component; var currVal_2 = _co.AText; _ck(_v, 2, 0, currVal_2); });}复制代码
如果你读了 或 ,就会对上面代码中的各个视图节点比较熟悉了。前两个节点中,jit_elementDef_2
是元素节点,jit_directiveDef_5
是指令节点,这两个组成了子组件 BComponent
;第三个节点 jit_elementDef_2
也是元素节点,组成了 span
元素。
节点绑定
相同类型的节点使用相同的节点定义函数,但区别是接收的参数不同,比如 jit_directiveDef_5
节点定义函数参数如下:
jit_directiveDef_5(..., jit_BComponent6, [], { textContent: [0, 'textContent']}, ...),复制代码
其中,参数 {textContent: [0, 'textContent']}
叫做 props,这点可以查看 函数的参数列表:
directiveDef(..., props?: {[name: string]: [number, string]}, ...)复制代码
props 参数是一个对象,每一个键为绑定属性名,对应的值为绑定索引和绑定属性名组成的数组,比如本例中只有一个绑定,textContent 对应的值为:
{textContent: [0, 'textContent']}复制代码
如果指令有多个绑定,比如:
复制代码
props 参数值也包含两个属性:
jit_directiveDef5(49152, null, 0, jit_BComponent6, [], { textContent: [0, 'textContent'], otherProp: [1, 'otherProp']}, null),复制代码
Angular 会使用这些值来生成当前指令节点的 ,从而生成当前视图的指令节点。在变更检测时,每一个 决定 Angular 使用哪种操作来更新节点和提供上下文信息,绑定类型是通过 设置的(注:每一个绑定定义是 ,它的属性 决定 Angular 该采取什么操作,比如 Class
型绑定和 Style
型绑定都会调用对应的操作函数,见下文)。比如,如果是属性绑定,编译器会设置绑定标志位为:
export const enum BindingFlags { TypeProperty = 1 << 3,复制代码
注:上文说完了指令定义函数的参数,下面说说元素定义函数的参数。
本例中,因为 span
元素有属性绑定,编译器会设置绑定参数为 [[8, 'textContent', 0]]
:
jit_elementDef2(..., 'span', [], [[8, 'textContent', 0]], ...)复制代码
不同于指令节点,对元素节点来说,绑定参数结构是个二维数组,因为 span
元素只有一个绑定,所以它仅仅只有一个子数组。数组 [8, 'textContent', 0]
中第一个参数也同样是绑定标志位 ,决定 Angular 应该采取什么类型操作(注:[8, 'textContent', 0]
中的 8
表示为 property
型绑定):
export const enum BindingFlags { TypeProperty = 1 << 3, // 8复制代码
其他类型标志位已经在文章 有所解释:
TypeElementAttribute = 1 << 0,TypeElementClass = 1 << 1,TypeElementStyle = 1 << 2,复制代码
编译器不会为指令定义提供绑定标志位,因为指令的绑定类型也只能是 BindingFlags.TypeProperty
。
注:节点绑定 这一节主要讲的是对于元素节点来说,每一个节点的 类型是由 决定的;对于指令节点来说,每一个节点的 类型只能是
BindingFlags.TypeProperty
。
updateRenderer 和 updateDirectives
组件工厂代码里,编译器还为我们生成了两个函数:
function (_ck, _v) { var _co = _v.component; var currVal_0 = _co.AText; var currVal_1 = _co.AProp; _ck(_v, 1, 0, currVal_0, currVal_1);},function (_ck, _v) { var _co = _v.component; var currVal_2 = _co.AText; _ck(_v, 2, 0, currVal_2);}复制代码
如果你读了 ,应该对第二个函数即 有所熟悉。第一个函数叫做 。这两个函数都是 类型接口,两者都是视图定义的属性:
interface ViewDefinition { flags: ViewFlags; updateDirectives: ViewUpdateFn; updateRenderer: ViewUpdateFn;复制代码
有趣的是这两个函数的函数体基本相同,参数都是 _ck
和 _v
,并且两个函数的对应参数都指向同一个对象,所以为何需要两个函数?
因为在变更检测期间,这是不同阶段的两个不同行为:
这两个操作是在变更检测的不同阶段执行,所以 Angular 需要两个独立的函数分别在对应的阶段调用:
- updateDirectives——变更检测的开始阶段被调用,来更新子组件的输入绑定属性
- updateRenderer——变更检测的中间阶段被调用,来更新当前组件的 DOM 元素
这两个函数都会在 Angular 被调用,并且函数参数也是在这时被传入的。让我们看看函数内部做了哪些工作。
_ck
就是 check
的缩写,其实就是函数 ,另一个参数就是 。函数的主要功能就是从组件对象里拿到绑定属性的当前值,然后和视图数据对象、视图节点索引等一起传入 函数。其中,因为 Angular 会更新每一个视图的 DOM,所以需要传入当前视图的索引。如果我们有两个 span
和两个组件:
复制代码
编译器生成的 updateRenderer
函数和 updateDirectives
函数如下:
function(_ck, _v) { var _co = _v.component; var currVal_0 = _co.AText; // update first component _ck(_v, 1, 0, currVal_0); var currVal_1 = _co.AText; // update second component _ck(_v, 3, 0, currVal_1);}, function(_ck, _v) { var _co = _v.component; var currVal_2 = _co.AText; // update first span _ck(_v, 4, 0, currVal_2); var currVal_3 = _co.AText; // update second span _ck(_v, 5, 0, currVal_3);}复制代码
没有什么更复杂的东西,这两个函数还不是重点,重点是 _ck
函数,接着往下看。
更新元素的属性
从上文我们知道,编译器生成的 updateRenderer
函数会在每一次变更检测被调用,用来更新 DOM 元素的属性,并且其参数 _ck
就是函数 。对于 DOM 元素的更新,该函数经过一系列的函数调用后,最终会调用函数 ,这个函数会检查绑定标志位是 [attr.name, class.name, style.some]
其中的哪一个,又或者是属性绑定(注:可查看源码这段 ):
case BindingFlags.TypeElementAttribute -> setElementAttributecase BindingFlags.TypeElementClass -> setElementClasscase BindingFlags.TypeElementStyle -> setElementStylecase BindingFlags.TypeProperty -> setElementProperty;复制代码
上面代码就是刚刚说的几个绑定类型,当绑定标志位是 BindingFlags.TypeProperty
,会调用函数 ,该函数内部也是通过调用 DOM Renderer 的 方法来更新 DOM。
注:
setElementProperty
函数里这行代码view.renderer.setProperty(renderNode,name, renderValue);
,renderer 就是 ,它仅仅是一个接口,在浏览器平台下,它的实现就是 。
更新指令的属性
上文中已经描述了 updateRenderer
函数是用来更新元素的属性,而 updateDirective
是用来更新子组件的输入绑定属性,并且变更检测期间传入的参数 _ck
就是函数 。只是进过一系列函数调用后,最终调用的函数却是**,这是因为这次节点的标志位是 NodeFlags.TypeDirective
(注:可查看源码 ),** 函数主要功能如下:
-
从当前视图节点里获取组件/指令对象(注:查看 )
-
检查组件/指令对象的绑定属性值是否发生改变(注:查看 )
-
如果属性发生改变:
a. 如果变更策略设置为
OnPush
,设置视图状态为checksEnabled
(注:查看 )b. 更新子组件的绑定属性值(注:查看 )
c. 准备
SimpleChange
数据和更新视图的oldValues
属性,新值替换旧值(注:查看 )d. 调用生命周期钩子 ngOnChanges(注:查看 )
-
如果该视图是首次执行变更检测,则调用生命周期钩子 ngOnInit(注:查看 )
-
调用生命周期钩子 ngDoCheck(注:查看 )
当然,只有在生命周期钩子在组件内定义了才被调用,Angular 使用 节点标志位来判断是否有生命周期钩子,如果查看源码你会发现类似如下代码(注:查看 ):
if (... && (def.flags & NodeFlags.OnInit)) { directive.ngOnInit();}if (def.flags & NodeFlags.DoCheck) { directive.ngDoCheck();}复制代码
和更新元素节点一样,更新指令时也同样把上一次的值存储在视图数据的属性 里(注:即上面的 3.c
步骤)。