million.js の仕組みをちゃんと理解するスクラップ
Virtual DOM: Back in Block
上記の記事を理解するために翻訳する。
以下の内容は全て記事内の文章の翻訳ですが、意訳が多く含まれている点にご注意ください。
4年ほど前、Rich Harris 氏は従来の仮想 DOM 操作のパフォーマンスを分析した記事、 Virtual DOM is pure overhead ( 仮想DOMは純粋なオーバーヘッドである ) を発表しました。
Rich Harris 氏は記事の中で、React などのフレームワークで称賛されている機能である仮想 DOM は、多くの開発者が考えるほど効率的ではないと主張し、さらにその仮想 DOM に代わるアプローチを提示しています。
そして、その数年後に新たな流行が発生しました。それは 「 the virtual DOM is pure overhead ( 仮想 DOM は純粋なオーバーヘッドである ) 」というモノでした。この流行は非常に根強く、”仮想 DOM を無くす” という啓蒙活動をより本格的な活動へと変化させました。
こうして、仮想 DOM は「誰も好きじゃないけど、雰囲気的に使ってる」という地位に追いやられてしまいました。そのせいか、仮想 DOM は必要悪のような扱いを受け、宣言的 UI の利便性のために支払わなければならないパフォーマンス税となりました。
今までは。
起源の物語
仮想 DOM は実際の DOM を頻繁に操作することで発生するパフォーマンスの問題に対処するために作成されました。これは、実際の DOM の軽量なメモリ内表現であり、、後で実際の Web ページを更新するための参照として使用できます。
コンポーネントがレンダリングされると、仮想 DOM は新しい状態と更新前の状態の違いを計算し (「diffing(差分)」と呼ばれるプロセス)、実際の DOM に最小限の変更を加えて、更新された仮想 DOM と同期させます (「reconciliation(調整)」と呼ばれるプロセス)。
視覚的な例
次のような <Numbers />
という React コンポーネントが与えられたとします。
function Numbers() {
return (
<foo>
<bar>
<baz />
</bar>
<boo />
</foo>
);
}
React がこのコンポーネントをレンダリングすると、差分(変更の確認)と調整(DOMの更新)の二つのプロセスが実行されます。
2 つの仮想 DOM が与えられます: UI の外観を表す現在の DOM と、必要なものを表す新しい DOM です
最初のノードを比較しますが、違いが見つからないので次に進みます
2 番目のノードを比較して違いを見つけたので DOM を更新します
3 番目のノードを比較すると、新しい仮想 DOM では消えていることがわかったので DOM を削除します
4 番目のノードも比較すると消えているので、DOM を削除します
5 番目のノードを比較して違いを見つけたので、DOM を更新して終了します
ここまでの問題点
上記の例では、差分はツリーサイズに依存し、最終的には仮想 DOM のボトルネックになることが分かります。ノードの数が多ければ多いほど、差分にかかる時間も長くなります。
Svelte のような新しいフレームワークではパフォーマンスのオーバーヘッドを抑えるため、仮想 DOM は使用されていません。代わりに、Svelte は「dirty checking」と呼ばれる手法を使用して、何が変更されたかを判断します。SolidJS のようなきめ細かいリアクティブなフレームワークでは、これをさらに一歩進めて、何が変更されたかを正確に特定し、DOM にその部分のみを更新します。
ブロック仮想 DOM
2022 年、Blockdom がリリースされました。Blockdom は根本的に異なるアプローチを採用し、「 block viertual DOM (ブロック仮想 DOM) 」というアイディアを導入しました。
ブロック仮想 DOM は、「diffing(差分)」に対して異なるアプローチを採用しており、次の2つの部分に分けることができます。
- 静的分析: 仮想 DOM が分析され、ツリーの動的な部分が「Edit Map (編集マップ)」、つまり仮想 DOM の動的な部分の状態が"編集マップ"のリストに抽出されます。
- 変更検知(Dirty Checking): 状態 (仮想 DOM ツリーではない) を比較して、何が変更されたかを判断します。状態が変更された場合、DOM は編集マップを介して直接更新されます。
Million.js は Blockdom と同様のアプローチを採用しているため、この記事の残りの部分では Million.js 構文を使用します。
反例
簡単な反例と、それが Million.js でどのように処理されるかを見てみましょう。
import { useState } from 'react';
import { block } from 'million/react';
function Count() {
const [count, setCount] = useState(0);
+ const node1 = count + 1;
+ const node2 = count + 2;
return (
<div>
<ul>
+ <li>{node1}</li>
+ <li>{node2}</li>
</ul>
<button
onClick={() => {
setCount(count + 1);
}}
>
Increment Count
</button>
</div>
);
}
const CountBlock = block(Count);
静的解析
静的分析のステップでは、Million.jsの実験的コンパイラを使うかどうかによって、コンパイル時に行われるか、実行時に最初に行われるかに分かれます。
このステップでは、仮想 DOM の動的な部分を編集マップに抽出します。
React で JSX をレンダリングする代わりに、Million.jsを使ってレンダリングします。Million.js は仮想 DOM に「holes」("?" の部分) を渡し、これらの「holes」は動的コンテンツのプレースホルダーとして静的解析時に使用されます
ここから静的分析を開始します。最初のノードにはプレースホルダーが無かったので、次へ進みます
2 番目のノードでもプレースホルダーが無かったので、次へ進みます
3 番目のノードでプレースホルダーを見つけたので、プレースホルダーを「編集マップ」に追加します。これによって props1
がプレースホルダーのノードと関連付けられ、ブロックからプレースホルダーを削除して次へ進みます
4 番目のノードでプレースホルダーを見つけたので、3番目と同様に「編集マップ」に追加してprops2
をプレースホルダーのノードと関連付けて、ブロックからプレースホルダーを削除して次へ進みます
5 番目のノードでプレースホルダーをチェックしましたが見つからなかったので、これで終了です
変更検知 (Dirty Checking)
編集マップが作れたら、変更検知(Dirty Checking)のステップを開始できます。このステップでは、状態の変更内容を判断し、それに応じて DOM を更新します。
要素を比較する代わりに prop1
と prop2
を比較することができます。どちらも、静的分析中に作成した「編集マップ」によってそれぞれのノードに関連付けられているため、違いが判明したら DOM を直接更新できます
現在の prop1
と新しい prop1
の値が違っていたので DOM を更新します
現在の prop2
と新しい prop2
の値が違っていたので DOM を更新します
変更検知(Dirty Checking)の例では、仮想 DOM の差分プロセスよりも計算量が大幅に減少していることが分かります。これは、変更検知(Dirty Checking)が仮想 DOM ではなく状態のみに関係するためです。仮想ノードを比較するには深い等価性チェックが必要になることがありますが、状態の比較であれば浅い等価性チェックのみで十分です。
このテクニックは効果的ですか?
もちろん!しかし銀の弾丸ではありません。(View latest benchmark)
Million.js は非常に高いパフォーマンスを誇り、JavaScript フレームワークのベンチマークでは React を上回るパフォーマンスを発揮します。ただし、この場合 Million.js がなぜ高速化できるのかを理解することが重要です。
JavaScript フレームワークのベンチマークでは、行と列の大きなテーブルをレンダリングしてフレームワークのパフォーマンスをテストしています。このベンチマークは、非常に非現実的なパフォーマンス(1000 行の追加/置換など)をテストするように設計されており、必ずしも実際のアプリケーションを代表するものではありません。
では、Million.js または ブロック仮想 DOM はどこで使用できるのでしょうか?
静的コンテンツが多く、動的コンテンツが少ない
ブロック仮想 DOM は、動的コンテンツが少なく静的コンテンツが多い場合に最適です。ブロック仮想 DOM の最大の利点は、仮想 DOM の性的部分を考慮する必要が無い事です。そのため、多くの静的コンテンツをスキップできれば、非常に高速になります。
たとえば、次の場合はブロック仮想 DOM の方が通常の DOM よりもはるかに高速になります。
// ✅ Good
<div>
<div>{dynamic}</div>
Lots and lots of static content...
</div>
ただし、動的なコンテンツが大量にある場合は、ブロック仮想 DOM と通常の仮想 DOM の間に大きな違いが見られない場合があります。
// ❌ Bad
<div>
<div>{dynamic}</div>
<div>{dynamic}</div>
<div>{dynamic}</div>
<div>{dynamic}</div>
<div>{dynamic}</div>
</div>
管理ダッシュボードや、静的コンテンツを多く含むコンポーネントの Web サイトを構築する場合は、ブロック仮想 DOM が適している可能性があります。ただし、データの差分プロセスに必要な計算が仮想 DOM の差分プロセスに必要な計算よりも大幅に大きい Web サイトを構築する場合は、大きな違いがみられない可能性があります。
たとえば、以下のコンポーネントは、仮想 DOM ノードよりも比較するデータ値が多いため、ブロック仮想 DOM には適していません。
// ブロック仮想 DOM なら 5 つの値を比較します
function Component({ a, b, c, d, e }) {
// 仮想 DOM なら 1 つの仮想ノードを比較します
return <div>{a + b + c + d + e}</div>;
}
「安定した(Stable)」UIツリー
ブロック仮想 DOM は、「安定した(Stable)」 UI ツリー、つまりあまり変更されない UI ツリーにも適しています。これは、編集マップが 1 回だけ作成され、レンダリングごとに再作成する必要がないためです。
たとえば、次のコンポーネントはブロック仮想 DOM に適しています。
function Component() {
// ✅ 安定(stable)しているため上手く機能します
return <div>{dynamic}</div>;
}
ただし、このコンポーネントは通常の仮想 DOM よりも遅くなる可能性があります。
function Component() {
// ❌ 不安定なため上手く機能しません
return Math.random() > 0.5 ? <div>{dynamic}</div> : <p>sad</p>;
}
リストのような動的で不安定な戻り値を使用する必要がある場合は、<For />
コンポーネントを使用すると便利です:
function Component() {
return <For each={items}>{(item) => <div>{item}</div>}</For>;
}
アプリケーション UI の構造に制限があることに注意してください。「安定した((Stable))」戻り値とは、リストのような動的ではないコンポーネント (条件で戻り値が変わるコンポーネントなど) が許可されないことを意味します。
細かく使用する
初心者が犯す最大の間違いの 1 つは、ブロック仮想 DOM をあらゆる場所で使用することです。これは悪い考えです。ブロック仮想 DOM は銀の弾丸ではなく、通常の仮想 DOM よりも必ずしも高速ではないからです。
代わりに、ブロック仮想 DOM の方が高速な特定のパターンを認識し、その場合にのみブロック仮想 DOM を使用する必要があります。たとえば、大きなテーブルにはブロック仮想 DOM を使用し、静的コンテンツが少ない小さなフォームには通常の仮想 DOM を使用してください。
終わりに
ブロック仮想 DOM は、仮想 DOM の概念に新たな視点をもたらし、更新を管理し、オーバーヘッドを最小限に抑える代替アプローチを提供します。その可能性にもかかわらず、これは万能のソリューションではありません。開発者は、このアプローチを採用するかどうかを決定する前に、アプリケーションのニーズとパフォーマンス要件を評価する必要があります。
多くのアプリケーションでは、従来の仮想 DOM で十分であり、ブロック仮想 DOM やその他のパフォーマンス重視のフレームワークに切り替える必要がない場合があります。ほとんどのデバイスでパフォーマンスの問題が発生することなくアプリケーションがスムーズに実行される場合は、別のフレームワークに移行する時間と労力に見合う価値がない可能性があります。技術スタックに大きな変更を加える前に、トレードオフを慎重に検討し、アプリケーション固有の要件を評価することが重要です。
そうは言っても、Million.js の将来がどうなるのか楽しみです。あなたもそう思いませんか? ( 自作してみようぜ! )
block()
Behind the
詳細なロジックを理解するために、上記の記事を翻訳する。
以下の内容は全て記事内の文章の翻訳ですが、意訳が多く含まれている点にご注意ください。
Million.js を使ったことがあるなら、block()
関数について聞いたことがあるでしょう。
function MyComponent() {
// ...
}
const MyBlock = block(MyComponent);
export default function App() {
return <MyBlock />; // ✨ ちゃんと動く! ✨
}
React コンポーネントを block()
でラップするとブロック(block)が生成されます。このブロック(block)は、特別な高階コンポーネント(HOC)で、React コンポーネントとして使用できますが、内部では Million.js を使用してレンダリングすることで、描画速度を最適化しています。
しかし、これはどうして可能なのでしょうか?そもそも React 内部でブロック(block)って使えるのでしょうか?Million.js はまったく異なる仮想 DOM じゃないのでしょうか?
block()
の謎を解く
ブロック(block)を作成し、React コンポーネントとして使用するとレンダリング中に以下のことが発生します:
1. React が <Loader />
をレンダリングする
始めに、React は <Loader />
のレンダリングを担当します。このプロセスでは、必要な DOM を作成し初期プロパティやスタイルを適用します。この段階で、React はコンポーネントのライフサイクルと状態を管理し、状態管理やライフライクル、メソッドなどの機能を実現します。
2. React が <Loader />
をマウントし、DOM に ref を配置します
レンダリング処理に続けて、React は <Loader />
をマウントします。これは、コンポーネントを DOM に挿入し、ユーザーから見えるようにする事です。また、この時点で React は DOM の ref も更新します。React における ref とは、レンダリングを呼び出さないローカルな状態を保持する方法であり、今回は DOM へ参照するために使用されています。
3. Million.js は <App />
を ref にレンダリングします
最後に、ref は高速で軽量な仮想 DOM ライブラリである Million.js に渡されます。Million.js は、ref に格納された DOM 参照を使って <App />
を直接 DOM にレンダリングします。これにより、Million.js は <App />
を React とは別に管理することができ、潜在的なパフォーマンスの利点と責任の分離につながります。
このパターンによって、React に知られることなく DOM を「コントロール」することができます。React は <Loader />
のことしか知らず、Million.js は <App />
のことしか知りません。
block()
の実装
これを念頭に置いて、このパターンの基本的な実装を作ることができます。
1. HOC ファクトリーの作成
HOC ファクトリーは React コンポーネントを受け取り、<Loader />
を吐き出します。<Loader />
は DOM 要素をレンダリングし、Million.js に渡す役割を担っています。
const block = (ReactComponent) => {
return function Loader(props) {
return /*... */;
};
};
2. useRef()
による DOM 要素の取得
useRef()
を使って DOM 要素を取得することができます。
const block = (ReactComponent) => {
return function Loader(props) {
const el = useRef(); // stores the DOM element
return <div ref={el}></div>;
};
}
3. Million.jsをレンダリングするエフェクトを作成する
さて、全てをまとめます。マウント時にエフェクトを実行する <Effect />
を作成します。このエフェクトは <App />
を DOM 要素にレンダリングする役割を果たします。useCallback()
を使って、エフェクトへの安定したクロージャ参照を作成します。
ここで、Million.convert()
と Million.render()
の呼び出しに注意してください。これらは実態ではありませんが、基本的にブロック(block)を作成し、DOM 要素にレンダリングします。
const block = (ReactComponent) => {
const MillionComponent = Million.convert(ReactComponent);
return function Loader(props) {
const el = useRef();
// 3. Million.js は ref へ <App /> をレンダリングします
const effect = useCallback(() => {
// useCallback を使って安定的にクロージャを参照できるようにします
Million.render(MillionComponent, el.current);
}, []);
// 2. React は <Loader /> をマウントし ref に DOM を配置します
return (
<>
<div ref={el}></div>
<Effect effect={effect} />
</>
);
};
};
// Effect はマウント上でエフェクトを実行するコンポーネントです
function Effect({ effect }) {
useEffect(effect, []);
return null;
}
コンパイラー、君は魔法使いだ!🧙
ランタイム実装の大きな制限のひとつは、ユーザーがステートレス・コンポーネントを渡す必要があることです。これは内部ブロック実装に多くの制限があるためです。
例えば、isSad
の状態を持つ <Emotion />
があり、その状態に基づいて 😢 または 😂 の Emoji をレンダリングするとします。
function Emotion() {
const [isSad, setIsSad] = useState(true);
return <div>{isSad ? '😢' : '😂'}</div>;
}
const EmotionBlock = block(Emotion);
コンパイラは isSad
の状態を取り出して、Million.js が理解できる Props に変換することができます。
function Emotion_jsx({ _0 }) {
return <div>{_0}</div>;
}
const Emotion_jsx_block = block(Emotion_component);
function EmotionBlock() {
const [isSad, setIsSad] = useState(true);
return <Emotion_jsx_block _0={isSad ? '😢' : '😂'} />;
}
しかし、もし <Emotion />
の中に別のReactコンポーネントがあったらどうなるでしょうか?
function SadEmoji() {
return '😢';
}
function HappyEmoji() {
return '😂';
}
function Emotion() {
const [isSad, setIsSad] = useState(true);
return <div>{isSad ? <SadEmoji /> : <HappyEmoji />}</div>;
}
const EmotionBlock = block(Emotion);
この場合、同じように抽出されますがレンダリング中にコンポーネントの境界に出会うと「 React render scope 」 を作成します。これは基本的にコンポーネントをレンダリングする責務を React に移譲します。
function SadEmoji() {
return '😢';
}
function HappyEmoji() {
return '😂';
}
function Emotion_jsx({ _0 }) {
return <div>{_0}</div>;
}
const Emotion_jsx_block = block(Emotion_component);
function EmotionBlock() {
const [isSad, setIsSad] = useState(true);
return (
<Emotion_jsx_block
_0={renderReactScope(isSad ? <SadEmoji /> : <HappyEmoji />)}
/>
);
}
上記のとおり、コンパイラーは親要素から状態を抽出してレンダリングできます。また、コンポーネントの境界にぶつかった時に、それを認識して React にレンダリングの責務を委譲することもできます。
Million.js だけじゃない
この記事では、Million.js がこのパターンをどのように活用しているかを詳しく説明していますが、これは Million.js だけに限ったことではありません。
DOM 要素のレンダリングできる最新のフレームワークであれば、<Loader />
と HOC パターンを使って、React の中で別のフレームワークコンポーネントをレンダリングできます。
非常に似たコンセプトとして 「 islands architecture (アイランドアーキテクチャ) 」があり、静的なHTMLにあらゆるフレームワークを組み込むことができます。Million.js の場合、これとは少し異なり静的な HTML にレンダリングするのではなく、React ツリーにレンダリングします。
なぜ互換レイヤーではないのか?
Preact や Inferno などの JavaScript フレームワークには互換性があり、React コンポーネントのように扱えます。これにより、プロジェクトやエンジニアリングチームはコードベース全体を書き直すことなく非常に迅速に作業を進めることができます。
しかし、それには代償が伴います。 互換性レイヤーは常に互換性を保たなければなりません。React が新しい機能を追加すると、互換レイヤーはそのサポートを追加しなければなりません。同じ動作を維持するのは不可能に近く、特に React の辺高所リモデルと同じ動作と利点を再現するのは難しいでしょう。
最後に
特定の異なるレンダリング手法をコンポーネントごとに使い分けることで、両者の長所を生かし、適切な仕事に適切なツールを使うことができます。いつか、このパターンを採用するフレームワークが増えることを期待したいです。パフォーマンスが移行のトレードオフであってはならないので。
謝辞
Ryan Carniato 氏には、この記事のインスピレーションとなった Solid.js in React の概念実証を作成して頂きありがとうございました。
もっと知りたいですか? Yongjun Park による別の 興味深い記事 をご覧ください。
💯 Hundred
上記のリンクは Million.js を簡易的に実装して行くリポジトリ。これを翻訳していく。
以下の内容は全て記事内の文章の翻訳ですが、意訳が多く含まれている点にご注意ください。
Hundred は Million.js をベースとしたおもちゃのブロック仮想 DOM かつコンセプトの証明で、実際にプロダクションで使用するツールというよりも学習リソースであることを意図しています。また、この実装も同様に blockdom をベースにしています。