所有現代前端框架都是用元件來合成 UI,這樣很自然就會產生父子元件層級,這就需要框架提供父子元件通訊的機制。同樣,Angular 也提供了兩種方式來實現父子元件通訊:輸入輸出繫結和共享服務。對於 stateless presentational components 我更喜歡輸入輸出繫結方式,然而對於 stateful container components 我使用共享服務方式。
本文主要介紹輸入輸出繫結方式,特別是當父元件輸入繫結值變化時,Angular 如何更新子元件輸入值。如果想了解 Angular 如何更新當前元件 DOM,可以檢視 譯 Angular DOM 更新機制,這篇文章也會有助於加深對本文的理解。由於我們將探索 Angular 如何更新 DOM 元素和元件的輸入繫結屬性,所以假定你知道 Angular 內部是如何表現元件和指令的,如果你不是很瞭解並且很感興趣,可以檢視 譯 為何 Angular 內部沒有發現元件, 這篇文章主要講了 Angular 內部如何使用指令形式來表示元件。而本文對於元件和指令兩個概念互換使用,因為 Angular 內部就是把元件當做指令。
模板繫結語法
你可能知道 Angular 提供了 屬性繫結語法 —— []
,這個語法很通用,它可以用在子元件上,也可以用在原生 DOM 元素上。如果你想從父元件把資料傳給子元件 b-comp
或者原生 DOM 元素 span
,你可以在父元件模板中這麼寫:
import { Component } from `@angular/core`;
@Component({
moduleId: module.id,
selector: `a-comp`,
template: `
<b-comp [textContent]="AText"></b-comp>
<span [textContent]="AText"></span>
`
})
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
,就去查詢這個元素是否定義在 dom_element_schema_registry,從而知道它是 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);
});
}
複製程式碼
如果你讀了 譯 Angular DOM 更新機制 或 譯 為何 Angular 內部沒有發現元件,就會對上面程式碼中的各個檢視節點比較熟悉了。前兩個節點中,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 函式的引數列表:
directiveDef(..., props?: {[name: string]: [number, string]}, ...)
複製程式碼
props 引數是一個物件,每一個鍵為繫結屬性名,對應的值為繫結索引和繫結屬性名組成的陣列,比如本例中只有一個繫結,textContent 對應的值為:
{textContent: [0, `textContent`]}
複製程式碼
如果指令有多個繫結,比如:
<b-comp [textContent]="AText" [otherProp]="AProp">
複製程式碼
props 引數值也包含兩個屬性:
jit_directiveDef5(49152, null, 0, jit_BComponent6, [], {
textContent: [0, `textContent`],
otherProp: [1, `otherProp`]
}, null),
複製程式碼
Angular 會使用這些值來生成當前指令節點的 binding,從而生成當前檢視的指令節點。在變更檢測時,每一個 binding 決定 Angular 使用哪種操作來更新節點和提供上下文資訊,繫結型別是通過 BindingFlags 設定的(注:每一個繫結定義是 BindingDef,它的屬性 flags: BindingFlags 決定 Angular 該採取什麼操作,比如 Class
型繫結和 Style
型繫結都會呼叫對應的操作函式,見下文)。比如,如果是屬性繫結,編譯器會設定繫結標誌位為:
export const enum BindingFlags {
TypeProperty = 1 << 3,
複製程式碼
注:上文說完了指令定義函式的引數,下面說說元素定義函式的引數。
本例中,因為 span
元素有屬性繫結,編譯器會設定繫結引數為 [[8, `textContent`, 0]]
:
jit_elementDef2(..., `span`, [], [[8, `textContent`, 0]], ...)
複製程式碼
不同於指令節點,對元素節點來說,繫結引數結構是個二維陣列,因為 span
元素只有一個繫結,所以它僅僅只有一個子陣列。陣列 [8, `textContent`, 0]
中第一個引數也同樣是繫結標誌位 BindingFlags,決定 Angular 應該採取什麼型別操作(注:[8, `textContent`, 0]
中的 8
表示為 property
型繫結):
export const enum BindingFlags {
TypeProperty = 1 << 3, // 8
複製程式碼
其他型別標誌位已經在文章 譯 Angular DOM 更新機制 有所解釋:
TypeElementAttribute = 1 << 0,
TypeElementClass = 1 << 1,
TypeElementStyle = 1 << 2,
複製程式碼
編譯器不會為指令定義提供繫結標誌位,因為指令的繫結型別也只能是 BindingFlags.TypeProperty
。
注:節點繫結 這一節主要講的是對於元素節點來說,每一個節點的 binding 型別是由 BindingFlags 決定的;對於指令節點來說,每一個節點的 binding 型別只能是
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);
}
複製程式碼
如果你讀了 譯 Angular DOM 更新機制,應該對第二個函式即 updateRenderer 有所熟悉。第一個函式叫做 updateDirectives。這兩個函式都是 ViewUpdateFn 型別介面,兩者都是檢視定義的屬性:
interface ViewDefinition {
flags: ViewFlags;
updateDirectives: ViewUpdateFn;
updateRenderer: ViewUpdateFn;
複製程式碼
有趣的是這兩個函式的函式體基本相同,引數都是 _ck
和 _v
,並且兩個函式的對應引數都指向同一個物件,所以為何需要兩個函式?
因為在變更檢測期間,這是不同階段的兩個不同行為:
這兩個操作是在變更檢測的不同階段執行,所以 Angular 需要兩個獨立的函式分別在對應的階段呼叫:
- updateDirectives——變更檢測的開始階段被呼叫,來更新子元件的輸入繫結屬性
- updateRenderer——變更檢測的中間階段被呼叫,來更新當前元件的 DOM 元素
這兩個函式都會在 Angular 每次的變更檢測時 被呼叫,並且函式引數也是在這時被傳入的。讓我們看看函式內部做了哪些工作。
_ck
就是 check
的縮寫,其實就是函式 prodCheckAndUpdateNode,另一個引數就是 元件檢視資料。函式的主要功能就是從元件物件裡拿到繫結屬性的當前值,然後和檢視資料物件、檢視節點索引等一起傳入 prodCheckAndUpdateNode 函式。其中,因為 Angular 會更新每一個檢視的 DOM,所以需要傳入當前檢視的索引。如果我們有兩個 span
和兩個元件:
<b-comp [textContent]="AText"></b-comp>
<b-comp [textContent]="AText"></b-comp>
<span [textContent]="AText"></span>
<span [textContent]="AText"></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
就是函式 prodCheckAndUpdateNode。對於 DOM 元素的更新,該函式經過一系列的函式呼叫後,最終會呼叫函式 checkAndUpdateElementValue,這個函式會檢查繫結標誌位是 [attr.name, class.name, style.some]
其中的哪一個,又或者是屬性繫結(注:可檢視原始碼這段 L233-L250):
case BindingFlags.TypeElementAttribute -> setElementAttribute
case BindingFlags.TypeElementClass -> setElementClass
case BindingFlags.TypeElementStyle -> setElementStyle
case BindingFlags.TypeProperty -> setElementProperty;
複製程式碼
上面程式碼就是剛剛說的幾個繫結型別,當繫結標誌位是 BindingFlags.TypeProperty
,會呼叫函式 setElementProperty,該函式內部也是通過呼叫 DOM Renderer 的 setProperty 方法來更新 DOM。
注:
setElementProperty
函式裡這行程式碼view.renderer.setProperty(renderNode,name, renderValue);
,renderer 就是 Renderer2 interface,它僅僅是一個介面,在瀏覽器平臺下,它的實現就是 DefaultDomRenderer2。
更新指令的屬性
上文中已經描述了 updateRenderer
函式是用來更新元素的屬性,而 updateDirective
是用來更新子元件的輸入繫結屬性,並且變更檢測期間傳入的引數 _ck
就是函式 prodCheckAndUpdateNode。只是進過一系列函式呼叫後,最終呼叫的函式卻是**checkAndUpdateDirectiveInline,這是因為這次節點的標誌位是 NodeFlags.TypeDirective
(注:可檢視原始碼 L428-L429),checkAndUpdateDirectiveInline** 函式主要功能如下:
-
從當前檢視節點裡獲取元件/指令物件(注:檢視 L156)
-
檢查元件/指令物件的繫結屬性值是否發生改變(注:檢視 L160-L199)
-
如果屬性發生改變:
a. 如果變更策略設定為
OnPush
,設定檢視狀態為checksEnabled
(注:檢視 L438)b. 更新子元件的繫結屬性值(注:檢視 L446)
c. 準備
SimpleChange
資料和更新檢視的oldValues
屬性,新值替換舊值(注:檢視 L451-L454)d. 呼叫生命週期鉤子 ngOnChanges(注:檢視 L201)
-
如果該檢視是首次執行變更檢測,則呼叫生命週期鉤子 ngOnInit(注:檢視 L205)
-
呼叫生命週期鉤子 ngDoCheck(注:檢視 L233)
當然,只有在生命週期鉤子在元件內定義了才被呼叫,Angular 使用 NodeDef 節點標誌位來判斷是否有生命週期鉤子,如果檢視原始碼你會發現類似如下程式碼(注:檢視 L203-L207):
if (... && (def.flags & NodeFlags.OnInit)) {
directive.ngOnInit();
}
if (def.flags & NodeFlags.DoCheck) {
directive.ngDoCheck();
}
複製程式碼
和更新元素節點一樣,更新指令時也同樣把上一次的值儲存在檢視資料的屬性 oldValues 裡(注:即上面的 3.c
步驟)。