年末のイベントで何度か Angular 2 について紹介させていただいたのですが、自分で話しながらよくわかっていないなと感じることしきり。時間ができたらしっかり理解したい!と思っていました。理解するにはソースを読むしかない!ということで Angular 2 のソースを読んでみます。
使い方については本家ドキュメントを始めいろいろなところに書かれているので、内部構造について調べます。Change Detection, Dependency Injection 周りは @laco0416 さんによってカバーされている/される予定(12/23)なので、初期化・Component のコンパイル周りに挑戦してみたいと思います。
AngularConnect のキーノートでは、Angular 1 ではランタイムで何度も HTML のコンパイルが走っていたが、Angular 2 では初期化時に一度走るだけになり、高速化につながっているという話がありました。このあたり実際にどうなっているのか興味がありますね。
バージョンは beta.0 リリース直後の 2a2f9a9a196cf1502c3002f14bd42b13a4dcaa53 です。基本的に beta.0 としてリリースされたバージョンと同じと考えていいと思います。
TL;DR
- Angular 2 はプラットフォームごとに様々なコンポーネントを切り替えられるようになっている。
- Component のコンパイルの結果、複数の Command ができる。画面を描画するための命令群のようなもの。
- RuntimeCompiler がトップレベル Component 型を DOM 関係なくコンパイルして ProtoViewRef を返す。View を作るための雛形のようなもの。
- AppViewManagaer が ProtoViewRef(コンパイルされた Component のメタ情報)をもとに Component のインスタンスを作り、DOM要素に紐付ける。
ソースを読む前に
まず前提として Angular 2 のレポジトリは GitHub の angular/angular です。modules というディレクトリに angular2_material などいくつかのプロジェクトと一緒に入っています。本体のソースは modules/angular2 です。
本体のソース以外で役に立つのは modules/playground のサンプル群と modules/angular2/src/test です。両方共かなり充実しているので、ソースだけでは理解するのが難しい使い方や詳細な仕様が確認できます。playground は HTML に $SCRIPTS$
などの特殊な文字が入っていてビルド時に埋まる感じになっているので、よくわからない場合は angular/angular をチェックアウトしてビルドしてみましょう。angular/angular 本体は Node 4, npm 2 系までしか対応していないことに要注意です。
ソースコードを読むのには Visual Studio Code を使いました。TypeScript の型情報を使って定義にジャンプしたり、参照を検索したりできて便利。
bootstrap を読んでみる
アプリを初期化する bootstrap から始めましょう。以下に出てくるパスは全て modules 以下の話です。ブラウザ向けの bootstrap のある angular2/platfrom/browser.ts
を見てみます。
export function bootstrap(
appComponentType: Type,
customProviders?: Array<any /*Type | Provider | any[]*/>): Promise<ComponentRef> {
reflector.reflectionCapabilities = new ReflectionCapabilities();
let appProviders =
isPresent(customProviders) ? [BROWSER_APP_PROVIDERS, customProviders] : BROWSER_APP_PROVIDERS;
return platform(BROWSER_PROVIDERS).application(appProviders).bootstrap(appComponentType);
}
bootstrap
にはトップレベルの Component の型と(必要あれば)独自の providers を渡します。以下のような処理の流れですね。
-
platform
:BROWSER_PROVIDERS
からPlatformRef
を作る。 -
PlatformRef#application
:BROWSER_APP_PROVIDERS
+ 独自の providers を渡してApplicationRef
を作る。 -
ApplicationRef#bootstrap
: トップレベルの Component の型を渡してアプリケーションを開始する。
ではこれらのメソッドを見ていきましょう。
ちなみに BROWSER_PROVIDERS
はプラットフォーム共通の provider に DOM アダプタの初期化処理を加えたものの模様。
/**
* A set of providers to initialize the Angular platform in a web browser.
*
* Used automatically by `bootstrap`, or can be passed to {@link platform}.
*/
export const BROWSER_PROVIDERS: Array<any /*Type | Provider | any[]*/> = CONST_EXPR([
PLATFORM_COMMON_PROVIDERS,
new Provider(PLATFORM_INITIALIZER, {useValue: initDomAdapter, multi: true}),
]);
platform
import/export を追っていくと platform
は angular2/src/core/application_ref.ts
にあることがわかります。ちなみに platform
の引数は BROWSER_PROVIDERS
です。ざっくり言うと _createPlatform
というのを呼んでいるだけです。
function _createPlatform(providers?: Array<Type | Provider | any[]>): PlatformRef {
_platformProviders = providers;
let injector = Injector.resolveAndCreate(providers);
_platform = new PlatformRef_(injector, () => {
_platform = null;
_platformProviders = null;
});
_runPlatformInitializers(injector);
return _platform;
}
新規に Injector を作って、それを使って PlatformRef_
なるものを作った後、プラットフォームごとの初期化処理をした後、PlatformRef_
を返しています。
PlatformRef_
PlatformRef_
を見てみましょう。
constructor(private _injector: Injector, private _dispose: () => void) { super(); }
引数をメンバ変数としてとっておいているだけですね。第二引数は、アプリ終了時の処理のようです。
PlatformRef_#bootstrap
bootstrap
内で呼ばれている application
メソッドを見てみましょう。ここでいう providers は BROWSER_APP_PROVIDERS
+ 独自 providers です。
application(providers: Array<Type | Provider | any[]>): ApplicationRef {
var app = this._initApp(createNgZone(), providers);
return app;
}
NgZone
を作って _initApp
に渡しています。
private _initApp(zone: NgZone, providers: Array<Type | Provider | any[]>): ApplicationRef {
var injector: Injector;
var app: ApplicationRef;
zone.run(() => {
providers = ListWrapper.concat(providers, [
provide(NgZone, {useValue: zone}),
provide(ApplicationRef, {useFactory: (): ApplicationRef => app, deps: []})
]);
var exceptionHandler;
try {
injector = this.injector.resolveAndCreateChild(providers);
exceptionHandler = injector.get(ExceptionHandler);
zone.overrideOnErrorHandler((e, s) => exceptionHandler.call(e, s));
} catch (e) {
if (isPresent(exceptionHandler)) {
exceptionHandler.call(e, e.stack);
} else {
print(e.toString());
}
}
});
app = new ApplicationRef_(this, zone, injector);
this._applications.push(app);
_runAppInitializers(injector);
return app;
}
ApplicationRef_
なるものが作られ、返されています。その途中で(?) BROWSER_PROVIDERS
が入っている injector を継承して BROWSER_APP_PROVIDERS
+ 独自 providers を足した子 injector が作られているのがわかります。
TODO: NgZone#run とは?
BROWSER_APP_PROVIDERS
ちなみに BROWSER_APP_PROVIDERS
は、ブラウザ上で動くアプリに必要な provider たちの模様。BROWSER_APP_COMMON_PROVIDERS
と COMPILER_PROVIDERS
が分かれているのは、オフラインコンパイルへの布石?
/**
* An array of providers that should be passed into `application()` when bootstrapping a component.
*/
export const BROWSER_APP_PROVIDERS: Array<any /*Type | Provider | any[]*/> = CONST_EXPR([
BROWSER_APP_COMMON_PROVIDERS,
COMPILER_PROVIDERS,
new Provider(XHR, {useClass: XHRImpl}),
]);
BROWSER_APP_COMMON_PROVIDERS
は以下のような感じです。
/**
* A set of providers to initialize an Angular application in a web browser.
*
* Used automatically by `bootstrap`, or can be passed to {@link PlatformRef.application}.
*/
export const BROWSER_APP_COMMON_PROVIDERS: Array<any /*Type | Provider | any[]*/> = CONST_EXPR([
APPLICATION_COMMON_PROVIDERS,
FORM_PROVIDERS,
new Provider(PLATFORM_PIPES, {useValue: COMMON_PIPES, multi: true}),
new Provider(PLATFORM_DIRECTIVES, {useValue: COMMON_DIRECTIVES, multi: true}),
new Provider(ExceptionHandler, {useFactory: _exceptionHandler, deps: []}),
new Provider(DOCUMENT, {useFactory: _document, deps: []}),
new Provider(EVENT_MANAGER_PLUGINS, {useClass: DomEventsPlugin, multi: true}),
new Provider(EVENT_MANAGER_PLUGINS, {useClass: KeyEventsPlugin, multi: true}),
new Provider(EVENT_MANAGER_PLUGINS, {useClass: HammerGesturesPlugin, multi: true}),
new Provider(DomRenderer, {useClass: DomRenderer_}),
new Provider(Renderer, {useExisting: DomRenderer}),
new Provider(SharedStylesHost, {useExisting: DomSharedStylesHost}),
DomSharedStylesHost,
Testability,
BrowserDetails,
AnimationBuilder,
EventManager
]);
ApplicationRef_
今度は ApplicationRef_ を見てみましょう。
constructor(private _platform: PlatformRef_, private _zone: NgZone, private _injector: Injector) {
super();
if (isPresent(this._zone)) {
ObservableWrapper.subscribe(this._zone.onTurnDone,
(_) => { this._zone.run(() => { this.tick(); }); });
}
this._enforceNoNewChanges = assertionsEnabled();
}
NgZone#onTurnDone
は
/**
* Notifies subscribers immediately after Angular zone is done processing
* the current turn and any microtasks scheduled from that turn.
*
* Used by Angular as a signal to kick off change-detection.
*/
get onTurnDone() { return this._onTurnDoneEvents; }
だそうで、AJAX や DOM などのイベント発生した後に呼ばれるのかなと。tick()
の中では変更検知をキックしているようです。
TODO: Zone.js の詳細
ApplicationRef_#bootstrap
そして最後の bootstrap
です。
bootstrap(componentType: Type,
providers?: Array<Type | Provider | any[]>): Promise<ComponentRef> {
var completer = PromiseWrapper.completer();
this._zone.run(() => {
var componentProviders = _componentProviders(componentType);
if (isPresent(providers)) {
componentProviders.push(providers);
}
var exceptionHandler = this._injector.get(ExceptionHandler);
this._rootComponentTypes.push(componentType);
try {
var injector: Injector = this._injector.resolveAndCreateChild(componentProviders);
var compRefToken: Promise<ComponentRef> = injector.get(APP_COMPONENT_REF_PROMISE);
var tick = (componentRef) => {
this._loadComponent(componentRef);
completer.resolve(componentRef);
};
var tickResult = PromiseWrapper.then(compRefToken, tick);
// THIS MUST ONLY RUN IN DART.
// This is required to report an error when no components with a matching selector found.
// Otherwise the promise will never be completed.
// Doing this in JS causes an extra error message to appear.
if (IS_DART) {
PromiseWrapper.then(tickResult, (_) => {});
}
PromiseWrapper.then(tickResult, null,
(err, stackTrace) => completer.reject(err, stackTrace));
} catch (e) {
exceptionHandler.call(e, e.stack);
completer.reject(e, e.stack);
}
});
return completer.promise.then(_ => {
let c = this._injector.get(Console);
let modeDescription =
assertionsEnabled() ?
"in the development mode. Call enableProdMode() to enable the production mode." :
"in the production mode. Call enableDevMode() to enable the development mode.";
c.log(`Angular 2 is running ${modeDescription}`);
return _;
});
}
長いですが結局は、さらに子 injector
を作って、ComponentRef
なるものを取得し、それを使って _loadComponent
して、それで結果の Promise
を resolve
しているようです。
_loadComponent(ref): void {
var appChangeDetector = internalView(ref.hostView).changeDetector;
this._changeDetectorRefs.push(appChangeDetector.ref);
this.tick();
this._rootComponents.push(ref);
this._bootstrapListeners.forEach((listener) => listener(ref));
}
ref.hostView
などが生えていることなら、ComponentRef
はコンパイル済の Component の模様。いつの間にコンパイルしていたのでしょうか?injector から ComponentRef を取得するところ?
APP_COMPONENT_REF_PROMISE
で検索するとありました。
function _componentProviders(appComponentType: Type): Array<Type | Provider | any[]> {
return [
provide(APP_COMPONENT, {useValue: appComponentType}),
provide(APP_COMPONENT_REF_PROMISE,
{
useFactory: (dynamicComponentLoader: DynamicComponentLoader, appRef: ApplicationRef_,
injector: Injector) => {
// Save the ComponentRef for disposal later.
var ref: ComponentRef;
// TODO(rado): investigate whether to support providers on root component.
return dynamicComponentLoader.loadAsRoot(appComponentType, null, injector,
() => { appRef._unloadComponent(ref); })
.then((componentRef) => {
ref = componentRef;
if (isPresent(componentRef.location.nativeElement)) {
injector.get(TestabilityRegistry)
.registerApplication(componentRef.location.nativeElement,
injector.get(Testability));
}
return componentRef;
});
},
deps: [DynamicComponentLoader, ApplicationRef, Injector]
}),
provide(appComponentType,
{
useFactory: (p: Promise<any>) => p.then(ref => ref.instance),
deps: [APP_COMPONENT_REF_PROMISE]
}),
];
}
TestabilityRegistry
はテスト関係だと思うので無視して、DynamicComponentLoader#loadAsRoot
がトップレベル Component
の型をもらって ComponentRef
を作っているようです。
DynamicComponentLoader#loadAsRoot
loadAsRoot(type: Type, overrideSelector: string, injector: Injector,
onDispose?: () => void): Promise<ComponentRef> {
return this._compiler.compileInHost(type).then(hostProtoViewRef => {
var hostViewRef =
this._viewManager.createRootHostView(hostProtoViewRef, overrideSelector, injector);
var newLocation = this._viewManager.getHostElement(hostViewRef);
var component = this._viewManager.getComponent(newLocation);
var dispose = () => {
if (isPresent(onDispose)) {
onDispose();
}
this._viewManager.destroyRootHostView(hostViewRef);
};
return new ComponentRef_(newLocation, component, type, injector, dispose);
});
}
- Compiler#compileInHost: Component 型をコンパイルして ProtoViewRef を作ります。
- AppViewManager#createRootHostView: ProtoViewRef から Component のインスタンスを作成し、Global View(ブラウザなら Document)内で Component のセレクタ(上書き可)にマッチする最初の要素に結びつけます。返すのは HostViewRef。
- AppViewManager#getHostElement:(省略)
- AppViewManager#getComponent:(省略)
TODO: HostViewRef と ElementRef の違い?後者は DOM 要素を指していそう。前者は?
Compiler_#compileInHost
いよいよ核心に迫って来ました。
compileInHost(componentType: Type): Promise<ProtoViewRef> {
var metadatas = reflector.annotations(componentType);
var compiledHostTemplate = metadatas.find(_isCompiledHostTemplate);
if (isBlank(compiledHostTemplate)) {
throw new BaseException(
`No precompiled template for component ${stringify(componentType)} found`);
}
return PromiseWrapper.resolve(this._createProtoView(compiledHostTemplate));
}
あれ?もう Metadata がコンパイルされている前提になっている?何かがおかしい...
探してみると angular2/src/compiler/compiler.ts
の中の COMPILER_PROVIDERS
で以下のようになっています。
export const COMPILER_PROVIDERS: Array<Type | Provider | any[]> = CONST_EXPR([
// ...
new Provider(RuntimeCompiler, {useClass: RuntimeCompiler_}),
new Provider(Compiler, {useExisting: RuntimeCompiler}),
// ...
]);
Compiler
型の DI では Compiler_
ではなく RuntimeCompiler_
を使うようになっていたわけですね。RuntimeCompiler_
は Compiler_
を継承しています。
Runtime と名前についているということは Runtime じゃない Compiler もあるはずなので、オフライン用の Compiler も用意されるのかもしれません(AngularConnect のキーノートでもオフラインコンパイルがそのうち実装されるようなことを言っていました)。Compiler_ はオフラインでコンパイルしたテンプレートを使うためのもの?
RuntimeCompiler
気を取り直して RuntimeCompiler を追ってみます。
compileInHost(componentType: Type): Promise<ProtoViewRef> {
return this._templateCompiler.compileHostComponentRuntime(componentType)
.then(compiledHostTemplate => internalCreateProtoView(this, compiledHostTemplate));
}
-
TemplateCompiler#compileHostComponentRuntime
: これがまさに Angular 2 のコンパイルの模様! -
internalCreateProtoView
: 辿ってみるとProtoViewFactory#createHost
を呼んでいます。基本的には 1 でできたCompiledHostTemplate
を Renderer に登録した後、AppProtoView
で包んでいるだけのようです。
TemplateCompiler#compileHostComponentRuntime
TemplateCompiler を見てみます。
compileHostComponentRuntime(type: Type): Promise<CompiledHostTemplate> {
var hostCacheKey = this._hostCacheKeys.get(type);
if (isBlank(hostCacheKey)) {
hostCacheKey = new Object();
this._hostCacheKeys.set(type, hostCacheKey);
var compMeta: CompileDirectiveMetadata = this._runtimeMetadataResolver.getMetadata(type);
assertComponent(compMeta);
var hostMeta: CompileDirectiveMetadata =
createHostComponentMeta(compMeta.type, compMeta.selector);
this._compileComponentRuntime(hostCacheKey, hostMeta, [compMeta], new Set());
}
return this._compiledTemplateDone.get(hostCacheKey)
.then(compiledTemplate => new CompiledHostTemplate(compiledTemplate));
}
-
RuntimeMetadataResolver#getMetadata
: Component のメタデータを取得していますね。 -
createHostComponentMeta
:(省略) -
TemplateCompiler#_compileComponentRuntime
: ここがメインのコンパイル処理のようです!
TemplateCompiler#_compileComponentRuntime
なかなかの大物が出て来ました。
private _compileComponentRuntime(
cacheKey: any, compMeta: CompileDirectiveMetadata, viewDirectives: CompileDirectiveMetadata[],
compilingComponentCacheKeys: Set<any>): CompiledComponentTemplate {
let uniqViewDirectives = removeDuplicates(viewDirectives);
var compiledTemplate = this._compiledTemplateCache.get(cacheKey);
var done = this._compiledTemplateDone.get(cacheKey);
if (isBlank(compiledTemplate)) {
var styles = [];
var changeDetectorFactory;
var commands = [];
var templateId = `${stringify(compMeta.type.runtime)}Template${this._nextTemplateId++}`;
compiledTemplate = new CompiledComponentTemplate(
templateId, (dispatcher) => changeDetectorFactory(dispatcher), commands, styles);
this._compiledTemplateCache.set(cacheKey, compiledTemplate);
compilingComponentCacheKeys.add(cacheKey);
done = PromiseWrapper
.all([<any>this._styleCompiler.compileComponentRuntime(compMeta.template)].concat(
uniqViewDirectives.map(dirMeta => this.normalizeDirectiveMetadata(dirMeta))))
.then((stylesAndNormalizedViewDirMetas: any[]) => {
var childPromises = [];
var normalizedViewDirMetas = stylesAndNormalizedViewDirMetas.slice(1);
var parsedTemplate = this._templateParser.parse(
compMeta.template.template, normalizedViewDirMetas, compMeta.type.name);
var changeDetectorFactories = this._cdCompiler.compileComponentRuntime(
compMeta.type, compMeta.changeDetection, parsedTemplate);
changeDetectorFactory = changeDetectorFactories[0];
var tmpStyles: string[] = stylesAndNormalizedViewDirMetas[0];
tmpStyles.forEach(style => styles.push(style));
var tmpCommands: TemplateCmd[] = this._compileCommandsRuntime(
compMeta, parsedTemplate, changeDetectorFactories,
compilingComponentCacheKeys, childPromises);
tmpCommands.forEach(cmd => commands.push(cmd));
return PromiseWrapper.all(childPromises);
})
.then((_) => {
SetWrapper.delete(compilingComponentCacheKeys, cacheKey);
return compiledTemplate;
});
this._compiledTemplateDone.set(cacheKey, done);
}
return compiledTemplate;
}
CompiledCompoentTemplate
というものを作って返しています。
@CONST()
export class CompiledComponentTemplate {
constructor(public id: string, public changeDetectorFactory: Function,
public commands: TemplateCmd[], public styles: string[]) {}
}
changeDetectorFactory
の実体, commands
, styles
は、CompiledComponentTemplate
を返してしまった後に埋まるようになっているのが面白いところ。
以下が実際のコンパイルの処理です。
-
StyleCompiler#compileComponentRuntime
: CSS のパース(多分 AST にしている) -
TemplateCompiler#normalizeDirectiveMetadata
: テンプレート内で使われる Directive たち(ngFor など PLATFORM_DIRECTIVES 内のものや directives で指定したもの)の Metadata を正規化 -
TemplateParser#parse
: 2 を使ってテンプレートをパースして AST に。結果はTemplateAST[]
。 - ChangeDetector のファクトリ関数を作成。
-
TemplateCompiler#_compileCommandsRuntime
: 上で作った AST などの情報から commands(TemplateCmd[]
) を作る。中では再帰的に子 Component をコンパイルしていくようです。
どうやらここで作りたかったのは TemplateCmd[]
のようです。
Command とは一体何?
export interface TemplateCmd extends RenderTemplateCmd {
visit(visitor: RenderCommandVisitor, context: any): any;
}
TemplateCmd の親は・・・
/**
* Abstract base class for commands to the Angular renderer, using the visitor pattern.
*/
export abstract class RenderTemplateCmd {
abstract visit(visitor: RenderCommandVisitor, context: any): any;
}
これを継承している抽象クラスには以下がありました。
- RenderBeginCmd: Command to begin rendering.
- RenderTextCmd: Command to render text.
- RenderNgContentCmd: Command to render projected content.
- RenderBeginElementCmd: Command to begin rendering an element.
- RenderBeginComponentCmd: Command to begin rendering a component.
- RenderEmbeddedTemplateCmd: Command to render a component's template.
意外に少ないですね。どうやら Command というのは Rendering のための命令のことのようです。つまるところ Angular 2 は、Component の Template を一回 AST にパースした後、(ブラウザの場合は)HTML を描画するための命令セットに変換していることがわかりました。このことをコンパイルと言っているようです。
終わりに
今回はこのあたりで時間切れですが、(次があれば)次回はコンパイルして作った Command がどうやって使われているかを見ていきたいなと思います。みんなで Angular 2 の内部構造を解析しましょう!