Backbone.js で書かれた単純なアプリに仮想 DOM ライブラリの Snabbdom を導入して前後で考えたことをまとめました。
Backbone.js を使い続ける理由
React を中心に仮想 DOM および仮想 DOM を前提としたコンポーネントライブラリ、Redux や RxJS などの状態管理のライブラリなどが大きな注目を集める一方で、これらのライブラリは発展途上にあり、学習コストや作業コストに見合うだけの乗り換えのメリットがすぐに見いだせない可能性があります。
Backbone.js の開発は成熟し、2016年の時点で新しく取り組もうという人はあまり見られませんが、現時点でも評価する点としては、SPA (Single Page Application) 開発のためのルーターを含めた一通りの機能を使うための学習コストの低さ、underscore.js を背景にモデルの充実したメソッド群、(view.listenTo(model, 'change', view.render)
) によるモデルの監視とモデルの変化に応じてビューを更新するイベントシステムが挙げられます。
モデルとビューの関係のありかたの議論について、自分で翻訳した「Passive/Reactive プログラミングの違いを学ぶ」の記事が参考になりました。
逆に2016年の時点で不満に思うことは View オブジェクトの中でイベントハンドラーを書くことができるので、Controller と View の責務があいまいになるもしくは単一責務の原則違反になることが挙げられます。これは React についても当てはまります。
ビューにイベントハンドラーを書くべきではないとする考えについては自分で翻訳した記事の「MVI (Model-View-Intent) と MVC との違い」が参考になりました。
MVC の作者のオリジナルのコンセプトによれば、「ビューはマウスのオペレーションやキーストロークのような、ユーザーの入力は知ってはならない。」とのことです。
Snabbdom を導入する理由
仮想 DOM のメリットはアプリケーションのビュー全体を関数もしくは状態として見なすことができるようになることです。クリックなどのユーザーイベントをもとにビューを更新する際に、仮想 DOM ライブラリは以前つくった仮想ノードと新しい仮想ノードの差分を計算し、DOM 操作を必要最小限に抑えてくれます (Snabbdom の場合、patch
メソッド)。
Snabbdom が提供する機能は仮想 DOM に限定されるので、使うために学ぶ必要のあることは少なく、ソースコードのファイルサイズは小さく、仮想 DOM ライブラリのベンチマークで最速とのことです (2016年3月時点)。この検証によれば、React 0.14 に対して2倍以上の処理速度を示しています。Snabbdom のくわしい機能はこちらの記事をご参照ください。
Backbone.js の View を使い続けながら、導入しやすいことが理由として挙げられます。直接の置き換えの対象になるのはテンプレートです (_.template
メソッド)。
Snabbdom は JavaScript のコミュニティに影響力をもつ開発者に評価されており、2016年の時点で小さいコミュニティながら持続可能性が期待できます。
redux-saga の開発者は Snabbdom のチュートリアル (
React-less Virtual DOM with Snabbdom) を公開し、Cycle.js の開発者は別の仮想 DOM ライブラリの Matt-Esch/virtual-dom から Snabbdom に移行することを検討しています。
また仮想 DOM の技術はゲーム開発の分野で古くから存在しており、安定していることが挙げられます (Reactive MVC and the Virtual DOM)。
検討課題
ES6 (ES2015) への対応
const は主要なブラウザーで対応しているので、ES5 対応にとどめる人も採用してよいと思います。
Backbone.js の場合、ES6 のクラスに対応するかどうかが大きな課題でしょう (Backbone and ES6 Classes Revisited と Lessons Backbone Developers Can Learn From React)。
まずは el
や events
プロパティは get で表現するのか、それとも従来の initialize
もしくは ES2015 の constructor
内で記述するのか。
get el() {
return '#container';
}
get events() {
return {
'click #plus': 'plusClickHandler',
'click #minus': 'minusClickHandler'
};
}
親クラスにコンストラクターの引数を渡したい場合、ES2015 の constructor
および super
を使うことができます。Object.assign
を使えばオブジェクトをマージできます。
constructor(options) {
super(
Object.assign({
el: '#container',
events: {
'click #plus': 'plusClickHandler',
'click #minus': 'minusClickHandler'
}
}, options)
);
}
Backbone.js の開発には CoffeeScript が使われていますが、2016年の時点で CoffeeScript に取り組むメリットを見いだすことはむずかしいでしょう (さよなら CoffeeScript や Moving to ES6 from CoffeeScript )。
モジュールローダーの選択肢
ES5 対応を基準にするのであれば、browserify が手軽でよいでしょう。ES6 対応は babelify と Babel のプリセットを組み合わせます。
hyperscript vs JSX
Snabbdom はデフォルトで仮想 DOM の記述に hyperscript を採用しています。書き方は h('div', 'Hello world')
のようになります。hyperscript をより短く記述するための hyperscript-helpers を組み合わせることができます。書き方は div('Hello World')
のようになります。React のように JSX の構文を使いたい場合、snabbdom-jsx を組み合わせます。
どちらの表記を選ぶのかの評価基準として、変換ライブラリやモジュールローダーの設定・管理、慣れや好み、プログラミングを日常的に書かないデザイナーやサイト管理者の比率などがあるので、メンテナンスする人達と話し合う必要があるでしょう。
HTML から hyperscript への書き換え
hyperscript を採用する場合、hyperscript を書くことに慣れるための練習時間や既存の HTML を hyperscript に書き換えるための時間が必要になります。
HTML から hyperscript の変換にはオンラインサービスの http://html-to-hyperscript.paqmind.com/ を使うことができます。このサービスは変換ツールの html-to-hyperscript のデモサイトも兼ねています。
脱 jQuery の方針
画面の構成要素をクラスやコンポーネントと呼ばれるオブジェクト単位で管理する場合、複雑なクエリセレクターはあまり使わなくなってくるので、jQuery をなるべく使わない方針をとることが考えられます。Backbone.js の Wiki には脱 jQuery のための取り組みがまとめられています (Using Backbone without jQuery)。ちょっとした DOM 操作であれば、次のようなショートカットを用意するとよいでしょう。
// http://stackoverflow.com/a/14947838/531320
const query = document.querySelector.bind(document);
const queryAll = document.querySelectorAll.bind(document);
const fromId = document.getElementById.bind(document);
const fromClass = document.getElementsByClassName.bind(document);
const fromTag = document.getElementsByTagName.bind(document);
Ajax 通信に関して、特別なこだわりがなければ標準の Fetch API を使うとよいでしょう。github で polyfill が配布されています。
セットアップ
パッケージ
npm で jQuery、Backbone.js、Snabbdom をダウンロードします。
npm install --save jquery backbone snabbdom
hyperscript をより短く表記するために hyperscript-helpers を導入する選択肢があります。
npm install --save hyperscript-helpers
JSX の構文を使うのであれば、snabbdom-jsx を導入します。
npm install --save snabbdom-jsx
browserify
node.js で採用されている require をブラウザーでも利用するには browserify による変換が必要です。まずはグローバルなコマンドツールとしてインストールします。
npm install -g browserify
ソースコードの app.js を変換した結果を bundle.js として保存するには次のように引数を指定します。
browserify app.js -o bundle.js
ES6 (ES2015) への対応には babelify を導入します。
npm install --save-dev babelify
Babel のプリセットを導入します。
npm install --save-dev babelify babel-preset-es2015
Babel のトランスパイルを実行するには次のコマンドを実行します。
browserify app.js -o bundle.js -t [ babelify --presets [ es2015 ] ]
コードの例
HTML
コードの例では次の HTML を前提とします。
<html>
<body>
<div id="container"></div>
<script src="bundle.js"></script>
</body>
</html>
Hello World
ES5 + const 対応版は次のとおりです。
var Backbone = require('backbone');
var snabbdom = require('snabbdom');
var patch = snabbdom.init([
require('snabbdom/modules/class'),
require('snabbdom/modules/props'),
require('snabbdom/modules/style'),
require('snabbdom/modules/eventlisteners'),
]);
var h = require('snabbdom/h');
var { div, p } = require('hyperscript-helpers')(h);
var AppView = Backbone.View.extend({
initialize(options) {
this.oldVnode = document.getElementById('container');
this.render();
},
createVnode() {
return div([
p('Hello World')
]);
},
render() {
var newVnode = this.createVnode();
this.oldVnode = patch(this.oldVnode, newVnode);
}
});
new AppView();
ES6 版は次のとおりです。
import Backbone from 'backbone';
import snabbdom from 'snabbdom';
import snabbdomClass from 'snabbdom/modules/class';
import snabbdomProps from 'snabbdom/modules/props';
import snabbdomStyle from 'snabbdom/modules/style';
import snabbdomEventlisteners from 'snabbdom/modules/eventlisteners';
import h from 'snabbdom/h';
import hyperscriptHelpers from 'hyperscript-helpers';
const { div, p } = hyperscriptHelpers(h);
const patch = snabbdom.init([
snabbdomClass,
snabbdomProps,
snabbdomStyle,
snabbdomEventlisteners,
]);
class AppView extends Backbone.View {
initialize(options) {
this.oldVnode = document.getElementById('container');
this.render();
}
createVnode() {
return div([
p('Hello World')
]);
}
render() {
var newVnode = this.createVnode();
this.oldVnode = patch(this.oldVnode, newVnode);
}
}
new AppView();
クリックカウンター
仮想 DOM のイベントハンドラーは Backbone.View
の events
プロパティから指定できなかったので、addEventListener
で指定することにしました。
ES5 + const 対応版は次のとおりです。
const Backbone = require('backbone');
const snabbdom = require('snabbdom');
const patch = snabbdom.init([
require('snabbdom/modules/class'),
require('snabbdom/modules/props'),
require('snabbdom/modules/style'),
require('snabbdom/modules/eventlisteners'),
]);
const h = require('snabbdom/h');
const { div, p, button } = require('hyperscript-helpers')(h);
var AppView = Backbone.View.extend({
initialize(options) {
this.oldVnode = document.getElementById('container');
this.listenTo(this.model, 'change', this.render);
this.render();
},
createVnode() {
return div([
p([String(this.model.get('value'))]),
button('#plus', 'プラス'),
p([' ']),
button('#minus', 'マイナス'),
]);
},
render() {
var newVnode = this.createVnode();
this.oldVnode = patch(this.oldVnode, newVnode);
}
});
var AppModel = new Backbone.Model({value: 0});
new AppView({model: AppModel});
// http://stackoverflow.com/a/14947838/531320
const fromId = document.getElementById.bind(document);
fromId('plus').addEventListener('click', function() {
AppModel.set({'value': AppModel.get('value') + 1 });
});
fromId('minus').addEventListener('click', function() {
AppModel.set({'value': AppModel.get('value') - 1 });
});
ES6 対応版は次のとおりです。
import Backbone from 'backbone';
import snabbdom from 'snabbdom';
import snabbdomClass from 'snabbdom/modules/class';
import snabbdomProps from 'snabbdom/modules/props';
import snabbdomStyle from 'snabbdom/modules/style';
import snabbdomEventlisteners from 'snabbdom/modules/eventlisteners';
import h from 'snabbdom/h';
import hyperscriptHelpers from 'hyperscript-helpers';
const { div, p, button } = hyperscriptHelpers(h);
const patch = snabbdom.init([
snabbdomClass,
snabbdomProps,
snabbdomStyle,
snabbdomEventlisteners,
]);
class AppView extends Backbone.View {
initialize(options) {
this.oldVnode = document.getElementById('container');
this.listenTo(this.model, 'change', this.render);
this.render();
}
createVnode() {
return div([
p([String(this.model.get('value'))]),
button('#plus', 'プラス'),
p([' ']),
button('#minus', 'マイナス'),
]);
}
render() {
const newVnode = this.createVnode();
this.oldVnode = patch(this.oldVnode, newVnode);
}
}
var AppModel = new Backbone.Model({value: 0});
new AppView({model: AppModel});
// http://stackoverflow.com/a/14947838/531320
const fromId = document.getElementById.bind(document);
fromId('plus').addEventListener('click', function() {
AppModel.set({'value': AppModel.get('value') + 1 });
});
fromId('minus').addEventListener('click', function() {
AppModel.set({'value': AppModel.get('value') - 1 });
});