皆さんこんにちは。現在、フロントエンドでは宣言的UIが大流行しており、そのためのライブラリもReactを筆頭に複数存在しています。
ライブラリが複数存在するところには当然のように比較や論争が起こるものですが、UIライブラリの場合はパフォーマンスがよく焦点となります。
筆者はReactの信者ですが、Reactは古株ということもあってか、最近の議論ではReactは他のライブラリと比較されるかませ犬のような役割を担うのがよく見られます。「仮想DOMは必要ない」といった類のものです。
しかし、筆者の考えではReactは今でも、もっとも真剣にパフォーマンスに取り組んでいるUIライブラリです。特に、Reactはパフォーマンスを高いユーザーエクスペリエンスのための手段として捉えており、ドキュメントにもユーザーエクスペリエンスという言葉が多く出てきます。
そこで、今回はReactが最も有利になるようなベンチマークアプリケーションを用意し、それをAngular, Preact, SolidJS, Svelte, React, Vueで実装してパフォーマンスを比較しました。
ベンチマーク内容
今回作成したベンチマークアプリケーションはこのURLからアクセスできます。
アプリケーションを開くと下の画像のような画面が現れます。
画面には1つの入力欄と、背景にポケモンの英語名と日本語名がセットになったボックスがたくさん並んでいます。下のほうにスクロールすると道具や技名などもあり、全部で3756個の要素があります。
このアプリケーションの機能は、入力欄にアルファベットを入力することで、それを英語名に含むボックスをハイライトしてくれることです。
例えばこの画像のように「b」と入力すると、ボックスのうちBulbasaur(フシギダネ)やArbok(アーボック)などがハイライトされています。
このアプリケーションを使って測るのは入力の快適さです。お察しの通り、このアプリケーションは入力欄に入力された内容に応じて3756個のボックスの内容を再計算する必要があり、入力すると高負荷となります。そのような高負荷の状況でもユーザーが入力を快適に行えるかどうかを競います。
Reactはこのような状況が得意です。実際に上記のリンクを開いてみて、各ライブラリでの入力し心地を比較してみてください。おそらくReactが最も快適に入力できるはずです(性能がいいデバイスだとあまり違いが出ないかもしれません。その場合はCPUのスロットリングを設定すると違いが出やすくなります)。
ベンチマークスクリプト
人間が触ってみても違いが一応わかるはずですが、ちゃんと数値を出して比較してみましょう。今回、入力の快適さを測るためにベンチマークスクリプトを用意しました。
ベンチマークスクリプト
async function benchmark() {
const input = document.querySelector("input");
// https://stackoverflow.com/questions/23892547/what-is-the-best-way-to-trigger-onchange-event-in-react-js
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value"
).set;
const results = [];
for (const _ of Array.from(range(0, 101))) {
results.push(await runOnce());
}
results.sort((a, b) => a - b);
const median = results[50];
console.log("median:", median);
console.log(results);
async function runOnce() {
inputClear();
await waitForIdle();
const start = performance.now();
await runTasks(100, [
inputChar("b"),
inputChar("a"),
inputChar("l"),
inputChar("l"),
// removeChar,
// removeChar,
// removeChar,
// removeChar,
]);
const end = performance.now();
await waitForIdle();
return end - start;
}
function runTasks(interval, _tasks) {
const tasks = [..._tasks];
let end;
const timerId = setInterval(() => {
const task = tasks.shift();
if (task) {
task();
return;
}
clearInterval(timerId);
end();
}, interval);
return new Promise((resolve) => (end = resolve));
}
function inputChar(char) {
return () => {
nativeInputValueSetter.call(input, input.value + char);
input.dispatchEvent(
new InputEvent("input", {
bubbles: true,
})
);
};
}
function removeChar() {
nativeInputValueSetter.call(input, input.value.slice(0, -1));
input.dispatchEvent(
new InputEvent("input", {
bubbles: true,
})
);
}
function inputClear() {
nativeInputValueSetter.call(input, "");
input.dispatchEvent(
new InputEvent("input", {
bubbles: true,
})
);
}
function waitForIdle() {
return new Promise((resolve) => {
requestIdleCallback(resolve);
});
}
function* range(start, end) {
for (let i = start; i < end; i++) {
yield i;
}
}
}
benchmark();
このスクリプトは、入力欄に"ball"の4文字を100 msずつ間をあけて入力し、入力完了してその内容が画面に反映されるまでの時間を計測します。出力されるのは101回実行した中央値です。
今回、筆者のMacbook Pro上のGoogle Chromeでこのスクリプトを実行しました。ただし、デフォルトの状態だと負荷が足りないので、CPUの4x slowdownをシミュレーションする設定を有効にしました。
結果は以下の通りです。
React | Svelte | Angular | Preact | SolidJS | Vue |
---|---|---|---|---|---|
513.6 ms | 802.5 ms | 832.9 ms | 948.6 ms | 1029 ms | 1179 ms |
このように、Reactが他のUIライブラリに差をつけてパフォーマンスの良さを示しています。
実際に試してみた方から、M1 Macではこの通りの結果にならずReactが遅いという報告が上がっています。残念ながら、筆者がM1 Macを持っていないため原因は不明です。
追記1: GitHubでAngularのngForにtrackByが指定されていないという指摘をいただいたのでそれを修正して再実験しましたが、残念ながら筆者の環境ではベンチマークのスコア向上には繋がりませんでした。M1 Macではないからかもしれません。原理的には、レンダリングを100ms以内に全て終わらせられればReactに追いつくことができます。(ただし、その場合も負荷を上げるとReactと再び差がつきます。)
分析
このベンチマークでReactに有利な結果となったのは、Reactがジョブスケジューリングに力を入れているからだと考えられます。とくに、React 18で登場したトランジションを用いると、優先度の低いステート更新と優先度の高いステート更新を区別することができます。今回のアプリケーションでは、入力欄への入力を優先度の高いステート更新として扱い、後ろの3756個のボックスへの反映を優先度の低い更新として扱うことができます。優先度の低い更新によって高負荷になっている状態でも、ユーザーの入力は優先度が高いので割り込んで即座に画面に反映して高いユーザーエクスペリエンスを提供することができます。
そして、実は仮想DOMがこの性質に有利に働いています。仮想DOMがある場合、ステート更新により大まかにはコンポーネントの再レンダリング→仮想DOMツリーの構築→実DOMに反映 という動きが起こります。現在のDOM APIでは、実DOMに反映するフェーズが始まってしまうともう中断して割り込むことはできません。
Reactのスケジューリング機構では、その前までなら割り込みができるようになっています。最大限割り込みを可能にするために、仮想DOM等を用いて実DOMの操作を後回しにしていると解釈できます。
一方、他のライブラリは同等のスケジューリングを実装していないので、1文字入力されるたびに高負荷の更新が入り、次の入力の更新を阻害してしまうためにベンチマーク結果が悪くなってしまいます。特に、SvelteやSolidJSなど非仮想DOMのライブラリでは、リアクティブシステムと実DOMが直結している限り、このようなスケジューリングは原理的に不可能ですね。
まとめ
この記事ではReactに有利なパフォーマンスベンチマークを示しました。Reactは、高負荷の状態で素早くユーザーの入力に反応しなければならないシチュエーションにおいて、他のUIライブラリよりも高い反応速度を発揮します。
Reactが有利になる要因は、Reactのスケジューリング機構です。高負荷の状態でもユーザー入力が来た場合にはそちらの対応を高優先度で行うことができます。そんなに高負荷になる状況は無いだろと思われるかもしれませんが、負荷の主な原因はレンダリングされるコンポーネントの数です。大規模なアプリケーションになればなるほど、だんだんReactが有利な状況に近づいていくのです。
Reactは、スケジューリングの他にもStreaming SSRやServer Componentsなど、ユーザーエクスペリエンスに配慮した実践的なパフォーマンス施策を多く打ち出しています。レンダリング速度そのものに注目することにももちろん意味はありますが、より多角的な目線でUIライブラリを評価してみるのも面白いでしょう。
おことわり
筆者はReact以外のUIライブラリの使用経験があまりないので、今回のベンチマークは各ライブラリのチュートリアルを一通り読んで作ったものです。もし悪い実装のせいで遅くなっているところがあればぜひご指摘ください。
ただし、Reactの実装も含めてあまり細かなチューニングはせずに最大限自然かつ簡潔な実装にしています。マイクロチューニングで競うのはご遠慮ください。
追記: チューニングあり部門 開催!
やはり、ベンチマークを見せられるとチューニングしたくなるのが人間の性というものです。そこで、筆者がそれぞれのアプリをできる限りチューニングしていてReactに迫る性能を出せるかどうか今挑戦しています。自分でやってみたい方も歓迎します。
ただ、この記事で説明しているReactの強みはスケジューリングにあります。コメント欄などでも議論していただいている通り、レンダリングにかかる時間を細かく改善しても、データ量を増やしたりして負荷を高くすれば再びReactに差をつけられてしまいます。(実践的には一定のパフォーマンス基準を達成できれば十分とはいえ)本質的にReactに張り合うためには、Reactがやってくれるのと同等の、中断可能なスケジューリングを再現する必要があります。
やってできないことはないでしょうが、関数1つで全部やってくれるReactに比べると大がかりになるでしょう。次回はそれを鑑賞する回になるでしょう(完成させられれば)。
→こちらが会場です。