🅰️

Angular 2 @Output は Observer パターンなのかを調べてみた

2021/07/04に公開

こんにちは、@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 していることになる。

http://plnkr.co/edit/grfmkWVDdtXgWs4qdGNb?p=preview

結果は

ログ出力は"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.onethis.twoを入れ替えたら処理が入れ替わったのか、追跡してみる。まずは Template の(event)="onEvent()"構文をパースしている辺りが怪しいので調べる。

_parseAttr()

https://github.com/angular/angular/blob/851647334040ec95d1ab2f376f6b6bb76ead0d9a/modules/angular2/src/compiler/template_parser.ts#L344

// ...
      } 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

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);に等しい(thisAppインスタンス)。

テンプレートリテラル + new Function()の合わせ技によるリフレクションだ!キモい!キモいぞ!!キモすぎる!!!

呼び出し側は Breakpoint が打ちづらく今回は追うのを断念したが、たぶんこの引数oAppインスタンスが投げられて呼ばれていると推測する。だから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 の深い部分を知りたければ、この辺りを読むと面白いと思った。

Discussion