2015年3月18日
完璧なJavaScriptフレームワークを求めて Part 1
(2014-10-28)by Krasimir Tsonev
本記事は、原著者の許諾のもとに翻訳・掲載しております。
最近のフロントエンド開発では、多くのフレームワークやライブラリが利用できます。ただし玉石混淆で、良い物もあれば悪いものもあります。そんなわけで多くの場合、私たちは特定のコンセプトやモジュールまたは構文に傾倒しがちです。でも、それが万能かと言うとそうでもありません。この投稿では、将来的なフレームワーク、つまりまだ存在していないフレームワークについて話をしていきたいと思っています。現状で利用可能なJavaScriptフレームワークの利点や欠点をまとめて、完璧なソリューションを思い描いてみましょう。
抽象化は危険
私たちはシンプルなツールが好きですよね。複雑さはある意味、命取りです。作業が難しくなり、一定時間内で多くのことを覚えなければならなくなる(急勾配の学習曲線が求められる)ようなことが多々あります。プログラマは仕組みを理解するまで気も休まらないのではないでしょうか。複雑なシステムで作業をする場合、使用者がシステムを「使う」ことと、そのシステムの仕組みを「知っている」ことの間には、大きなギャップがあると言えるでしょう。例えば、以下のコードには複雑さが隠れています。
var page = Framework.createPage({
'type': 'home',
'visible': true
});
これを実際のフレームワークとして考えてみましょう。裏ではcreatePageが、home.htmlのテンプレートを読み込む新しいViewクラスを生成します。visibleのパラメータにより、新規作成されたDOM要素をツリーに加えるか加えないかが選択可能です。ここで、開発者の立場になって考えてみてください。私たちはこのドキュメンテーションから、これがあるテンプレートを伴う新規ページを作成するということは読み取れますよね。しかしこれは抽象化されているため、特定の詳細については何ら知ることは出来ないのです。
近年の一部のフレームワークでは、1つだけではなく多くのレベルで抽象化がなされています。しかし、そのフレームワークを適切に使うために、時には詳細な情報を知る必要だって出てくることもあるはずです。抽象化というのは機能をまとめるパワフルな方法で設計上の決定事項をカプセル化するものですが、プロセスの追跡が出来ないため、抽象化をする際には賢明な判断が求められます。
上の例を次のように変えてみるとどうでしょうか。
var page = Framework.createPage();
page
.loadTemplate('home.html')
.appendToDOM();
どうですか? 何がどう動いているかが分かりますよね。テンプレートと追加が、個別のAPIメソッドとして表記されているからです。これだと、開発者はプロセスに制御を加えたり、その中間で何かをしたり出来るようになります。
次は Ember.js を取り上げてみましょう。とても優秀なフレームワークで、これを使えば数行のコードでシングルページアプリケーションを作成できるようになります。しかし、それにはある犠牲が伴うことも事実です。裏では、いくつかのクラスが定義されます。例えばこんな感じです。
App.Router.map(function() {
this.resource('posts', function() {
this.route('new');
});
});
フレームワークは3つの経路を作成し、それぞれにコントローラが付いていますよね。これは、フレームワークがアプリケーションを動作させるために必要とするものなので、あなたが使おうと使うまいと、いや応なしに存在するクラスです。
プロジェクトを進めていると、往々にしてカスタム機能が必要な場合が出てくるでしょう。全ての要求に応えることの出来るフレームワークなんてありませんからね。そこで、簡単には解決できない問題に直面します。正しいやり方を探すために全ての仕組みを理解しなくてはならなくなってくるのです。もちろんカスタムの方向性はそれぞれに異なるため、フレームワークを使うというよりも、バラバラに切り刻んでいくような感覚に陥るかもしれません。
ここで例に挙げる Backbone.js では、いくつかの定義済みオブジェクトを導入しています。その中にはコア機能が含まれていますが、実装するかどうかはプログラマ次第です。下にあるDocumentViewクラスは、Backbone.Viewを拡張していますね。それだけです。コードとフレームワークのコア機能との間に複数の階層はありません。
var DocumentView = Backbone.View.extend({
'tagName': 'li',
'events': {
'mouseover .title .date': 'showTooltip',
'click .open': 'render'
},
'render': function() { … },
'showTooltip': function() { … }
});
個人的には、複数のレベルで抽象化されているようなフレームワークよりも、透明性の高いフレームワークの方が好みですね。
行方不明のコンストラクタ
一部のフレームワークはこちらが指定したクラスを受け入れてくれますが、コンストラクタは生成してくれません。インスタンスをいつどこで生成するかを決めるのはフレームワークです。これを私たちが出来るようなフレームワークがもっとあればいいと思いますね。例として、 Knockout を挙げましょう。
function ViewModel(first, last) {
this.firstName = ko.observable(first);
this.lastName = ko.observable(last);
}
ko.applyBindings(new ViewModel("Planet", "Earth"))
このフレームワークではモデルを定義し、初期化することが可能です。AngularJSでは、これとは少し異なります。
function TodoCtrl($scope) {
$scope.todos = [
{ 'text': 'learn angular', 'done': true },
{ 'text': 'build an angular app', 'done': false }
];
}
ここでは再度クラスを定義しますが、実行するわけではありません。これはただのコントローラなので、プロセスを決めるのはフレームワークです。私たちがアプリケーションのフローを可視化する際に使用するキーポイントがなくなるので、これについては混乱してしまうかもしれません。
DOM操作
私たちが何をするにせよ、DOMとの関わりを避けては通れません。重要なのは、それをどのようにするかです。なぜなら一般的にページ上の全てのノード操作では、コストがかかるリフローとリペイントが発生するからです。例として、次のJavaScriptのクラスの使用について考えてみましょう。
var Framework = {
'el': null,
'setElement': function(el) {
this.el = el;
return this;
},
'update': function(list) {
var str = '<ul>';
for (var i = 0; i < list.length; i++) {
var li = document.createElement('li');
li.textContent = list[i];
str += li.outerHTML;
}
str += '</ul>';
this.el.innerHTML = str;
return this;
}
}
この小さなフレームワークは、与えられたデータから番号なしの順不同リストを作成します。リストをホストするDOM要素を送ると、画面にデータを表示するupdate関数を呼び出します。
Framework
.setElement(document.querySelector('.content'))
.update(['JavaScript', 'is', 'awesome']);
このコードを実行すると、以下のようになります。
なぜこれが悪い設計なのかを説明するために、ページにリンクを追加してclickというイベントリスナを取り付けてみましょう。この関数は再度updataメソッドを呼び出しますが、アイテムは異なります。
document.querySelector('a').addEventListener('click', function() {
Framework.update(['Web', 'is', 'awesome']);
});
配列の最初の要素を変えただけで、送るデータはほとんど同じです。しかしinnerHTMLを使用しているので、再描画は各クリックの後に発火されます。ブラウザは最初の行の変更だけが必要なことを知らないので、リスト全体が再描画されます。ではOperaの開発者ツールを使って、プロファイルを実行しましょう。次のGIFアニメーションのデモで結果を見てください。
各クリックの後にコンテンツ全体が再描画されましたね。特にページ上で同じテクニックを多用する時などは、これが問題になります。
もっといいやり方は、
ノードを作成してその内容だけをアップデートすることです。この方法だとリスト全体でなく、子リストを変更するだけで済みます。まずsetElement内を変える必要があります。
setElement: function(el) {
this.list = document.createElement('ul');
el.appendChild(this.list);
return this;
}
このように、親要素のリファレンスは一切必要ありません。ただ
\<
ul>要素を作成して、それを追加するだけです。
パフォーマンスを向上させるロジックは、updateメソッドの本体にあります。
'update': function(list) {
for (var i = 0; i < list.length; i++) {
if (!this.rows[i]) {
var row = document.createElement('LI');
row.textContent = list[i];
this.rows[i] = row;
this.list.appendChild(row);
} else if (this.rows[i].textContent !== list[i]) {
this.rows[i].textContent = list[i];
}
}
if (list.length < this.rows.length) {
for (var i = list.length; i < this.rows.length; i++) {
if (this.rows[i] !== false) {
this.list.removeChild(this.rows[i]);
this.rows[i] = false;
}
}
}
return this;
}
最初のforループはデータを通して渡され、必要であれば
要素を作成します。this.rowsは作成されたタグを保持します。特定のインデックスにノードが存在する場合、適用可能であればフレームワークがtextContentのプロパティをアップデートします。最後のループは、渡された配列の要素が現行のものより少ない場合にノードを取り除きます。
結果はこうなります。
このブラウザは変更された部分だけをリペイントします。
幸いにも React のようなフレームワークは、すでにDOM操作を正確に扱っています。ブラウザはよりスマートになり、再描画を減らすための方法も適用されています。いずれにしろ、選択したフレームワークによって何がもたらされるかを念頭に置いてチェックするのがいいですね。
近い将来、このような問題について考えなくてもいい日が来ることを願います。フレームワークが自動的に解決してくれるのが一番です。
DOMイベントハンドリング
JavaScriptアプリケーションは通常、DOMイベントを通してユーザと関係性を持ちます。ページ上の要素がメッセージを送り、コードがそれを処理します。これはユーザの操作とページが相互に働いた際にアクションを起こす、Backbone.jsのコードの一部です。
var Navigation = Backbone.View.extend({
'events': {
'click .header.menu': 'toggleMenu'
},
'toggleMenu': function() {
// …
}
});
ここでは、要素は.header.menuセレクタとマッチし、ユーザがクリックした時にmenuを切り替える必要があります。この設計の問題点は、私たちがJsvaScriptオブジェクトを特定のDOMアイテムに結び付けていることです。HTMLに手を加えたり、.menuを.main-menuに変更したりすると、JavaScriptも変更しなければいけなくなります。出来ればコントローラは独立させたいので、DOMから分離させるべきです。
関数を定義して、タスクをJavaScriptのクラスに委譲します。このタスクがDOMイベントのハンドラである場合は、HTMLでの構築が有効です。
私はAngularJSのイベント処理の仕方が好きです。
<a href="#" ng-click="go()">click me</a>
goはコントローラに登録されている関数です。この手法に従えば、DOMセレクタのことを考える必要はありません。挙動を直接HTMLノードに適用するだけです。DOMとの退屈なやり取りをスキップできるので、この手法はとても役に立ちます。
私は、総じてHTML内部のこのようなロジックが好きです。私たちは長年にわたり、開発者にコンテンツ(HTML)と挙動(JavaScript)を区別し、インラインのスタイリングやスクリプティングを避けるように説いてきました。しかし、実際にこれをやってみると大幅に時間を節約でき、コンポーネントが柔軟になることが分かりました。もちろん、こんなコードのことではありませんよ。
<div onclick="javascript:App.doSomething(this);">banner text</div>
そうではなく、次のような要素の挙動を制御する記述的属性のことです。
<div data-component="slideshow" data-items="5" data-select="dispatch:selected">
…
</div>
JavaScriptのコードをHTMLに埋め込むものではなく、より設定を行うようなものでなければなりません。
依存性の管理
依存性の管理は開発プロセスの中で重要な仕事です。私たちは通常、外部の関数、モジュール、ライブラリに依存しています。実は、依存性はそうした中で常に生み出されているのです。全てが1つのメソッドに記述されるわけではありません。私たちはアプリケーションのタスクを関数に分割して連結させています。理想的なケースは、ブラックボックスのように動作するモジュールにロジックをカプセル化することです。モジュールは自身のジョブの詳細のみを認識し、それ以外は関知しません。
依存性の解決には、 RequireJS がよく使用されます。次のように、必要なモジュールを受け入れるクロージャにコードをラップします。
require(['ajax', 'router'], function(ajax, router) {
// …
});
上の例では、関数にajaxとrouterの2つのモジュールが必要です。有能なrequireメソッドが渡された配列を読み取り、適切な引数で関数を呼び出します。routerの定義は次のようになります。
// router.js
define(['jquery'], function($) {
return {
'apiMethod': function() {
// …
}
}
});
ここには別の依存性があることにお気づきでしょうか。jQueryです。モジュールのパブリックAPIを返すことも考える必要があります。そうしないと、モジュールを必要とするコードが、定義された機能にアクセスできません。
AngularJSの場合はfactoryと呼ばれるものを私たちに提供することで、さらに踏み込んだ処理をします。ここで依存性を登録すれば、なんと 魔法のように コントローラで使用できるようになります。例えば、こんな感じです。
myModule.factory('greeter', function($window) {
return {
'greet': function(text) {
alert(text);
}
};
});
function MyController($scope, greeter) {
$scope.sayHello = function() {
greeter.greet('Hello World');
};
}
通常、この手法で処理が簡略化されます。依存性を取得するためにrequireなどの関数を使う必要はありません。私たちは引数のリストに正しい単語を入力すればいいのです。
さて、2通りの依存性の注入を使えることが分かりましたが、これらは特定のコードの記述方法と関連しています。将来的には、この制限をなくすフレームワークが見てみたいです。変数を定義している間にメタデータを適用できたらスマートですよね。この言語では、まだそのような機能はありません。例えば、こんなコードを使えるようになったらステキです。
var router:<inject:Router>;
変数の定義と一緒に依存性を設定するということは、必要な時だけ注入を実行することを意味します。例えばRequireJSとAngularJSは関数レベルで動作します。ですから、特定のケースでのみモジュールを使うことが出来ますが、常に初期化とその注入が発生します。依存性を定義しなければならない状況は他にもあり、私たちはそれに縛られているのです。
後編 に続きます。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa