はじめに
私は日頃からSVGを使ったデータビジュアライゼーションをやっています。
2013年頃、はじめはSVG要素の構築にD3.jsを使っていましたが、性能と書きやすさ、保守性などのバランスをとって、最近ではAngularJSやReactへとライブラリを移行してきました。
AngularJSはお気に入りのライブラリですがデータビジュアライゼーションに使うという点では性能に不安がありました。
Angular2がリリースに向けて着々と準備されているということで、性能も改善されたというAngular2でSVGを描いてみます。
Angular1とAngular2で同じ機能を実装して違いを比べてみます。
どちらもTypeScriptで実装します。
Angular1のバージョンは1.4.8、Angular2のバージョンは2.0.0-alpha.53を使います。
Angular1による実装
まずはお決まりのやつから。
const app = angular.module('app', []);
本体の実装に入っていきますが、ComponentベースのAngular2と似せた実装をするためにAngular1でもdirectiveを使った実装をします。
今回はComponent1とComponent2という2つの独立したComponentから構成されるようにします。
まずはComponent1の実装です。
const component1Template = `
<div>
<h2>Component 1</h2>
<p>{{message}}</p>
<input ng-model="c1.message"/>
</div>
`;
class Component1 {
message = 'hello';
}
app.directive('component1', () => ({
template: component1Template,
controllerAs: 'c1',
controller: Component1
}));
message
をng-model
で双方向バインディングしています。
次に、Component2の実装です。
class Point {
x: number;
y: number;
}
const component2Template = `
<div>
<h2>Component 2</h2>
<div>
<input type="number" min="1" max="10" step="1" ng-model="c2.r"/>
</div>
<div>
<svg ng-attr-width="{{c2.size}}" ng-attr-height="{{c2.size}}">
<circle ng-attr-r="{{c2.r}}" ng-attr-cx="{{c2.cx(point)}}" ng-attr-cy="{{c2.cy(point)}}" ng-repeat="point in c2.points"/>
</svg>
</div>
</div>
`;
class Component2 {
r = 5;
size = 500;
points: Point[] = [];
constructor() {
var n = 10;
for (var i = 0; i < n; ++i) {
this.points.push({
x: Math.random() * this.size,
y: Math.random() * this.size
});
}
}
cx(point: Point) : number {
return point.x; // 何かすごい計算をする
}
cy(point: Point) : number {
return point.y; // 何かすごい計算をする
}
}
app.directive('component2', () => ({
template: component2Template,
controllerAs: 'c2',
controller: Component2
}));
10個の円をSVGに描画しています。
constructor
で円の座標をランダムに生成して、circle
要素のcx
、cy
へとデータバインドしています。
cx
、cy
は、座標変換の処理を挟むイメージでControllerのメソッドを経由させています。
ただし説明の単純化のため、この例では初期座標をそのまま返しています。
場合によってはメソッドの中で複雑な計算をすることもあると思ってください。
また、円の半径を表すr
はinput要素で変更可能です。
次に、Component1とComponent2の親ComponentになるAppです。
const appTemplate = `
<div>
<component1></component1>
<component2></component2>
</div>
`;
class App {
}
app.directive('myApp', () => ({
template: appTemplate,
controllerAs: 'app',
controller: App
}));
Angular2による実装
Angular1版と同じ機能をAngular 2で実装していきます。
はじめにモジュールのimportです。
import {Component, Input, ChangeDetectionStrategy} from 'angular2/core'
import {CORE_DIRECTIVES} from 'angular2/common'
import {bootstrap} from 'angular2/platform/browser'
alpha.53からangular2/angular2
からのimportが廃止されて、angular2/core
、angular2/common
などから個別にimportするように変更されました。
どこに何があるかは https://angular.io/docs/ts/latest/api/ などを見るといいでしょう。
Component1の実装は以下の通りです。
const component1Template = `
<div>
<h2>Component 1</h2>
<p>{{message}}</p>
<input [(ngModel)]="message"/>
</div>
`;
@Component({
selector: 'component1',
template: component1Template
})
class Component1 {
message = 'hello';
}
[(ngModel)]="message"
という記述で双方向データバインドを行っています。
Component2の実装は以下の通りです。
class Point {
x: number;
y: number;
}
const component2Template = `
<div>
<h2>Component 2</h2>
<div>
<input type="number" min="1" max="10" step="1" [(ngModel)]="r"/>
</div>
<div>
<svg [attr.width]="size" [attr.height]="size">
<circle [attr.r]="r" [attr.cx]="cx(point)" [attr.cy]="cy(point)" *ngFor="#point of points"/>
</svg>
</div>
</div>
`;
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'component2',
template: component2Template
})
class Component2 {
r = 5;
size = 500;
points: Point[] = [];
constructor() {
var n = 10;
for (var i = 0; i < n; ++i) {
this.points.push({
x: Math.random() * this.size,
y: Math.random() * this.size
});
}
}
cx(point: Point) : number {
return point.x; // 何かすごい計算をする
}
cy(point: Point) : number {
return point.y; // 何かすごい計算をする
}
}
[attr.r]="r"
という記述でcircle要素の属性をデータバインドしています。
Angular1のng-repeat="point in c2.points"
は*ngFor="#point of points"
のようになっています。
また、@Component
の引数にchangeDetection: ChangeDetectionStrategy.OnPush
を記述しています。
これはAngular2のChange Detectionの戦略をデフォルトから変更しています。
Change Detectionに関しては @laco0416 さんが既に詳しく書いてくれています。
http://qiita.com/laco0416/items/78edd53f5da8ead02e75
最後に、Appの実装です。
const appTemplate = `
<div>
<component1></component1>
<component2></component2>
</div>
`;
@Component({
selector: 'my-app',
template: appTemplate,
directives: [Component1, Component2, CORE_DIRECTIVES]
})
class App {
}
bootstrap(App);
Angular1とAngular2の違い
Angular1版とAngular2版の実装を少し比べてみましょう。
Angular2を意識してAngular1版を実装したこともありますが、基本的には同じような内容を実装できることがわかると思います。
Angular1ではComponentをdirective
で実装していますが、directive
は汎用性が高い代わりにAPIが複雑と言われていました。
Angular2では@Component
を使うことでシンプルになっていると思います。
また、テンプレート構文でも属性データバインドのng-attr-r="{{c1.r}}
が[attr.r]="r"
とシンプルになりました。
肝心のパフォーマンスに関してですが、Component2
のcx
メソッドにconsole.log
などを挟んでみるとわかりますが、Angular1では、Component2
とは独立しているComponent1
のmessage
が更新された場合でも毎回cx
メソッドの呼び出しが入ります。
もしcx
の中で重い処理をしている場合はそれが頻繁に呼び出されてパフォーマンスを悪化させてしまうことになります。
そのため、Angular1では重い処理をconstructor
などで事前に計算しておくといったコードが頻繁に現れていました。
今回はpoints
をComponent2
の中で生成していますが、これを親コンポーネントから受け取る場合は、さらにその変更をScope.$watch
で監視をするといった手間も必要になってきます。
この場合、コード量は増えるし、データの二重管理にもなるのであまりスマートではありませんでした。
Angular2でもデフォルトでは同じ動きをするようですが、Component2のDecoratorでchangeDetection
オプションを与えることでComponent2の状態に変化がない場合は要素の更新をスキップできます。
今回のr
の変更など、Component2の状態に変化を加えた場合はしっかり反映が行われます。
おわりに
本稿では、Angular2を使ってとりあえずSVGを描いてみて、Angular1で不満だった点がどうなったか確認してみました。
現在公式チュートリアルが対応しているalpha.44では、SVGの構築がうまくいかなかったんですがalpha.45、46、49あたりでSVG対応が進み、現在のalpha.53では無事にSVGを描くことができました。
まだ細かい不具合はあるでしょうが、リリースに向けて順調に調整されていくでしょう。
また、性能という観点でも、データビジュアライゼーションでは、$watch
の対象を減らすといったAngular1アプリでよくやるチューニングテクニックがあまり通用しないので、Change Detectionのような細かいチューニングの余地がAngular2で増えたことは非常にありがたいです。