Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save okunokentaro/b1ba46fbd42f7cb58c6adec33fb4f5bc to your computer and use it in GitHub Desktop.
Save okunokentaro/b1ba46fbd42f7cb58c6adec33fb4f5bc to your computer and use it in GitHub Desktop.
Angular 2 @outputはObserverパターンなのかを調べてみた
2015/12/24 にQiitaに投稿した記事のアーカイブです
---
こんにちは、@armorik83です。
---
先週[`@Output`についての記事](http://qiita.com/armorik83/items/5f429cf7be4adb8c9126)を書いたが、この`@Output`はObserverパターンと言えるのかどうかを調べてみる。Observerパターンについては[Wikipedia](https://ja.wikipedia.org/wiki/Observer_%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3)、あと[Google](https://www.google.co.jp/search?q=observer%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3&oq=observer%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3&aqs=chrome..69i57j69i59.4391j0j4&sourceid=chrome&es_sm=119&ie=UTF-8)。Pub/Subパターンともいう。詳しくは割愛。
# リスナ登録なのか、リスナ格納なのか
今回疑問に思ったのは、`@Output`属性に与えたメソッドは`addListener`的に追加されているのか、それともプロパティに格納されているだけなのかという点。次のようなコードで検証してみた。Angular 2のバージョンはbeta.0。
```ts
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していることになる。
http://plnkr.co/edit/grfmkWVDdtXgWs4qdGNb?p=preview
## 結果は
ログ出力は"2"だけになった。
```txt
=====
1
=====
1
=====
2
```
これによって`this.one`のリスナは破棄され、`this.two`が再登録されていることがわかる。あれ、これってObserverパターンって呼べるんだっけ?
## EventEmitterで検証
参照を渡したから中の処理が変わったのかと不安になったので、以下のコードで検証してみた。
```js
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`);
```
結果は次の通り。
```txt
=====
1
=====
1
=====
1
2
```
だよね、これが知ってるObserverパターンだ。参照もしていない(`listener`を変更しても追従せず"1"しか出力しない)し、`emitter.on()`すると前に登録したものはそのままに、新たに追加される。
# 本気出して追ってみた
では一体どうして`this.one`と`this.two`を入れ替えたら処理が入れ替わったのか、追跡してみる。まずはTemplateの`(event)="onEvent()"`構文をパースしている辺りが怪しいので調べる。
## _parseAttr()
https://github.com/angular/angular/blob/851647334040ec95d1ab2f376f6b6bb76ead0d9a/modules/angular2/src/compiler/template_parser.ts#L344
```ts
// ...
} 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()
https://github.com/angular/angular/blob/851647334040ec95d1ab2f376f6b6bb76ead0d9a/modules/angular2/src/compiler/template_parser.ts#L401
その実装詳細は`_parseEvent()`に記述されている。この中でも`this._parseAction(expression, sourceSpan)`が重要なようだ。`this._parseAction()`は処理を`Parser#parseAction()`に委譲しているので、そちらを読む。
## Parser#parseAction()
https://github.com/angular/angular/blob/851647334040ec95d1ab2f376f6b6bb76ead0d9a/modules/angular2/src/core/change_detection/parser/parser.ts#L70
ここから怒涛のパース祭りが始まっている。もうだいぶ複雑なので詳細は省くが、つまるところASTを連れ回してガンガン処理していく。コードが複雑なだけで割と普通だ。
## Parser#parseAccessMemberOrMethodCall()
https://github.com/angular/angular/blob/851647334040ec95d1ab2f376f6b6bb76ead0d9a/modules/angular2/src/core/change_detection/parser/parser.ts#L512
どうやら`reflector`を呼び出すところが肝である。`this.reflector.method(id)`の`id`にはメソッド名(今回だと`onOutput`)が入る。
## ReflectionCapabilities#method()
https://github.com/angular/angular/blob/851647334040ec95d1ab2f376f6b6bb76ead0d9a/modules/angular2/src/core/reflection/reflection_capabilities.ts#L173-L177
```ts
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の観点では
https://developer.mozilla.org/ja/docs/Security/CSP/Introducing_Content_Security_Policy
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の深い部分を知りたければ、この辺りを読むと面白いと思った。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment