Million.jsの仕組みが気になったので調べてみました
株式会社IVRy (アイブリー)のエンジニアのkinashiです。
先日、何かの記事で Million.js という React のコンポーネントを高速にしてくれるライブラリがあるというのを見かけました。
業務でも React を使っているので、パフォーマンスに問題が出たときに選択肢に入るといいなと思いドキュメントを読み始めましたが、アイデアが面白いライブラリだなと思ったので紹介しようと思います。
Million.jsについて
Up to 70% faster React components.
70%[1]速くなるってすごいですよね。
最初は React の仕組みの中で再レンダリングなどを最適化してくれるライブラリなのかなと思っていましたが、 React とは別に独自の仮想DOMを使うことによってレンダリングの速度を上げているそうです。
基本的には block という関数でコンポーネントを包むだけで使えます。
その他にリストを最適化する For コンポーネントなども提供されています。
function Lion() {
return (
<img src="https://million.dev/lion.svg" />
);
}
const LionBlock = block(Lion);
この記事の執筆時点(v2.4.2)では UI Component を提供するライブラリと一緒に使えなかったり、コンポーネントに props を渡す際にスプレッド構文が使えなかったりと、まだまだ制約が多そうです。
<div {...props} /> // ❌ Wrong
ブロック仮想DOMの仕組み
ブロック仮想DOMの仕組みについてはブログで詳しく説明されていたので、詳細気になる方は読んでみてください。
(下記抜粋は ChatGPT で翻訳したものになります)
ブロック仮想DOMは、差分検出に異なるアプローチを取り、次の2つのパートに分かれます:
- 静的解析: 仮想DOMは解析され、ツリーの動的な部分が「編集マップ」として抽出されます。編集マップは、仮想DOMの動的な部分と状態の間の「編集」(マッピング)のリストです。
- ダーティーチェック: 仮想DOMツリーではなく、状態が差分検出され、変更された箇所を特定します。状態が変更された場合、編集マップを介してDOMが直接更新されます。
block にコンポーネントが登録され、マウントされたときに編集マッピングを作成し、ダーティーチェックで差分が検出されると編集マップの情報から DOM 更新する仕組みになっているようです。
ブロック仮想DOMは、動的なコンテンツが少なく、静的なコンテンツが多い場合に最適です。ブロック仮想DOMの最大の利点は、静的な部分について考える必要がないため、多くの静的コンテンツをスキップできる場合、非常に高速になることです。
と書いてあるように静的なコンテンツが多い場合に真価を発揮するみたいでした。
初心者が犯す最大の間違いの 1 つは、あらゆる場所でブロック仮想DOMを使用することです。ブロック仮想DOMは特効薬ではなく、通常の仮想 DOM よりも常に高速であるとは限らないため、これは悪い考えです。
代わりに、ブロック仮想DOMの方が高速な特定のパターンを認識し、そのような場合にのみ使用する必要があります。たとえば、大きなテーブルにはブロック仮想DOMを使用しますが、静的コンテンツが少ない小さなフォームには通常の仮想DOMを使用することができます。
全てに適用するのではなく、効果を発揮できる部分で使いましょうとのこと。
(ブログのタイトルが AC/DC の名曲のタイトルに似てるのは狙っているのだろうか。。)
コードで見るブロック仮想DOM
先程のブログの最後に
That said, I'm excited to see what the future holds for it. Are you too? (Go build your own!)
という一文があり、開いてみるとブロック仮想DOMの仕組みについて、コードレベルで説明してくれているリポジトリでした。
主に block 関数ってこんな仕組みで動いてるよ、というのを簡素化したコードを使い step に分けて解説してくれているので、本来ならコード書きながら順に読むのがいいのですが、今回は抜粋して紹介します。
Hole
block で包まれたコンポーネントを独自の仮想DOMとして扱うために、 Proxy を使っています。
props を1度 Proxy に通すことでプロパティにアクセスすると特殊な値が返却されるようになるので、render関数の中でこの値を判定し、差分判定用の値を詰める処理になっていました。
Hole(落とし穴) という名前も Proxy のトラップから来ていそう?
// block is a factory function that returns a function that
// can be used to create a block. Imagine it as a live instance
// you can use to patch it against instances of itself.
export const block = <TProps extends Props = Props>(fn: (props: TProps) => VNode) => {
// by using a proxy, we can intercept ANY property access on
// the object and return a Hole instance instead.
// e.g. props.any_prop => new Hole('any_prop')
const proxy = new Proxy(
{} as TProps,
{
get(_, prop: string) {
return new Hole(prop);
},
}
);
// we pass the proxy to the function, so that it can
// replace property accesses with Hole placeholders
const vnode = fn(proxy);
// ...
ライブラリ側のコードは getter でインスタンスを返す形ではないですが、 Proxy を使ってる部分はほとんど同じでした。
const HOLE_PROXY = new Proxy(
{},
{
// A universal getter will return a Hole instance if props[any] is accessed
// Allows code to identify holes in virtual nodes ("digs" them out)
get(_, key: string): Hole {
return { $: key };
},
},
);
Proxy は中々使う機会がないので、実際に使ってるコードを見られてよかったです(小学生並みの感想)
render
かなり簡素化されていますが、この関数の中で差分チェックに使う情報を格納しています。
ブログの中で編集マップと言われていた部分。
for (const name in vnode.props) {
const value = vnode.props[name];
if (value instanceof Hole) {
edits.push({
type: 'attribute',
path, // the path we need to traverse to get to the element
attribute: name, // to set the value during mount/patch
hole: value.key, // to get the value from props during mount/patch
});
continue;
}
el[name] = value;
}
for (let i = 0; i < vnode.children.length; i++) {
const child = vnode.children[i];
if (child instanceof Hole) {
edits.push({
type: 'child',
path, // the path we need to traverse to get to the parent element
index: i, // index represents the position of the child in the parent used to insert/update the child during mount/patch
hole: child.key, // to get the value from props during mount/patch
});
continue;
}
// we respread the path to avoid mutating the original array
el.appendChild(render(child, edits, [...path, i]));
}
チュートリアルのコードではエレメントの作成までやっていますが、ライブラリでは renderToTemplate という関数になっていて、エレメントを作成するための文字列を返却する作りになっています。
patch
チュートリアルのコードでは patch を自分で実行することで、DOMが変更される仕様になっています。
ダーティーチェックをして DOM に反映する箇所ですね。
export const block = <TProps extends Props = Props>(fn: (props: TProps) => VNode) => {
// ...
// factory function to create instances of this block
return (props: TProps): Block => {
// ...
// patch updates the element references with new values
const patch = (newBlock: Block) => {
for (let i = 0; i < edits.length; i++) {
const edit = edits[i];
const value = props[edit.hole];
const newValue = newBlock.props[edit.hole];
// dirty check
if (value === newValue) continue;
const thisEl = elements[i];
if (edit.type === 'attribute') {
thisEl[edit.attribute] = newValue;
} else if (edit.type === 'child') {
// handle nested blocks if the value is a block
if (value.patch && typeof value.patch === 'function') {
// patch cooresponding child blocks
value.patch(newBlock.edits[i].hole);
continue;
}
thisEl.childNodes[edit.index].textContent = newValue;
}
}
};
変更がない Node では処理をスキップするので、親の Node が変更されたら自分以下の Tree をすべて更新する React よりも速くなるんだと理解しました。
ドキュメントにも動的な値を返すコンポーネントではそんなにパフォーマンスが改善しないと書いてありますが、このロジックを見て納得しました。
まとめ
仮想DOMはオーバーヘッドになる派の Svelte や Solid などが出てきている中で、新しいアイデアで仮想DOMを使いながらパフォーマンスを改善しようとするライブラリもあり、改めてフロントエンドは面白いなと思いました。
発展途上のライブラリではありますが、かなり速いペースで開発が行われていそうなので、今後に期待したいです!
最初見たときは謎のアイコンだと思っていましたがライオンでした。かわいい
最後に
IVRyでは一緒に働いてくれるエンジニアを募集中です!
話だけ聞いてみたい方はカジュアル面談からでも、お気軽にご応募ください!
Discussion