あなたが開発したのがjQueryを使ったごくシンプルなアプリに過ぎなくても、UIのさまざまなパーツの同期を維持する問題に直面したことがあるはずです。たいていの場合、データを変更すると複数の箇所に反映する必要があり、アプリの規模が大きくなるにつれて対応が困難になります。この問題にうまく対処するには、イベントを利用して、アプリのさまざまなパーツに対して、変更があったことを知らせるのが一般的です。
それでは現在、多くの人はアプリケーションのステートをどのように管理しているのでしょうか。
「ステート」とはいったい何者なのか?
ある人物がこう言っています。「こんにちは、ぼくだよ! ぼくにはfirstName、lastName、ageがあるんだ。それに、困ったときに来てくれるfullName()関数もあるんだ」
var person = {
firstName: 'Matt',
lastName: 'Ruby',
age: 37,
fullName: function () {
this.firstName + ' ' + this.lastName;
}
};
どのようにして、その人物に変更に関するさまざまな出力(ビュー、サーバー、デバッグログ)を通知しますか? また、いつ通知を出しますか? MobXの前に、セッターを用いてカスタムのjQuery eventsやjs-signalsを使いたいと思います。これらの選択はうまく動作しますが、決して粒度が高いとは言えません。personオブジェクトのどこかのパーツが変更されると、1つの「changed」イベントを動作させます。
自分のファーストネームを表示するビューコードの部品があったとします。年齢を変更すると、そのビューはpersonのchangedイベントと連動して更新されます。
person.events = {};
person.setData = function (data) {
$.extend(person, data);
$(person.events).trigger('changed');
};
$(person.events).on('changed', function () {
console.log('first name: ' + person.firstName);
});
person.setData({age: 38});
どのようにして変更を知らせればよいのでしょうか? 簡単です。各フィールドにセッターを保持し、変更ごとにイベントを分ければよいのです。ただし、ageとfirstNameをいっぺんに変更したい場合は、開始を少し待ってください。双方の変更が完了するまでイベントの開始を遅らせる方法を考える必要があります。面倒で気が進みませんが…
MobXによる救援
MobXはMichel Weststrateによって開発されたシンプルで、明確で、高性能で、邪魔にならないステート管理ライブラリーです。
MobXのドキュメントには以下のように書かれています。
ステートに少し対処するだけで、MobXはアプリの変更を尊重します。
var person = mobx.observable({
firstName: 'Matt',
lastName: 'Ruby',
age: 37,
fullName: function () {
this.firstName + ' ' + this.lastName;
}
});
違いが分かりますか? mobx.observableが唯一の変更点です。console.logの例を再び説明しましょう。
mobx.autorun(function () {
console.log('first name: ' + person.firstName);
});
person.age = 38; // prints nothing
person.lastName = 'RUBY!'; // still nothing
person.firstName = 'Matthew!'; // that one fired
autorunを使用して、MobXはアクセスされたものを監視するのみです。もし気に入ったら、以下をチェックしてみてください。
mobx.autorun(function () {
console.log('Full name: ' + person.fullName);
});
person.age = 38; // print's nothing
person.lastName = 'RUBY!'; // Fires
person.firstName = 'Matthew!'; // Also fires
興味がわいてきましたか? そうでしょう。
MobXの主要なコンセプト
observable
var log = function(data) {
$('#output').append('<pre>' +data+ '</pre>');
}
var person = mobx.observable({
firstName: 'Matt',
lastName: 'Ruby',
age: 34
});
log(person.firstName);
person.firstName = 'Mike';
log(person.firstName);
person.firstName = 'Lissy';
log(person.firstName);
MobXのobservableオブジェクトはただのオブジェクトです。 この例ではなにも監視していません。MobXの既存コードベースへの組み込みを開始する方法を示しています。mobx.observable()やmobx.extendObservable()を使用するだけで開始できます。
autorun
var person = mobx.observable({
firstName: 'Matt',
lastName: 'Ruby',
age: 0
});
mobx.autorun(function () {
log(person.firstName + ' ' + person.age);
});
// this will print Matt NN 10 times
_.times(10, function () {
person.age = _.random(40);
});
// this will print nothing
_.times(10, function () {
person.lastName = _.random(40);
});
observableの値が変化するとなんとかしたくなりませんか? そこでautorun()の出番です。 autorun()を使用すると参照されたobservableが変化するたびにコールバックを開始できます。上の例ではageが変更されてもautorun()が開始されないことに注意してください。
自動計算
var person = mobx.observable({
firstName: 'Matt',
lastName: 'Ruby',
age: 0,
get fullName () {
return this.firstName + ' ' + this.lastName;
}
});
log(person.fullName);
person.firstName = 'Mike';
log(person.fullName);
person.firstName = 'Lissy';
log(person.fullName);
fullName関数を見ると、パラメータもgetも取得していないことに気づきましたか? MobXは自動的にユーザーに計算済みの値を生成します。これは私のお気に入りのMobXの機能の1つです。person.fullNameについてなにかおかしなことに気づきませんか? もう1度見てください。person.fullNameは関数で、呼び出さなくても結果が分かるのです! 通常はperson.fullNameではなくperson.fullName()を呼び出します。これがはじめてのJS getterとの出会いです。
お楽しみはこれからです! MobXは計算された値の依存オブジェクトの変化を監視し、変化したときのみ実行されます。変更がない場合はキャッシュされた値が返されます。以下の例を参照してください。
var person = mobx.observable({
firstName: 'Matt',
lastName: 'Ruby',
age: 0,
get fullName () {
// Note how this computed value is cached.
// We only hit this function 3 times.
log('-- hit fullName --');
return this.firstName + ' ' + this.lastName;
}
});
mobx.autorun(function () {
log(person.fullName + ' ' + person.age);
});
// this will print Matt Ruby NN 10 times
_.times(10, function () {
person.age = _.random(40);
});
person.firstName = 'Mike';
person.firstName = 'Lissy';
ここで何度もperson.fullNameの計算された値がヒットしていることが分かりますが、その関数が実行されるのはfirstNameまたはlastNameのどちらかが変更されるときだけです。これはMobXがアプリケーションの速度を大きく改善する方法の1つです。
詳細情報
MobXのすばらしいドキュメントをもうこれ以上書き直すことはしません。observableとの連携やその作成方法に関してはドキュメントを参照してください。
MobXの組み込み
退屈になる前に、なにかを作成してみましょう。
MobXを使わずに作成したシンプルな人物の例で、人物に変更があるたびにフルネームを出力します。
ファーストネームやラストネームの変更がなくても、10回も名前がレンダリングされていることに注意してください。たくさんのイベントを使用するか、変更されたペイロードをいくつかチェックすると最適化できます。しかし、この方法は大変面倒です。
以下がMobXを使用して作成した同じ例です。
events、trigger、onがないことに注意してください。MobXを使って最新の値や変更された内容とやり取りをします。1度レンダリングされただけだと気づきましたか? なぜなら、autorunが監視しているものになにも変更がなかったためです。
それでは、ちょっとしたものを作成してみましょう。
// observable person
var person = mobx.observable({
firstName: 'Matt',
lastName: 'Ruby',
age: 37
});
// reduce the person to simple html
var printObject = function(objectToPrint) {
return _.reduce(objectToPrint, function(result, value, key) {
result += key + ': ' + value + '<br/>';
return result;
}, '');
};
// print out the person anytime there's a change
mobx.autorun(function(){
$('#person').html(printObject(person));
});
// watch all the input for changes and update the person
// object accordingly.
$('input').on('keyup', function(event) {
person[event.target.name] = $(this).val();
});
ここではpersonオブジェクト全体が編集可能で、自動的にデータの出力を監視できます。例は入力値がpersonオブジェクトと同期していないなど弱点がいくつか存在します。以下のように修正します。
mobx.autorun(function(){
$('#person').html(printObject(person));
// update the input values
_.forIn(person, function(value, key) {
$('input[name="'+key+'"]').val(value);
});
});
もう1つ不満があることは分かっています。「Ruby、君はレンダリングし過ぎだよ!」ごもっともです。これが多くの人びとがReactを使用する選択する理由です。Reactを使うと出力を簡単に小さいコンポーネントに分解でき、個別のレンダリングができるようになるからです。
万全を期すために、最適化したjQueryの例を参照してください。
実際のアプリでこのようなことができるでしょうか? おそらくできません。このレベルの粒度が必要になった場合はReactを使用するでしょう。実際のアプリケーションでMobXやjQueryを利用した際にはautorun()を使いました。十分に粒度が高く、変更のたびにDOM全体をリビルドしなくて済むからです。
ここまでできたら、ReactとMobXを使ってビルドした同じ例も参照してください。
スライドショーをビルドしよう
どうやってスライドショーのステートを示しますか? 以下のように個別のスライドファクトリを使用して開始します。
var slideModelFactory = function (text, active) {
// id is not observable
var slide = {
id: _.uniqueId('slide_')
};
return mobx.extendObservable(slide, {
// observable fields
active: active || false,
imageText: text,
// computed
get imageMain() {
return 'https://placeholdit.imgix.net/~text?txtsize=33&txt=' + slide.imageText + '&w=350&h=150';
},
get imageThumb() {
return 'https://placeholdit.imgix.net/~text?txtsize=22&txt=' + slide.imageText + '&w=400&h=50';
}
});
};
すべてのスライドを集約する必要があります。ここで作成しましょう。
var slideShowModelFactory = function (slides) {
return mobx.observable({
// observable
slides: _.map(slides, function (slide) {
return slideModelFactory(slide.text, slide.active);
}),
// computed
get activeSlide() {
return _.find(this.slides, {
active: true
});
}
});
};
スライドショーを実行します! ここが一番おもしろいところです。なぜなら、コレクションからスライドを追加したり削除したりできるobservableのslides配列を保持しており、それに応じてUIを更新するからです。次に、必要に応じて自身を維持するactiveSlideの計算済みの値を追加します。
それではスライドショーをレンダリングします。まだHTML出力の準備ができていないので、コンソールへの出力のみです。
var slideShowModel = slideShowModelFactory([
{
text: 'Heloo!',
active: true
}, {
text: 'Cool!'
}, {
text: 'MobX!'
}
]);
// this will output our data to the console
mobx.autorun(function () {
_.forEach(slideShowModel.slides, function(slide) {
console.log(slide.imageText + ' active: ' + slide.active);
});
});
// Console outputs:
// Heloo! active: true
// Cool! active: false
// MobX! active: false
すばらしいですね。数枚のスライドがあり、autorunが現在の値を出力したところです。ここで、1~2枚ほどスライドを変更してみます。
slideShowModel.slides[1].imageText = 'Super cool!';
// Console outputs:
// Heloo! active: true
// Super cool! active: false
// MobX! active: false
autorunが動作しています。autorunが監視しているものを変更した場合に実行されます。ここで、出力先をコンソールからHTMLへと変更します。
var $slideShowContainer = $('#slideShow');
mobx.autorun(function () {
var html = '<div class="mainImage"><img src="'
+ slideShowModel.activeSlide.imageMain
+ '"/></div>';
html += '<div id="slides">';
_.forEach(slideShowModel.slides, function (slide) {
html += '<div class="slide ' + (slide.active ? ' active' : '')
+ '" data-slide-id="' + slide.id + '">';
html += '<img src="' + slide.imageThumb + '"/>'
html += '</div>';
});
html += '</div>';
$slideShowContainer.html(html);
});
すでにスライドショー表示の基礎は準備できていますが、まだインタラクティブではありません。サムネイルをクリックしたりメインイメージを変更したりはできません。しかし、コンソールを使って簡単に、イメージテキストを変更したり、スライドを追加したりできます。
// add a new slide
slideShowModel.slides.push(slideModelFactory('TEST'));
// change an existing slide's text
slideShowModel.slides[1].imageText = 'Super cool!';
選択されたスライドを設定するために最初で唯一のアクションを生成します。以下のアクションを追加してslideShowModelFactoryを修正する必要があります。
// action
setActiveSlide: mobx.action('set active slide', function (slideId) {
// deactivate the current slide
this.activeSlide.active = false;
// set the next slide as active
_.find(this.slides, {id: slideId}).active = true;
})
なぜactionを使うのでしょうか? とても良い質問です! observableの値の変更についてのほかの例で説明した通り、MobXのアクションは必須ではありません。
アクションには役立ついくつかの方法があります。まず、MobXのアクションはすべてトランザクションで実行されるということです。つまり、autorunおよびほかのMobXリアクションは開始する前に、アクションが終了するのを待機します。それについて少し考えてみてください。もしトランザクション外でアクティブなスライドを非アクティブにして、次の1枚をアクティブにしようとしたらなにが起こるでしょうか? autorunは2度実行されてしまうかもしれません。表示されるアクティブなスライドがないので、1度目の実行は非常に厄介かもしれません。
トランザクションの本質に加えて、MobXのアクションはより簡単にデバッグする傾向があります。mobx.actionへ渡す最初のオプションのパラメーターは'set active slide'という文字列です。MobXのデバッグ用APIを使用してこの文字列が出力されることがあります。
アクションが用意できたので、jQueryを使用して使えるように配置します。
$slideShowContainer.on('click', '.slide', function () {
slideShowModel.setActiveSlide($(this).data('slideId'));
});
作業は以上です。サムネイルをクリックすると、期待通りにアクティブなステートが伝搬します。以下がスライドショーの動作例になります。
同じスライドショーのReactの例です。
モデルをまったく変更していないことに気づきましたか? MobXが関与する限り、jQueryやコンソールと同様に、Reactはもう1つの派生データであるに過ぎません。
jQueryスライドショーの例に関する注意点
jQueryの例はまったく最適化されてないことに注意してください。変更のたびにスライドショーのDOM全体を上書きしています。つまり上書きによって、クリックするたびにスライドショーのHTMLすべてが置き換わるということです。堅牢なjQueryベースのスライドショーを作成するなら、おそらく最初のレンダリング後にアクティブなクラスを設定や削除をしたり、mainImageの<img>のsrc属性を変更したりして、DOMを微調整することになります。
詳細情報
MobXについてもっと詳細を知りたい場合は、以下の便利なリソースを参照してください。
- MobXのブログ、チュートリアルおよびビデオ
- Egghead.io のコース: Manage Complex State in React Apps with MobX
- Practical React with MobX
- シンプルなMobXのサンプル
- MobX APIリファレンス
※本記事はMichel Weststrate、Aaron Boyerが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:How to Manage Your JavaScript Application State with MobX)
[翻訳:市川千枝/編集:Livit]