2013 年に、私は Web アプリケーションを開発するための最小限のツール セットを構築することに着手しました。おそらく、そのプロセスから生まれた最高の成果は、2,000 行のコードで記述されたクライアント側の純粋な JS フロントエンド フレームワークであるgotoBでした。
非常に成功したフロントエンド フレームワークの作者による興味深い記事を夢中で読んだ後、私はこの記事を書く気になりました。
これらの記事で私が興奮したのは、構築物の背後にあるアイデアの進化について語られていることです。実装はそれを現実のものにするための方法にすぎず、議論されている機能はアイデアそのものを表すほど重要なものだけです。
これまでのところ、gotoB から生まれたものの中で最も興味深いのは、それを構築する際の課題に直面した結果として生まれたアイデアです。ここで取り上げたいのは、その点です。
私はフレームワークをゼロから構築し、ミニマリズムと内部の一貫性の両方を実現しようとしていたため、ほとんどのフレームワークが同じ問題を解決する方法とは異なる方法で 4 つの問題を解決したと思います。
私が今皆さんにシェアしたいのは、この 4 つのアイデアです。これは、皆さんに私のツールを使ってもらうよう説得するためではなく (もちろん使っていただいても構いませんが)、むしろ、皆さんがアイデア自体に興味を持ってくれることを期待してのことです。
どの Web アプリケーションでも、アプリケーションの状態に基づいて、その場でマークアップ (HTML) を作成する必要があります。
これは例で説明するのが一番です。非常にシンプルな ToDo リスト アプリケーションでは、状態は ToDo リスト['Item 1', 'Item 2']
になります。静的なページではなくアプリケーションを作成しているため、ToDo リストは変更可能である必要があります。
状態は変化するため、アプリケーションの UI を作成する HTML も状態に応じて変化する必要があります。たとえば、ToDo を表示するには、次の HTML を使用できます。
<ul> <li>Item 1</li> <li>Item 2</li> </ul>
状態が変わり、3 番目の項目が追加されると、状態は次のようになります: ['Item 1', 'Item 2', 'Item 3']
。HTML は次のようになります:
<ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul>
アプリケーションの状態に基づいて HTML を生成するという問題は、通常、テンプレート言語によって解決されます。テンプレート言語は、プログラミング言語の構造 (変数、条件、ループ) を疑似 HTML に挿入し、実際の HTML に展開します。
たとえば、異なるテンプレート ツールでこれを行うには、次の 2 つの方法があります。
// Assume that `todos` is defined and equal to ['Item 1', 'Item 2', 'Item 3'] // Moustache <ul> {{#todos}} <li>{{.}}</li> {{/todos}} </ul> // JSX <ul> {todos.map((item, index) => ( <li key={index}>{item}</li> ))} </ul>
私は HTML にロジックをもたらすこれらの構文があまり好きではありませんでした。テンプレート化にはプログラミングが必要であることに気づき、そのための別の構文を避けたいと思ったので、代わりにオブジェクト リテラルを使用して HTML を js に持ち込むことにしました。つまり、HTML をオブジェクト リテラルとして簡単にモデル化できました。
['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]]
反復処理を使用してリストを生成したい場合は、次のように記述するだけです。
['ul', items.map ((item) => ['li', item])]
そして、このオブジェクト リテラルを HTML に変換する関数を使用します。このようにして、テンプレート言語やトランスパイルを使用せずに、すべてのテンプレートを JS で実行できます。HTML を表すこれらの配列を説明するために、 lithsという名前を使用します。
私の知る限り、他の JS フレームワークでは、テンプレートにこのようにアプローチしていません。調べてみると、ほぼ同じ構造を使用して JSON オブジェクト (JS オブジェクト リテラルとほぼ同じ) で HTML を表現するJSONMLが見つかりました。ただし、これを中心に構築されたフレームワークは見つかりませんでした。
MithrilとHyperapp は私が使用したアプローチにかなり近いですが、それでも各要素に対して関数呼び出しを使用します。
// Mithril m("ul", [ m("li", "Item 1"), m("li", "Item 2") ]) // hyperapp h("ul", [ h("li", "Item 1"), h("li", "Item 2") ])
オブジェクト リテラルを使用するアプローチは HTML ではうまく機能したため、これを CSS に拡張し、現在ではすべての CSS もオブジェクト リテラルを通じて生成しています。
何らかの理由で JSX をトランスパイルしたりテンプレート言語を使用したりできない環境にあり、文字列を連結したくない場合は、代わりにこの方法を使用できます。
Mithril/Hyperapp のアプローチが私のものより優れているかどうかはわかりません。確かに、lith を表す長いオブジェクト リテラルを記述するときに、どこかにカンマを忘れてしまうことがあり、それを見つけるのが難しい場合があります。それ以外は、特に不満はありません。また、HTML の表現が 1) データであり、2) JS であるという事実が気に入っています。この表現は、アイデア #4 でわかるように、実際に仮想 DOM として機能します。
ボーナスの詳細: オブジェクトリテラルから HTML を生成する場合は、次の 2 つの問題を解決するだけで済みます。
私はコンポーネントがあまり好きではありませんでした。コンポーネントを中心にアプリケーションを構築するには、コンポーネントに属するデータをコンポーネント自体の中に配置する必要があります。これでは、そのデータをアプリケーションの他の部分と共有することが困難、または不可能になります。
私が携わったすべてのプロジェクトで、アプリケーション状態の一部を互いにかなり離れたコンポーネント間で共有する必要があることが常にわかりました。典型的な例はユーザー名です。ユーザー名はアカウント セクションだけでなく、ヘッダーでも必要になる場合があります。では、ユーザー名はどこに属しているのでしょうか。
そのため、私は早い段階で単純なデータ オブジェクト ( {}
) を作成し、そこにすべての状態を格納することにしました。私はそれをstoreと呼びました。store はアプリのすべての部分の状態を保持するため、どのコンポーネントでも使用できます。
このアプローチは 2013 年から 2015 年にかけてはやや異端視されていましたが、その後普及し、優位に立つようになりました。
まだかなり斬新だと思うのは、ストア内の任意の値にアクセスするためにパスを使用する点です。たとえば、ストアが次の場合:
{ user: { firstName: 'foo' lastName: 'bar' } }
B.get ('user', 'lastName')
と記述することで、パスを使用して (たとえば) lastName
にアクセスできます。ご覧のとおり、 ['user', 'lastName']
は'bar'
へのパスです。 B.get
は、ストアにアクセスし、関数に渡すパスで示される特定の部分を返す関数です。
上記とは対照的に、リアクティブ プロパティにアクセスする標準的な方法は、JS 変数を介して参照することです。例:
// Svelte let { firstName, lastName } = $props(); firstName = 'foo'; lastName = 'bar'; // Knockout const firstName = ko.observable('foo'); const lastName = ko.observable('bar'); // mobx class UserStore { firstName = 'foo'; lastName = 'bar'; constructor() { makeAutoObservable(this); } } const userStore = new UserStore(); // SolidJS const [firstName, setFirstName] = createSignal('foo'); const [lastName, setLastName] = createSignal('bar');
ただし、この方法では、値が必要な場所でfirstName
とlastName
(またはuserStore
) への参照を保持する必要があります。私が使用するアプローチでは、ストア (グローバルでどこでも使用可能) へのアクセスのみが必要であり、JS 変数を定義せずにきめ細かなアクセスが可能になります。
Immutable.js と Firebase Realtime Database は、別々のオブジェクトで動作しているにもかかわらず、私が行ったことと非常に近いことを行います。ただし、これらを使用して、すべてを 1 か所に保存し、細かくアドレス指定できるようにすることも可能です。
// Immutable.js let store = Map({ user: Map({ firstName: 'foo', lastName: 'bar' }) }); const firstName = store.getIn(['user', 'firstName']); // 'foo' // Firebase const db = firebase.database(); db.ref('user').set({ firstName: 'foo', lastName: 'bar' }); db.ref('user/firstName').once('value').then(snapshot => { const firstName = snapshot.val(); // 'foo' });
パスを通じて細かくアクセスできるグローバルにアクセス可能なストアにデータを置くことは、非常に便利なパターンだと私は思います。const const [count, setCount] = ...
などと書くと、冗長に感じます。アクセスする必要があるときはいつでも、 count
やsetCount
を宣言して渡すことなく、 B.get ('count')
を実行できるのはわかっています。
アイデア #2 (パスを通じてアクセス可能なグローバル ストア) がコンポーネントからデータを解放するのであれば、アイデア #3 はコンポーネントからコードを解放する方法です。私にとって、これがこの記事で最も興味深いアイデアです。それでは始めましょう。
状態は、定義上、変更可能なデータです (不変性を使用する場合、議論は依然として有効です。つまり、状態の古いバージョンのスナップショットを保持している場合でも、状態の最新バージョンを変更する必要があります)。状態をどのように変更するのでしょうか。
私はイベントを使うことにしました。ストアへのパスはすでにあったので、イベントは単に動詞 ( set
、 add
、 rem
など) とパスの組み合わせになります。つまり、 user.firstName
を更新したい場合は、次のように記述できます。
B.call ('set', ['user', 'firstName'], 'Foo')
これは、次のように書くよりも間違いなく冗長です。
user.firstName = 'Foo';
しかし、これにより、 user.firstName
の変更に応答するコードを書くことができました。そして、これが重要なアイデアです。UI には、状態のさまざまな部分に依存するさまざまな部分があります。たとえば、次のような依存関係があります。
user
とcurrentView
によって異なりますuser
によって異なりますitems
によって異なります
私が直面した大きな疑問は、 user
が変更されたときにヘッダーとアカウント セクションを更新し、 items
変更されたときには更新しないようにするにはどうすればよいか、また、 updateHeader
やupdateAccountSection
などの特定の呼び出しを行わずにこれらの依存関係を管理するにはどうすればよいか、ということでした。これらの種類の特定の呼び出しは、「jQuery プログラミング」の最も保守しにくい部分を表しています。
私にとってより良いアイデアに思えたのは、次のようなことでした。
B.respond ('set', [['user'], ['currentView']], function (user, currentView) { // Update the header }); B.respond ('set', ['user'], function (user) { // Update the account section }); B.respond ('set', ['items'], function (items) { // Update the todo list });
したがって、 user
に対してset
イベントが呼び出されると、イベント システムは、その変更に関係するすべてのビュー (ヘッダーとアカウント セクション) に通知し、他のビュー (todo リスト) はそのままにします。B.respond は、レスポンダー(通常は「イベント リスナー」または「リアクション」と呼ばれます) を登録するために使用する関数です。 B.respond
はグローバルであり、どのコンポーネントにもバインドされていないことに注意してください。ただし、特定のパスでset
イベントのみをリッスンします。
さて、そもそもchange
イベントはどのように呼び出されるのでしょうか? これは私が行った方法です:
B.respond ('set', '*', function () { // Assume that `path` is the path on which set was called B.call ('change', path); });
少し簡略化していますが、基本的には gotoB での動作はこのようになります。
イベント システムが単なる関数呼び出しよりも強力である理由は、イベント呼び出しでは 0、1、または複数のコードを実行できるのに対し、関数呼び出しでは常に 1 つの関数が呼び出されるからです。上記の例では、 B.call ('set', ['user', 'firstName'], 'Foo');
を呼び出すと、ヘッダーを変更するコードとアカウント ビューを変更するコードの 2 つのコードが実行されます。firstName を更新するfirstName
では、誰がこれをリッスンしているかは「考慮」されないことに注意してください。呼び出しは単に処理を実行し、レスポンダーが変更を取得できるようにします。
私の経験では、イベントは非常に強力で、計算された値や反応を置き換えることができます。つまり、アプリケーションで必要な変更を表現するためにイベントを使用できます。
計算された値は、イベント レスポンダーで表現できます。たとえば、 fullName
を計算し、それをストアで使用しない場合は、次のようにします。
B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; // Do something with `fullName` here. });
同様に、反応はレスポンダーで表現できます。次の例を考えてみましょう。
B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; document.getElementById ('header').innerHTML = '<h1>Hello, ' + fullName + '</h1>'; });
HTML を生成するための、気まずい文字列の連結をしばらく無視すると、上記はレスポンダーが「副作用」(この場合は DOM の更新) を実行しているところを示しています。
(補足: Web アプリケーションのコンテキストで、副作用の適切な定義は何でしょうか? 私にとっては、それは 1) アプリケーションの状態の更新、2) DOM の変更、3) AJAX 呼び出しの送信、という 3 つのことに要約されます。
DOM を更新する別のライフサイクルは実際には必要ないことがわかりました。gotoB には、ヘルパー関数の助けを借りて DOM を更新するレスポンダー関数がいくつかあります。したがって、 user
変更されると、それに依存するレスポンダー (または、より正確には、DOM の一部を更新するタスクを担うレスポンダーに私が付けた名前であるビュー関数) が実行され、DOM を更新する副作用が発生します。
レスポンダ関数を同じ順序で 1 つずつ実行することで、イベント システムを予測可能にしました。非同期レスポンダは同期レスポンダとして実行でき、それらの「後」のレスポンダはそれらを待機します。
DOM を更新せずに状態を更新する必要がある (通常はパフォーマンス上の理由) より洗練されたパターンは、ストアを変更するがレスポンダーをトリガーしないmsetなどのミュート動詞を追加することで追加できます。また、再描画が発生した後にDOM で何かを行う必要がある場合は、そのレスポンダーの優先度を低くして、他のすべてのレスポンダーの後に実行するようにするだけです。
B.respond ('set', 'date', {priority: -1000}, function () { var datePicker = document.getElementById ('datepicker'); // Do something with the date picker });
動詞とパスを使用するイベント システムと、特定のイベント呼び出しによって一致 (実行) されるグローバル レスポンダーのセットを用意する上記のアプローチには、もう 1 つの利点があります。それは、すべてのイベント呼び出しをリストに配置できることです。その後、アプリケーションをデバッグするときにこのリストを分析し、状態の変化を追跡できます。
フロントエンドのコンテキストでは、イベントとレスポンダーによって次のことが可能になります。
これは(私の経験では)彼らが許可していないことです:
これらはすべてイベント呼び出しとレスポンダーであり、一部のレスポンダーはビューのみに関係し、その他は他の操作に関係します。フレームワークの内部はすべてユーザー空間を使用しています。
gotoB でこれがどのように機能するかを知りたい場合は、この詳細な説明を確認してください。
双方向データ バインディングは、今ではかなり時代遅れに聞こえます。しかし、タイムマシンで 2013 年に戻り、状態が変わったときに DOM を再描画するという問題を根本から解決するとしたら、何がより合理的に思えるでしょうか。
実際、状態から DOM への一方向のデータ フローであるオプション 2 は、より複雑で非効率的であるように思われます。
これを具体的に考えてみましょう。フォーカスされているインタラクティブな<input>
または<textarea>
の場合、ユーザーがキーを押すたびに DOM の一部を再作成する必要があります。単方向のデータ フローを使用している場合は、入力が変更されるたびに状態が変更され、 <input>
が再描画されて、正確に一致するようになります。
これにより、DOM 更新の基準が非常に高くなります。DOM 更新は迅速に行われ、インタラクティブ要素に対するユーザーの操作を妨げないようにする必要があります。これは簡単に解決できる問題ではありません。
さて、なぜ状態から DOM (JS から HTML) への一方向データが勝ったのでしょうか? それは、その方が推論しやすいからです。状態が変化する場合、その変化がどこから来たかは問題ではありません (サーバーからデータを取得する AJAX コールバック、ユーザー操作、タイマーなど)。状態は常に同じように変化します (または、変化します)。そして、状態からの変化は常に DOM に流れ込みます。
では、ユーザー操作を妨げずに DOM 更新を効率的に実行するにはどうすればよいでしょうか。これは通常、目的を達成するために必要な最小限の DOM 更新を実行することに帰着します。これは通常、「差分処理」と呼ばれます。古い構造 (既存の DOM) を取得して新しい構造 (状態が更新された後の新しい DOM) に変換するために必要な差分のリストを作成するためです。
2016 年頃にこの問題に取り組み始めたとき、私は React が何をしているかを見てごまかしました。React は、2 つのツリー (DOM はツリーです) を比較するための汎用的で線形パフォーマンスのアルゴリズムがないという重要な洞察を与えてくれました。しかし、どちらかと言えば頑固な私は、比較を実行する汎用アルゴリズムが依然として必要でした。React (または、実際のところ、ほぼすべてのフレームワーク) で特に気に入らなかったのは、連続する要素にはキーを使用する必要があるという主張です。
function MyList() { const items = ['Item 1', 'Item 2', 'Item 3']; return ( <ul> {items.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> ); }
私にとって、 key
ディレクティブは不要でした。なぜなら、それは DOM とは何の関係もなく、フレームワークへの単なるヒントだったからです。
次に、フラット化されたバージョンのツリーでテキスト差分アルゴリズムを試してみようと考えました。両方のツリー (所有していた古い DOM 部分と、それを置き換えたい新しい DOM 部分) をフラット化し、そのdiff
(編集の最小セット) を計算して、より少ない手順で古いツリーから新しいツリーに移行できるようにしたらどうなるでしょうか。
そこで、 git diff
実行するたびに使用するMyers アルゴリズムを採用し、フラット化されたツリーに適用しました。例を挙げて説明しましょう。
var oldList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ]]; var newList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]];
ご覧のとおり、私は DOM ではなく、アイデア 1 で見たオブジェクトリテラル表現を使用しています。ここで、リストの最後に新しい<li>
を追加する必要があることに気付くでしょう。
平らになった木は次のようになります。
var oldFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'C ul']; var newFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'O li', 'L Item 3', 'C li', 'C ul'];
O
「開始タグ」、 L
は「リテラル」(この場合はテキスト)、 C
は「終了タグ」を表します。各ツリーが文字列のリストになり、ネストされた配列がなくなったことに注意してください。これがフラット化の意味です。
これらの各要素に対して diff を実行すると (配列内の各項目をユニットのように扱う)、次の結果が得られます。
var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['add', 'O li'] ['add', 'L Item 3'] ['add', 'C li'] ['keep', 'C ul'] ];
おそらくご想像のとおり、リストの大部分はそのままにして、リストの末尾に<li>
を追加します。これらがadd
エントリです。
ここで、3 番目の<li>
のテキストをItem 3
からItem 4
に変更して diff を実行すると、次のようになります。
var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['keep', 'O li'] ['rem', 'L Item 3'] ['add', 'L Item 4'] ['keep', 'C li'] ['keep', 'C ul'] ];
このアプローチが数学的にどれほど非効率なのかはわかりませんが、実際にはかなりうまく機能しています。大きなツリーを比較するときにのみパフォーマンスが低下します。そのようなことが時々発生する場合は、200 ミリ秒のタイムアウトを使用して比較を中断し、問題のある DOM 部分を完全に置き換えます。タイムアウトを使用しないと、比較が完了するまでアプリケーション全体がしばらく停止します。
Myers diff を使用する利点は、挿入よりも削除を優先することです。つまり、アイテムの削除とアイテムの追加の間で同等に効率的な選択がある場合、アルゴリズムは最初にアイテムを削除します。実際には、これにより、削除されたすべての DOM 要素を取得して、後で diff で必要になった場合に再利用できます。最後の例では、最後の<li>
、その内容をItem 3
からItem 4
に変更することで再利用されます。要素を再利用することで (新しい DOM 要素を作成するのではなく)、DOM が常に再描画されていることをユーザーが気付かない程度にパフォーマンスが向上します。
DOM に変更を適用するこのフラット化および差分化メカニズムの実装がどれほど複雑か疑問に思うかもしれませんが、私は 500 行の ES5 JavaScript でそれを実行し、Internet Explorer 6 でも実行できました。しかし、確かに、これは私がこれまでに書いたコードの中で最も難しいものだったかもしれません。頑固であることには代償が伴います。
以上が私が紹介したい 4 つのアイデアです。完全にオリジナルというわけではありませんが、斬新で興味深いものになるといいなと思います。お読みいただきありがとうございました。