NHN Cloud Meetup 編集部
0.7KBでVueのようなリアクティブシステムを作成する
2019.08.28
672
TOAST UI Gridは、現在、新しいメジャーアップデートであるバージョン4をリリースするために懸命に準備しています。バージョン4は、BackboneとjQueryを使って作成した既存のコードをすべて捨てて、最初から新しく作成する大々的な改編作業です。今回のアップデートによって、不要な依存性を削減して、はるかに軽くて速いグリッドになることを期待しています。
バージョン4は、BackboneとjQueryの代わりに、直接作成したリアクティブの状態管理やPreactを基盤に作成しました。バージョン4の初回アルファ配布を記念し、この記事では、Backboneのようなイベント基盤の状態管理方式と、Vue、MobXのようなリアクティブの方法がどのように異なるのか、なぜリアクティブの状態管理を直接作成するのか、リアクティブシステムを実装するために考慮すべき点など、実際のソースコードを見ながら紹介したいと思います。
リアクティブ(reactivity)システムとは?
リアクティブという単語は、様々な場面で汎用的に使用されているので、まず用語を明確に定義する必要があるようです。この記事で言うリアクティブシステムとは、VueやMobXで使用される方法のことで、オブジェクトの状態変更を自動検出し、そのオブジェクトを使用している他のオブジェクトの状態を変更したり、関連するビュー(View)を自動で更新するシステムのことを指します。つまり、既存のイベント基盤方式で、すべての状態変更に対してイベントを発生させ、変更を検出するため当該イベントのリスナーを登録していた作業が自動で行われます。
このような方式は、Backbone以降に登場したほとんどのフレームワークでサポートされている方法です。AngularJSが最初に注目されたときは、データバインディングという用語が一般的に使用されました。しかし最近は、Vueでリアクティブ(reactivity)という単語が公式的に使用され、今ではVueを代表する特徴的な用語となっています。そのため、Vueで使用される特定の実装方式と関連して使用されることも多いです。Vueはリアクティブを実装するため、getter/setterを使ってプロキシをかける方式を使用しており、現在開発中のVue 3では、ES2015のProxyを直接使用して実装される予定です。
MobXはVueのリアクティブシステムとほぼ同じ方法を使用しており、バージョン4まではgetter/setterを、バージョン5からはProxyを使って実装されているため、ブラウザのサポート範囲に基づいて適切なバージョンを選択して使用できます。ただしMobXは、状態管理用のライブラリのため、UIを表現するには、Reactのような別途フレームワークを一緒に使用する必要があります。
イベント方式vsリアクティブ方式
簡単な例を参考に、リアクティブ方式が既存のイベント基盤の方式と比べて、どのようなメリットがあるのか確認しましょう。イベント方式はBackboneのModelを、リアクティブ方式はMobXのobservableを使用します。概念の説明を目的としているので、APIに慣れていなくても、ソースコードを読むことは全く問題ないでしょう。
ゲームプレイヤーA、Bがあり、この2つのプレイヤーのスコア合計を表すボードがあるとします。各プレイヤーのスコアが変更されると、ボードの合計も一緒に変わらなければなりません。この機能をBackBoneを使って実装します。
import {Model} from 'BackBone'; const playerA = new Model({ name: 'A', score: 10 }); const playerB = new Model({ name: 'B', score: 20 }); const Board = Model.extend({ initialize(attrs, options) { this.playerA = options.playerA; this.playerB = options.playerB; this.listenTo(playerA, 'change:score', this._updateTotalScore); this.listenTo(playerB, 'change:score', this._updateTotalScore); this._updateTotalScore(); }, _updateTotalScore() { this.set('totalScore', this.playerA.get('score') + this.playerB.get('score')); } }); const board = new Board(null, {playerA, playerB}); console.log(board.get('totalScore')); // 30 playerA.set('score', 20); console.log(board.get('totalScore')); // 40 playerB.set('score', 30); console.log(board.get('totalScore')); // 50
Boardクラスを定義するコードで、playerAとplayerBのscoreプロパティが変更されることを検出するため、各オブジェクトのchange:scoreイベントをサブスクライブしています。もし、他のプロパティの変更を検出するには、別途イベントリスナーを作成しなければなりません。
では、リアクティブシステムはどうなるでしょうか?MobXに実装したコードを見てみましょう。
const {observable} = require('mobx'); const playerA = observable({ name: 'A', score: 10 }); const playerB = observable({ name: 'B', score: 20 }); const board = observable({ get totalScore() { return playerA.score + playerB.score; } }) console.log(board.totalScore); // 30 playerA.score = 20; console.log(board.totalScore); // 40 playerB.score = 30; console.log(board.totalScore); // 50
上のコードでは、boardオブジェクトのtotalScoreプロパティの値がgetter関数として定義されていることが分かります。このプロパティの値は、getter関数の内部で参照するすべてのobservable値の変更を検出し、そのうちの1つでも変更されると、getter関数を再実行して、自分の値を更新します。このようなプロパティ値を算出(computed)あるいは派生(derived)と呼び、この特徴がリアクティブシステムの中核と言えます。MobXの公式文書でも設計原則を次のように要約しています。
アプリケーションの状態のうち、派生(derived)できるすべての値は、自動で派生しなければならない。
既存のイベント基盤のシステムでは、通常_updateTotalScore()のようなプロパティ値を変更する関数によって値が決定されます。このような関数が複数に散らばっていると、一目でデータの依存関係を把握するのが難しくなります。一方で派生プロパティは、1つのgetter関数によって値が決定され、通常は別のsetterを持たないため、getter関数の内部だけを見れば、どのような値に影響を受けて変化するか一目で把握できます。
つまり、既存のイベント基盤のシステムが強制型(imperative)であるならば、リアクティブシステムは、データの構造を宣言型(declaritive)として定義することができます。サンプルコードも明確になり、宣言型で定義されたコードは、強制型に比べると、はるかに簡潔、直感的で、データ間の接続関係を一目で把握することができます。
リアクティブシステムを直接実装する理由
TOAST UI Gridは、特定のUIフレームワークに依存しないライブラリです。
MobXは状態管理のみ担当するため、Vueに比べてはるかに小さい容量ですが様々な機能を提供しています。一般オブジェクトだけでなく、配列、マップなど、色々なタイプのオブジェクトをリアクティブオブジェクトにすることができ、多様な方式のオブザーバ関数を提供して、細部操作ができるようにinterceptとobserve関数、非同期アクションなどの機能もサポートしています。それでは、別途リアクティブの状態管理を作らずとも、MobXを使用すればよいのではないでしょうか?
一般的なWebアプリケーションを作成するときは、MobXが素晴らしい選択となります。しかし、TOAST UI GridのようなUIライブラリを作成するときには、外部ライブラリの依存関係、バンドルサイズ、性能などの面から、様々な検討が必要になります。MobXを使用しなかった理由をいくつか紹介します。
1.外部ライブラリの依存関係とバンドルサイズ
TOAST UI Gridバージョン4にアップデートする重要な目的の1つは、既存のコードが持っていた依存性(Backbone、jQuery)を除去することです。ライブラリを使用する立場では、依存性が増えるほど、容量や性能の面で負担がかかるので、可能な限り外部依存を最小化することが望ましいです。
また、MobXのバンドルサイズは、バージョン4.9.4を基準に、最小化されたファイルが、約56KB(Gzip圧縮時16KB)になりました。Backboneの最小化ファイルが約25KB(Gzip圧縮時8KB)であることを勘案すれば、ほぼ2倍以上の容量です。MobXが提供するすべての機能が必要ならば話は違いますが、MobXの一部機能を使用する立場では負担がかかる容量でしかありません。
2.大容量データのパフォーマンス問題
すべての技術がそうであるように、リアクティブシステムも万能ではありません。特にグリッドのように大容量の配列を扱わなければならない場合、リアクティブシステムは、パフォーマンスの面で多くの弱点を持ちます。次のコードを見てみましょう。
import { observable } from 'mobx'; const data = observable({ rawData: [ { firstName: 'Michael', lastName: 'Jackson' }, { firstName: 'Michael', lastName: 'Johnson' } ], get viewData() { return this.rawData.map(({firstName, lastName}) => ({ fullName: `${firstName} ${lastName}` })); } }); console.log(data.viewData[1].fullName); // Michael Jackson data.rawData[1].lastName = 'Bolton'; console.log(data.viewData[1].fullName); // Michael Bolton data.rawData.push({firstName: 'Michael', lastName: 'Jordan'}) console.log(data.viewData[2].fullName); // Michael Jordan
上のコードでdata.viewDataは、data.rawDataが変更される度に更新されます。コードを見ると、data.rawData配列内のオブジェクトのプロパティ値を変更したり、新しい要素を追加するなど、すべての変更に対してdata.viewData
が更新されることが分かります。しかし問題は、すべての変更に対して、毎回、配列全体を巡回しながら新しい配列を作り出すという点です。このとき、配列のサイズが非常に大きければ、パフォーマンスの問題を引き起こしかねません。
例えば、rawData10万件のデータを持っている場合、配列の要素の1つのデータが変更されただけでも、毎回10万回を巡回しながら、新しいviewData
を作成します。このような問題を回避するため、observe関数を使って変更されたタイプに応じて、それぞれ異なる処理が必要です。この場合、宣言的にデータを定義できるリアクティブのメリットが削減され、個々のデータを直接変更するよりも、むしろコードが複雑になることがあります。
また、配列をリアクティブで作成するとき、MobXは(バージョン4基準)すべての配列のインデックスにgetterを使用してプロキシを設定しますが、これもまた無視できない性能低下を引き起こします。試しに開発者PCでテストしたところ、10万件の数値配列に対して約150msの時間がかかり、プロパティを30個以上持っているオブジェクトの配列では、内部オブジェクトをすべてリアクティブで作りながら、全体で10秒以上かかりました。
TOAST UI Gridは、10万件のデータであっても500ms前後の性能を出すことを目標にしているので、MobXのobservableをそのまま使用することは難しい状況でした。配列の一部だけリアクティブで作成したり、データの追加/削除/変更があるかによって、細部操作が必要な場合が多かったからです。このように、性能に敏感なアプリケーションの場合、リアクティブシステムを直接実装することでパフォーマンスの問題をより柔軟に対応できると判断しました。
リアクティブシステムの基礎:getter/setterを理解する
では、本格的にリアクティブの状態管理を作ってみましょう。リアクティブシステムの基本的な原理は意外と簡単で、少量のコードでも使い勝手のよい状態管理ができます。
前述のとおり、リアクティブシステムを作成する方法は2つあります。しかし、ES 2015のProxyは、Internet Explorerなどの古いブラウザでサポートされておらず、トランスコンパイラやPolyfillを用いても完全にサポートすることができません。TOAST UI Gridは、ブラウザの互換性のためにgetter/setterを使用しており、この記事でもgetter/setter方式で説明します。
リアクティブシステムの基本構成単位は、observableオブジェクトとobserve
関数です。observe
関数に引数として渡したオブザーバ(observer)関数を実行すると、内部でアクセスするすべてのobservable
オブジェクトのプロパティに対応するオブザーバ関数をリスナーとして登録しておき、observable
オブジェクトのプロパティの値が変更されると、登録されたオブザーバ関数を再実行します。
ライブラリ毎に名前は若干異なりますが、この記事では、MobXのAPIと類似した名前のobservableとobserveを使用します。なお、RxJSのObservableとは関係がないのでご注意ください。
実装に先立ち、使い方を簡単に説明しましょう。
const player = observable({ name: 'A', score: 10 }); observe(() => { console.log(`${player.name} : ${player.score}`); }); // A : 10 player.name = 'B'; // B : 10 player.score = 20; // B : 20
サンプルコードを見ると、observe関数に引数として渡したオブザーバ関数が最初に一度実行された後、playerオブジェクトのプロパティ値が変更される度に継続して実行されていることが分かります。
この機能を実装するには、各オブジェクトのプロパティ値にアクセスする度、現在のobserve関数が実行中かどうかを先に知る必要があります。まず、observe関数を実装しましょう。
let currentObserver = null; function observe(fn) { currentObserver = fn; fn(); currentObserver = null; }
observeは、引数として渡された関数をモジュールスコープにcurrentObserverという名前で登録して実行します。currentObserverがあるとき、getterが呼び出されると、この関数をオブザーバ配列に登録しておき、setterが呼び出される度に登録されたオブザーバ関数をすべて実行します。ただし、observe
関数内で同じプロパティを何度も参照することもできるので、すでに登録されているオブザーバ関数は、重複して登録しないようにしましょう。
function observable(obj) { Object.keys(obj).forEach((key) => { const propObservers = []; let _value = obj[key]; Object.defineProperty(obj, key, { get() { if (currentObserver && !propObservers.includes(currentObserver)) { propObservers.push(currentObserver); } return _value; }, set(value) { _value = value; propObservers.forEach(observer => observer()); } }); }); return obj; }
ここで注目すべきは、_valueという別の変数を関数スコープに定義して使用している点です。これはsetter関数で、this[key] = valueと同じように値を指定する場合、setter関数が無限に繰り返されることを防ぎます。それ以外は、特別なものがない20行余りのコードですので、誰でも一目でobservable
関数が処理する内容を把握できるでしょう。
今まで実装したコードの動作を図で表すと、このようになります。
もちろん、他にも追加が必要な機能は残っていますが、このコードだけで最初に見たサンプルは問題なく実行できます。observe関数を含めて、全体が30行もありませんが、このコードが実際のリアクティブシステムのコアになっています。
派生(derived)プロパティを実装する
次に派生プロパティの値を実装しましょう。派生プロパティを定義する方法は色々あります。MobXの@computedのようにデコレータを使用したり、Vueのようにcomputedオブジェクトを個別に定義することもできます。この記事では、別途APIがなく、getterを使って定義されたプロパティを派生プロパティとして処理するようにします。まず、使い方を見てみましょう。
const board = observable({ score1: 10, score2: 20, get totalScore() { return this.score1 + this.score2; } }); console.log(board.totalScore); // 30; board.score1 = 20; console.log(board.totalScore); // 40;
board.totalScore値をgetterを使って定義し、board.score1とboard.score2
が変更されると、自動的に計算されて割り当てられます。
基本的な動作は、先に作成したobserveと同じです。内部的にobserve関数を実行して、当該プロパティ値を毎回更新するだけです。そのためには、まずオブジェクトのプロパティを巡回するとき、当該プロパティにgetterが設定されているかどうかを確認する必要があり、Object.getOwnPropertyDescriptorが返却するオブジェクトのgetプロパティを取得するとよいでしょう。
const getter = Object.getOwnPropertyDescriptor(obj, key).get;
定義されたgetterがある場合は、setterを設定する代わりにobserve関数を実行して、オブザーバ関数内でgetterを実行した結果値に内部データを変更し、登録されたオブザーバ関数を呼び出します。ただし、getter内部でthisを使ってオブジェクトにアクセスするため、callを使ってオブジェクトをコンテキストに渡す必要があります。
if (getter) { observe(() => { _value = getter.call(obj); propObservers.forEach(observer => observer()); }); }
完成した完全なコードは、次のとおりです。
function observable(obj) { Object.keys(obj).forEach((key) => { const getter = Object.getOwnPropertyDescriptor(obj, key).get; const propObservers = []; let _value = getter ? null : obj[key]; Object.defineProperty(obj, key, { configurable: true, get() { if (currentObserver && !propObservers.includes(currentObserver)) { propObservers.push(currentObserver); } return _value; }, }); if (getter) { observe(() => { _value = getter.call(obj); propObservers.forEach(observer => observer()); }); } else { Object.defineProperty(obj, key, { set(value) { _value = value; propObservers.forEach(observer => observer()); } }); } }); return obj; }
getterとsetterを一度に指定しないため、getterを登録する際、configurableをtrueに設定してから、setterを追加することができます。その他は既存のコードとほぼ同じです。派生プロパティも、ユーザーが定義したgetterではなく、プロキシ用getterがかかっており、次のように連鎖的に派生した値でも問題なく動作します。
const board = observable({ score1: 10, score2: 40, get totalScore() { return this.score1 + this.score2; }, get ratio1() { return this.score1 / this.totalScore; } }); console.log(board.ratio1); // 0.2 board.score1 = 60; console.log(board.ratio1); // 0.6
追加で検討すべきこと
これまでに作成したコードだけでも、一般的な状況で使用するには全く無理がありませんが、いくつか考慮されていないものがあります。ここでは、重要な内容だけを整理します。
1. observe初期の実行で不足しているコード
observe関数は、引数として受け取ったオブザーバ関数を最初に実行するときだけcurrentObserverに設定します。つまり、すべてのオブザーバは、初期実行時のみプロキシgetterによって検出されます。そのため、次のようにオブザーバ関数の内部に分岐文があると、最初に実行されないコードは、observable
オブジェクトのプロパティのオブザーバリストに追加されません。
const board = observable({ score1: 10, score2: 20 }); observe(() => { if (board.score1 === 10) { console.log(`score1 : ${board.score1}`); } else { console.log(`score2 : ${board.score2}`); } }); // score1 : 10 board.score1 = 20; // score2 : 20; board.score2 = 30; // 反応なし
observeに引数として渡したオブザーバ関数は、else文内でboard.score2にアクセスしますが、このコードは最初の実行時には実行されないため、以降に発生したboard.score2の変更は検出できなくなります。この問題を解決するには、オブザーバ関数を実行する度に、毎回currentObserver
に設定しなければなりません。その後、オブザーバの重複を除去するために毎回配列を巡回することがパフォーマンスに影響を与える可能性があるため、オブザーバリストを保存する際、配列の代わりにSetを使用した方が良いでしょう。Setを使用できない環境であれば、オブザーバ別に固有IDを割り当てた後、オブジェクトを使って管理しなければなりません。
また、毎回オブザーバの有無を確認する必要があるため、連鎖した派生プロパティを使用したり、オブザーバ内部でオブジェクトを修正して連鎖的なオブザーバの呼び出しが発生すると、currentObserverが途中でnullに初期化される問題が生じる場合があります。この問題を解決するには、配列を使ってcurrentObserver
スタックのように管理する必要があります。
2. unobserve関数
observeを使って一度登録されたオブザーバは、オブザーバ対象がメモリから消えるまで永遠に実行されます。この場合、特定の状態をオブザーブしているUIコンポーネントは、画面から削除されても継続メモリに残って不要な作業を繰り返すことになります。そのため、observe
関数は必ずオブザーバを解除できるunobserve
(あるいはdispose)関数を返却する必要があります。
const board = observable({ score: 10 }); const unobserve = observe(() => { console.log(board.score); }); // 10 board.score = 20; // 20 unobserve(); board.score = 30; // 反応なし
現在は、propObservers配列が関数スコープの内部に存在するため、外部からのオブザーバを削除できる方法がありません。また、オブザーバ関数別に、自分が登録したすべてのobservableオブジェクトのプロパティのオブザーバ配列を把握する必要があるため、これを実装するには、モジュールスコープ内で全体のオブザーバ情報を個別に管理する必要があります。
3.リアクティブ配列
リアクティブのシステムが大容量の配列を処理する際にどのような問題を持つかは、すでに説明しました。様々な方法を試みましたが、結論としてTOAST UI Gridは、配列に対してリアクティブシステムを使用しないことに決めました。大半の配列は、数十個程度の要素だけを持ち、このような配列を毎回新しく生成するコストは大きくないからです。つまり、配列オブジェクトがリアクティブでなくても、次のように配列が属するオブジェクトのプロパティ値を毎回新しい配列に変更すればオブザーバが反応するようになります。
const data = obaservable({ nums: [1, 2, 3], get squareNums() { return this.nums.map(num => num * num); } }); console.log(squareNums); // [1, 4, 9] data.nums = [...nums, 4]; console.log(squareNums); // [1, 4, 9, 16]
しかし、サイズが非常に大きい配列を毎回新しく生成するのは、パフォーマンスに影響を及ぼしかねません。これを解決するため、MobXやVueでpushやpopのように配列を変更する一部APIを変更して、内部的にオブザーバを呼び出す別のリアクティブ配列オブジェクトを生成します。また、配列の要素にアクセスすることを確認するため、個別インデックスのアクセス者にプロキシgetterを設定したりします。この方式は細部操作のため、多くの状況を考慮する必要があり、実装と使用が複雑で性能にも少なからず影響を及ぼす可能性があります。
TOAST UI Gridは、このような問題を解決するため、配列自体をリアクティブにするのではなく、notify関数を作成して、特定のオブジェクトのプロパティ値にかかっているオブザーバ関数を強制的に呼び出すように処理しています。
const data = observbable({ nums: [1, 2, 3], get squareNums() { return this.nums.map(num => num * num); } }); console.log(squareNums); // [1, 4, 9] data.nums.push(4); notify(data, 'num'); console.log(squareNums); // [1, 4, 9, 16]
このnotify関数は、変更をランダムに発生させるため、自然なリアクティブとは言えません。しかし、この関数を使用することはほとんどなく、非常に大規模な配列数に対して限定的に使用するため、パフォーマンス用の例外処理としては十分に合理的な選択だと思われます。
まとめ
TOAST UI Grid内部で使用しているリアクティブ状態管理システムについて紹介しました。この他にも、キャッシュされたデータを使用する処理や、リアクティブではなく純粋オブジェクトを返却する関数などの機能がありますが、ここでは割愛しました。
プログラミングの世界では、「車輪を再発明するな」という言葉があります。しかし、すでにある車輪の中で私たちが望む車輪がないとき、合理的なコストで新しいホイールを作ることができれば、自分のホイールを作るのが一番良い選択だと思われます。新しいライブラリやフレームワークを見つけて、使用方法を習得することも重要ですが、それよりも重要なことはその中の核心原理を理解することにあります。核心原理を理解すれば、アプリケーションを開発する際、技術的にさらに多様な可能性を考慮することができ、困難な問題にぶつかっても、より柔軟に対応することができるでしょう。
TOAST UI Gridはバージョン4の正式配布に向けて懸命に準備しています。公式WeeklyとTwitterからの発表を楽しみに待っていてください!