POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

Alexander Schepanovski

本記事は、原著者の許諾のもとに翻訳・掲載しております。

「Reactが素晴らしい理由は、UIをアプリケーションの状態の純粋関数にできるからだ」いうような話を聞いたことがあるでしょう。しかしそれだけではなく、不変性と仮装DOMを利用して動作するということも聞きますよね。その上、保存、読み込み、取り消し、それにタイムトラベル・デバッグと呼ばれるすごい機能まで自由に手に入れられる。でも知っていますか? Reactの核となるアイデアを利用し、その恩恵に預かるのにこれらのことは必要ありません。jQueryの数行にしてお見せします。

<span id="colored-counter">0</span>
<input id="color"></input>
<button id="inc"></button>

<script>
$('#color').on('keyup', function () {
    $('#colored-counter').css('color', this.value);
})

$('#inc').on('click', function () {
    var oldValue = $('#colored-counter').html();
    var newValue = 1 + Number(oldValue);
    $('#colored-counter').html(newValue);
})
</script>

これを以下のように書き直します。

<span id="colored-counter">0</span>
<input id="color"></input>
<button id="inc"></button>

<script>
var state = {color: '', value: 0};

function updateUI() {
    $('#colored-counter').css('color', state.color);
    $('#colored-counter').html(state.value);
}

$('#color').on('keyup', function () {
    state.color = this.value;
    updateUI();
})

$('#inc').on('click', function () {
    state.value++;
    updateUI();
})
</script>

最もシンプルな(そして簡略化された)Reactの概念が提示されていますね。それについてはまた後で書くとして、今は、こっちが優れている点は何かという疑問に答えようと思います。では、図解します。最初のバージョンはこのような仕組みになっています。


2つ目はこうです。

とりたててこちらがいいようにも思えませんが、これをスケーリングすることを考えてみましょう。時間が経てばUI上でより多くのイベントや要素が発生します。イベントは複数の要素を更新し、要素は複数のイベントによって更新されます。それを表すと下記のようになります。

簡略化したReactライクなコードではこうなります。

最初の図ではN個のイベントとM個の要素、そして最大O(N M)になるリンクがあります。2つ目ではN個のイベントリンクが1つと、M個要素のリンクだけです。全体的に見れば、最大でO(N M)の複雑性をO(N+M)まで下げることができます。単純な仕掛けの割には悪くありません。

さあ一歩前進です。しかし魔法のようなパワーについてはどうでしょう。それらももうすぐゲットできますよ。

魔法のパワー

基本的に上述した全ての魔法のパワーはこのように書くことで得られます。

// 保存
var cannedState = deepcopy(state);

// 読み込み
state = cannedState;
updateUI();

2つの事実がこれを支えています。

  • 明確な状態が1つ存在する。
  • UIを任意の状態に従って更新することができる。

これだけでいいのです。シリアライズされたcanned stateをローカルストレージに書き込み、サーバに送信することによりcanned stateの履歴を管理することができます。これにより、ページのリロードの保護や、持続性、取り消しやタイムトラベルがそれぞれ可能になります。

ややこしいですか? コードを書いてみましょう。偶発的なページのリロードから守ってくれる方法です。

function updateUI() {
    // ローカルストレージに最新の状態を保存
    LocalStorage.set('state', JSON.stringify(state));
    // ... 以降はそのまま
}

// ページが読み込まれたら、ローカルストレージから状態を読み込む
$(function () {
    state = JSON.parse(LocalStorage.get('state'));
    updateUI();
});

こっちはタイムトラベル・デバッグのやり方です。

<span id="time-pos"></span>
<button id="back">Back</button>
<button id="next">Next</button>

<script>
var time = {history: [], pos: 0};

function updateTimeUI() {
    $('#time-pos').html('Position ' + time.pos + ' of ' + time.history.length);
}

function saveState() {
    time.history.push(deepcopy(state));
    time.pos++;
    updateTimeUI();
}

$('#back').on('click', function () {
    // history上の位置を移動
    time.pos--;
    updateTimeUI();
    // historyから読んだ状態を読み出す
    state = deepcopy(time.history[time.pos]);
    updateUI();
})
$('#next').on('click', function () {
    time.pos++;
    // ... 同様
})

// ...

function updateUI() {
    // Save state to history on every change
    saveState();
    // ... 以降はそのまま
}
</script>

状態の変更があるごとに、そのディープコピーを履歴リストに追加します。そうすれば単に履歴からコピーし、それに合わせてUIを更新することにより状態の復元ができます。もう少し詳しく説明するため、 クリックして試せるデモ を作成しました。

ここでも同じパターンが使われていますね。 time はタイムトラベル・サブアプリケーションの状態を表し、 updateTimeUI() はその更新のための関数です。

さて、これでビルドできます。取り消し(undo)は開発者向けではなくユーザ向けのタイムトラベルの機能です。履歴をローカルストレージに保存することにより、ページのリロードごとに保管することができます。エラーを補足してシリアライズされた履歴とともにバグ管理システムに送信すれば、自動的にユーザが直面するエラーを再現できるのです。

Reactがこの全ての魔法のパワーを備えているわけではありません。少なくとも設定なしの状態では無理です。なぜならReactはアプリケーションの状態を細かく分断し、コンポーネントの中に隠すからです。

純粋関数

何故純粋関数や不変データ、そして仮想DOMが必要なのでしょうか。これらは最適化の手法であり、ある意味において簡素化でもありますが、アイデアの核ではありません。そうは言ってももう少し見ていきましょう。

最初にもう少し複雑な例が必要です。

<span id="count">2</span>
<ul>
    <li>hi</li>
    <li>there</li>
</ul>
<button id="add"></button>
<script>
var state = {items: ['hi', 'there']}

function updateUI() {
    $('#count').html(state.items.length);
    // ul.childNodesとstate.itemsを比較して更新
    // ...
}

$('ul').on('click', 'li', function () {
    state.items.splice($(this).index(), 1);
    updateUI();
})

$('#add').on('click', function () {
    state.items.push(getNextString());
    updateUI();
})
</script>

いくつかの点に注目してください。

  • プリレンダされたhtmlと初期状態の間に重複があります。
  • データ構造をDOMと比較しなくてはならないため、アップデートは複雑になっています。

これをもっとシンプルにやる方法があります。

<div id="ui"></div>
...

<script>
...

function render(state) {
    var span = '<span id="count">' + state.items.length + '</span>';
    var lis = state.items.map(function (item) {
        return '<li>' + item + '</li>';
    });
    return span + '<ul>' + lis.join('') + '</ul>'
}

function updateUI() {
    $('#ui').html(render(state));
}

...
</script>

ここでは render() はただのアプリケーションの状態からhtmlまでの純粋関数です。これが純粋関数でなくてはならないのは、状態のみがUIを定義するただ1つのものだからです。

この簡略化されたコードの中でさえ、ある意味UIは状態を表す関数でしたが、それほど明確というわけではありませんでした。なぜなら、毎回UIを計算し白紙の状態から再構築したわけではなく、更新したためです。また、 updateUI() と記述して台無しにする可能性もありました。その場合、UIの状態がそれ自身の前の状態とアプリケーションの状態の両方によって定義されるため、レンダリングは実際には最適化ではなく、単純化やガードとなるのです。

とりあえず、純粋関数については以上で終わりです。

仮想DOM

ここで、 render() / $().html() のペアをご覧ください。イベント毎に、恐らくキーを打つごとに全体の表現を一から構築しています。何だか重そうですね。そこで別の最適化を使ってみましょう。それが仮想DOMです。

var root = document.getElementById('ui');
var prevState = state, prevTree = [];

function render(state) {
    // 仮想DOMはただのJavaScriptのオブジェクトや配列のツリー
    return [
        ['span', {id: 'count'}, state.items.length],
        ['ul', {}, state.items.map(function (item) {
            return  ['li', {}, item]
        })]
    ]
}

function updateUI() {
    var vTree = render(state);
    var diff = vDiff(prevTree, vTree); // ただ単に差分を取っているだけです :)
    vApply(root, diff)                 // 一連の差分をDOMに適用

    prevState = deepcopy(state);
    prevTree = vTree;
}

このdiff / patchが複雑に思えるようでも、Reactや スタンドアロンのvirtual-dom実装 がカバーしてくれるでしょうから心配いりません。ただ、実際にはそれほど複雑ではありません。差分(diff)を算出するアルゴリズムが書けるようであれば、ほぼ間違いなく自分でこれを実装できるはずです。

ちなみに、先ほどやった最適化は不完全ではありますが、現状のシンプルな例は、上記の単純なrender-to-Stringの実装を問題なくこなせています。驚くべき事ですが、世間に出回っているほとんどのSPA(シングルページアプリケーション)でも同様にうまく処理することが可能です。最近はブラウザも高速ですからね。ここでもう一度強調しておきますが、平均的なアプリケーションであれば、初動期にはReactやその他の仮想DOMについてはスルーしても大丈夫です。動作が重くなってから目を向けても遅くはありません(そうでなければ、そのままスルーで大丈夫です)。

Reactの進行方向にあるもう1つの障害、それは仮想DOMがブロックの中で 最も遅いものの1つ だということです。

不変性

deepcopy() のコールが手つかずのままの状態であることに気付きましたか?一見するとゴミですね(些末な話ですが、もう少しお付き合いください)。不変データ構造の背後にある考え方は、以前の状態をコピーしてからそれを変えるのではなく、以前の状態に基づいて新しい状態を構築するということです。

少し具体性が足りないので、JSで不変オブジェクトを作成する方法をお見せします。

var object = {
    a: {x: 1, y: 2},
    b: {text: 'hi'}
}

// object.a.y = 3 とする代わりに
var object2 = {
    a: {x: object.a.x, y: 3},
    b: object.b
}

object.a.xobject.b を再利用していますよね。これによりコピーしなくても、より短い時間で効果的に差分を作成できるようになります。つまり、 object.bobject2.b の差分を抽出する前に、それらが同じオブジェクトであるかをまずチェックするのです(参照の等価性)。実行後、差分がemptyであれば最後までやる必要はありません。

上記に1つ付け加えることがあるとすれば、不変コレクションに対しては、この単純なアプローチは面倒なだけでなく、効果もないということです。多くのキーを持つオブジェクトがあると仮定してみてください。そのうちの1つの値が変わった場合、同じキーを持つ新たなオブジェクトを作らなければならなくなりますよね(値の再利用は可能ですが)。きっとそんなオブジェクトは使わないと思います。ただ、配列についてはどうでしょうか。どうなるか見てみましょう。

var prev = ['a', 'b', 'c', ..., 'z'];
var next = prev.slice(0, n-1).concat([newValue]).concat(prev.slice(n));

全てをコピーしなければなりませんね。でも、以下のようにすれば不変のシーケンスをもっといい形で実装できるようになります。

var prev = {
    '0-3': {
        '0-1': {0: 'a', 1: 'b'},
        '2-3': {...},
    },
    '4-7': {...}
}
var next = {
    '0-3': {
        '0-1': prev['0-3']['0-1'],
        '2-3': {
            2: 'hey',
            3: prev['0-3']['2-3'][3]
        }
    },
    '4-7': prev['4-7']

ここでは log N のオブジェクトを新規に作成したのみで、残りは再利用です。コピーの頻度はさらに減っていますし、差分もより高速になっています。また全てのツリーに対しても実際には対処する必要はありません。なお、すぐに使えるすばらしい実装があります。例:以下は Immutable.js を使うことで、最初に挙げた例がどう変わるかを示したものです。

var object = Immutable.fromJS({
    a: {x: 1, y: 2},
    b: {text: 'hi'}
})

var object2 = object.setIn(['a', 'y'], 3); // objectは変更されていない

効率的ですばらしいAPI、そして不変コレクションへの誤った書き込みに対する保護が提供されています。次に、ClojureScriptから抽出された不変コレクションのセットである mori も見てみましょう。

ちなみに実際のところ、Reactは既存のコレクションによる単純な不変を使っています。

まとめ

ここまでの私の意見からReactがそんなにいいツールではないと思われるかもしれませんが、そんなことはなく実際とても便利なツールです。魔法のパワーや最高の速さはないかもしれませんが、それに代わるコンポーネントがありますし、速度も比較的速いと言えるでしょう。それにエコシステムやコミュニティもあります。

さらに別の角度からも見ることが可能です。それは何かというと、Reactがフロントエンド開発を仮想DOMや不変コレクションライブラリなどといった面白い方向に大きく後押ししているということです。

いずれにしても、ここで紹介した内容を通じて、どんなツールでどのようにコードを構築するかを判断する際のお力になれれば幸いです。