Angular 2 @Output は Observer パターンなのかを調べてみた
こんにちは、@armorik83 です。
先週@Output
についての記事を書いたが、この@Output
は Observer パターンと言えるのかどうかを調べてみる。Observer パターンについてはWikipedia、あとGoogle。Pub/Sub パターンともいう。詳しくは割愛。
リスナ登録なのか、リスナ格納なのか
今回疑問に思ったのは、@Output
属性に与えたメソッドはaddListener
的に追加されているのか、それともプロパティに格納されているだけなのかという点。次のようなコードで検証してみた。Angular 2 のバージョンは beta.0。
import { Component, Output, EventEmitter } from "angular2/core";
@Component({
selector: "child",
providers: [],
template: ` <button (click)="onClick($event)">Click me</button> `,
directives: [],
})
class Child {
@Output() output = new EventEmitter();
onClick($event: MouseEvent): void {
console.log(`=====`);
this.output.emit($event);
}
}
@Component({
selector: "my-app",
providers: [],
template: `
<div>
<child (output)="onOutput()"></child>
<button (click)="changeBehavior()">Change!</button>
</div>
`,
directives: [Child],
})
export class App {
one = () => console.log(1);
two = () => console.log(2);
constructor() {
this.onOutput = this.one;
}
changeBehavior(): void {
this.onOutput = this.onOutput === this.two ? this.one : this.two;
}
}
"Click me"ボタンを連打するとログに"1"が並ぶ。そこで"Change!"ボタンを押したとき、その後のログ出力が"1", "2"となれば addListener していることになる。
結果は
ログ出力は"2"だけになった。
=====
1
=====
1
=====
2
これによってthis.one
のリスナは破棄され、this.two
が再登録されていることがわかる。あれ、これって Observer パターンって呼べるんだっけ?
EventEmitter で検証
参照を渡したから中の処理が変わったのかと不安になったので、以下のコードで検証してみた。
import { EventEmitter } from "events";
const emitter = new EventEmitter();
let listener = () => console.log(1);
console.log(`=====`);
emitter.on(`foo`, listener);
emitter.emit(`foo`);
console.log(`=====`);
listener = () => console.log(2);
emitter.emit(`foo`);
console.log(`=====`);
emitter.on(`foo`, listener);
emitter.emit(`foo`);
結果は次の通り。
=====
1
=====
1
=====
1
2
だよね、これが知ってる Observer パターンだ。参照もしていない(listener
を変更しても追従せず"1"しか出力しない)し、emitter.on()
すると前に登録したものはそのままに、新たに追加される。
本気出して追ってみた
では一体どうしてthis.one
とthis.two
を入れ替えたら処理が入れ替わったのか、追跡してみる。まずは Template の(event)="onEvent()"
構文をパースしている辺りが怪しいので調べる。
_parseAttr()
// ...
} else if (isPresent(bindParts[8])) { // match: (event)
this._parseEvent(bindParts[8], attrValue, attr.sourceSpan, targetMatchableAttrs,
targetEvents);
}
// ...
どんぴしゃっぽいのが居た。どうやらbindParts
というのは Angular 2 Template の様々な Sugar を格納しておくところのようで、[8]
にはイベント式(()
のもの)が入るようである。attr
は name, value, sourceSpan をもつオブジェクトで、sourceSpan には親 Component に関する情報が入っていた。残り二つはとりあえず放っておく。
_parseEvent()
その実装詳細は_parseEvent()
に記述されている。この中でもthis._parseAction(expression, sourceSpan)
が重要なようだ。this._parseAction()
は処理をParser#parseAction()
に委譲しているので、そちらを読む。
Parser#parseAction()
ここから怒涛のパース祭りが始まっている。もうだいぶ複雑なので詳細は省くが、つまるところ AST を連れ回してガンガン処理していく。コードが複雑なだけで割と普通だ。
Parser#parseAccessMemberOrMethodCall()
reflector
を呼び出すところが肝である。this.reflector.method(id)
のid
にはメソッド名(今回だとonOutput
)が入る。
ReflectionCapabilities#method()
method(name: string): MethodFn {
let functionBody = `if (!o.${name}) throw new Error('"${name}" is undefined');
return o.${name}.apply(o, args);`;
return <MethodFn>new Function('o', 'args', functionBody);
}
ここでfunctionBody
に入るのは string 型、つまり文字列をnew Function()
に与えて関数を生成し、その中のreturn o.${name}.apply(o, args);
にあるapply
で動作を実現している。${name}
はテンプレートリテラルのため実際の name(ここではonOutput
)に置き換えられる。すなわちthis.onOutput.apply(this, args);
に等しい(this
はApp
インスタンス)。
テンプレートリテラル + new Function()
の合わせ技によるリフレクションだ!キモい!キモいぞ!!キモすぎる!!!
呼び出し側は Breakpoint が打ちづらく今回は追うのを断念したが、たぶんこの引数o
にApp
インスタンスが投げられて呼ばれていると推測する。だからaddListener
ではなく処理入れ替えの挙動をとったんだね。
(追記)CSP の観点では
CSP によってnew Function()
が制限されている環境下で、この扱いがどうなるのかは追いきれなかった。
Angular2 Throws Security Exceptions under the CSP (Content Security Policy) #1744
https://github.com/angular/angular/issues/1744
This works as expected. To run the Angular 2 in CSP mode you have to switch into Dynamic mode or pre-generate change detectors offline.
issue によって指摘はされており、回答によると"Dynamic mode"もしくは"pre-generate change detectors offline"による手段でこれを解決できるように配慮されているらしいが、詳細は不明である。今後の動きを待とう。
わかったこと
-
@Output
は pub/sub っぽく見えても Method call - Angular 2 の肝はいくつかある
- DI
- Change Detection
- Template Parser + Reflector
- それらを上手く包括する Decorators 構文による Metadata
Angular 2 の深い部分を知りたければ、この辺りを読むと面白いと思った。
Discussion