はじめに
Reactアプリを育てていくと、リストの更新やフォームの入力のたびに画面がカクつくようになった、という経験はないでしょうか。原因の多くは、Reactの仮想DOMによる差分検出(reconciliation)が、更新のたびにコンポーネントツリー全体を線形時間で走査していることにあります。ロジックを見直しても、React.memoを貼っても、根本的なボトルネックはそのまま残ることが少なくありません。
Millionはこの問題に対して、Reactの差分検出そのものを迂回するというアプローチを取るコンパイラ兼ランタイムです。コンポーネントを解析し、変化しうる部分だけを直接DOM操作に変換することで、Reactの土台を維持したまま描画を高速化します。
とはいえ、言葉で説明するより実際に手を動かした方が早いと思います。先に挙動を見たい方はこちらからどうぞ。
Millionとは
Millionは、AidenBai氏らが開発しているReact向けの最適化コンパイラです。npmパッケージ名はmillionで、「Make React Faster. Automatically.」を謳っています。通常のReactコンポーネントをビルド時に解析し、静的な部分と動的な部分を切り分けた「ブロック(Block DOM)」という中間表現に変換することで、実行時の差分検出コストを大幅に削減します。
主な特徴
- Reactの差分検出を迂回する - コンポーネントを解析し、変化する箇所だけを直接更新する「ブロック」に変換することで、Reactの調整(reconciliation)処理をスキップします
- 既存のReactコードにほぼ手を加えず導入できる -
npx million@latestを実行すると、Vite・Next.js・CRAなどのビルド設定に自動でプラグインを組み込んでくれます - 手動最適化のAPIも用意されている - コンパイラを使わずとも、
block()やForコンポーネントを使って特定のコンポーネントだけを狙い撃ちで最適化できます - Reactのエコシステムをそのまま使える - Hooksやコンテキスト、既存のライブラリと組み合わせて利用でき、Million独自の書き方を大量に覚える必要はありません
インストール
対話式のCLIを使うのが最も簡単な導入方法です。プロジェクトの種類(Vite・Next.jsなど)を自動判定し、必要な設定を書き込んでくれます。
npx million@latest
手動でインストールする場合はnpmで追加します。
npm install million
コンパイラを使わずランタイムAPI(block・For)だけを使う場合も、同じパッケージから読み込みます。
import { block, For } from 'million/react'
Millionのサンプルを動かす
Millionのビルドプラグイン自体はViteやBabelの設定が前提のため、この記事のプレイグラウンド上では動かせません。そこで、コンパイラを使わずに手動で最適化を適用できるblock() APIを使い、プレーンなReactだけでMillionを動かしてみます。block()は渡したコンポーネントを「ブロック」化し、propsが変化しない限り再レンダリングをスキップするラッパーです。
import { block } from 'million/react'
// 頻繁に再レンダリングされる親を持つ子コンポーネントをブロック化
const Row = block(function Row({ label, value }) {
renderCount++
return <li>{label}: {value}</li>
})
下のサンプルでは、1秒ごとに更新される親コンポーネントの中に、block()でラップした行と、ラップしていない行を並べています。親が再レンダリングされても、block化した行は自分に渡されたpropsが変わらない限り再描画されず、レンダリング回数が増えません。
コンソールを開いたまま数秒待つと、親コンポーネント(App)は1秒ごとに再レンダリングされているのに対し、BlockRowのレンダリング回数はほとんど増えないことが確認できます。一方PlainRowは親が再描画されるたびに毎回レンダリングされています。valueをタイマーに連動させて変化するようにすると、BlockRowも追従して更新されるようになります。propsが変わらない限り再描画をスキップする、というのがblock()の基本動作です。
基本的な使い方
コンパイラを導入した場合、既存のコンポーネントに手を加える必要はほとんどありません。npx million@latestでVite設定にMillionのプラグインが追加されると、ビルド時にコンポーネントが自動的にブロックへ変換されます。
// vite.config.js(CLIが自動生成する設定の例)
import million from 'million/compiler'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
million.vite({ auto: true }),
react(),
],
})
auto: trueにしておくと、Millionが各コンポーネントを解析し、最適化してよいと判断したものだけを自動でブロック化します。手動で狙い撃ちしたい場合は、コンパイラを使わずにblock()で個別にラップする方法もあります(サンプル参照)。
実践的なユースケース
リスト描画をForコンポーネントで最適化する
Reactで配列を.map()してリストを描画する場合、要素の追加・削除のたびにリスト全体の差分検出が走ります。MillionのForコンポーネントは、配列の各要素をブロック化した状態で描画し、要素単位での差分更新に切り替えることでこのコストを抑えます。
import { For } from 'million/react'
<For each={items} memo>
{(item) => <li key={item.id}>{item.text}</li>}
</For>
memoオプションを付けると、各要素はpropsが変わらない限り再レンダリングされなくなります。以下のサンプルでは、リストに項目を追加・削除するたびに、既存の項目が再レンダリングされていないことをコンソールで確認できます。
「項目を追加」を押すと、コンソールには新しく追加された項目のItemを描画ログだけが出力され、既存の項目は再描画されません。通常の.map()とキー付きレンダリングでも近い最適化は起きますが、Forは内部で各要素をブロック化しているため、要素内部の構造が複雑になっても差分検出コストが増えにくいという特徴があります。
コンパイラの自動モードで既存コンポーネントを最適化する
すでに動いているReactアプリに対して、コンポーネントを1つずつblock()で書き換えるのは現実的ではありません。Millionのコンパイラにはautoモードがあり、ビルド時に各コンポーネントを解析して、安全に最適化できると判断したものだけを自動的にブロック化してくれます。
// vite.config.js
million.vite({
auto: {
threshold: 0.05, // 変更されるDOMノードの割合がこの値以下ならブロック化
skip: ['useBadHook'], // 特定のフック使用時はスキップ
},
})
thresholdは「再レンダリングのたびに書き換わるDOMノードの割合」の閾値です。値が小さいほど、Millionは静的な部分が多いコンポーネントだけを最適化対象に絞り込みます。狙った箇所が最適化されているかは、npx million@latest実行後のビルドログや、Reactのプロファイラで確認できます。autoはビルド設定に依存するためこの記事のプレイグラウンドでは動かせませんが、block()のサンプルで見た「propsが変わらなければ再描画しない」という挙動を、コンポーネントごとに手動指定せず自動で適用できると考えると分かりやすいと思います。
まとめ
Millionは、Reactの差分検出をコンポーネント単位で迂回することで、既存のコードベースをほぼ変更せずに描画を高速化できるコンパイラです。npx million@latestによるコンパイラ導入だけでなく、block()やForといったランタイムAPIを使えば、ビルド設定を変えずにピンポイントで最適化を適用することもできます。まずは再レンダリングがボトルネックになっているコンポーネントを1つ選び、block()で試してみると、Millionが何をしているのかを体感しやすいはずです。