皆様、お疲れ様です。iMasanariと申します。
いきなりですが、下記の記事をご存知でしょうか。
上記は、文字入力の快適さを計測するベンチマークによって、Reactは高いユーザーエクスペリエンスを実現できるライブラリである、という記事です。ベンチマークスクリプトやソースコードは公開されており、自由にチューニングを施してReactに挑戦できるようになっています。
興味がある方は、以上のレギュレーションの中で高速化に挑戦してみてください。純粋なレンダリング高速化で筆者の鼻を明かすのもよし、Reactのスケジューリングに負けない機構を自前で実装するもよし、レギュレーションの抜け穴を見つけてReactを打ち負かすのもよしです。
この記事では、上記に挑戦するためのUIライブラリを自作していきたいと思います。つまりは、「Reactに有利なベンチマーク」に有利なライブラリを作成します!
実装
Reactに勝つために、まずはベンチマーク v2の inputDelay
(文字入力のラグ) に注目し、ユーザーの文字入力中、UIスレッドで重い処理を行わない戦略で行きます。
なお、今回ベンチマークを動かすのに必要のない最適化や機能は行わないこととします。
戦略その1: スレッドの分離
作成するライブラリはjsxと仮想DOMを使ったよくある形式にしますが、メイン処理である差分検出をWeb Workerで行うようにします。つまりUIスレッドでDOMの更新だけを行えるように、Workerスレッドでは次のようなリストを作成してUIスレッドに送ります。
[
// DOMの新規作成 <div id="create" />
{ type: 'PLACEMENT', domId: 3, parentId: 1, nodeType: 'div', props: { id: 'create' } },
// DOMの更新 class="update"
{ type: 'UPDATE', domId: 1, props: { className: 'update' } },
// DOMの削除
{ type: 'DELETION', domId: 2 },
]
これによって inputDelay
の値は小さくなります。しかし、これだけではまだReactに勝つことはできません。文字入力と大量のDOM更新が重なってしまうと、入力にラグが発生してしまうからです。
戦略その2: スケジューリング
上記の問題を解決するため、Workerスレッドでの処理にスケジューリング機能を実装します。これは、仮想DOMの差分検出処理中にステートが更新された際に、これまでの差分検出を破棄し、やり直す機能です。差分検出が割り込みなく完了したら、その内容をDOMに反映します。
一見、スケジューリングとは関係ないように見えますが、差分検出処理が重い場合にデバウンスのような処理になり、ユーザー操作(文字入力など)が終わったあとにDOM反映がされるようになります。
上記のスケジューリングを行うためには、Reactと同じく仮想DOMをfiberというアルゴリズムを実装する必要があります。fiberは差分検出処理の中断と再開、破棄ができる仮想DOMアルゴリズムです。
fiberの実装には、Didactというライブラリをほぼ丸パクリ参考にしました。これは、Reactを理解するための簡易版Reactです。詳しくは下記記事を参照してください。英語で書かれていますが、勉強になるのでオススメです。
Didactではスケジューリングに requestIdleCallback
を使用していますが、Web Workerでは使えません。なので、MessageChannel
を使用して自作します。本家Reactのような優先度付きスケジューリングは実装しませんが、今回の目的であるDOM更新処理のデバウンスには問題ありません。
スケジューリング機能を実装したら renderingOverhead
の項目を良くするために、ひたすらチューニングです。高速化しそうな内容をひたすら行っていきます。
完成
というわけで、完成したライブラリがこちらになります。
ライブラリ名は「Fiberworks」と、上記の特徴をそのまま持ってきました。花火っぽい名前で気に入っています。
早速、使い方を見ていきましょう。
下記のコードはエントリーポイントです。main.js
では createApp
でWeb Workerを指定し、render
メソッドを呼び出しています。
これによって、worker.js
で登録したコンポーネントが画面に描画されます。
// UIスレッド側コード
import { createApp } from '@imasanari/fiberworks/client'
createApp(new Worker('./worker.js'), document.getElementById('app')).render()
// Workerスレッド側コード
import { registerApp } from '@imasanari/fiberworks'
import App from './App.js'
registerApp(<App />)
ステート管理には、Reactと同じく useState
hooksを用意しています。
また、イベント登録時には createEventBridge
を使用してください。引数の関数の内容がクライアント側で実行され、その結果をWorker側で使用することができます。
// Workerスレッド側コード
import { createEventBridge, useState } from '@imasanari/fiberworks'
// イベント属性(onChange等)に入れると、引数に指定した関数がクライアント側で実行され、その結果がWorker側に送られる
const inputEvent = createEventBridge((event) => {
return event.currentTarget.value
})
export default () => {
const [value, setValue] = useState('')
return (
<div>
<input
value={value}
// 引数の値「value」はクライアント側で実行した、`event.currentTarget.value`
onInput={inputEvent(value => setValue(value))}
/>
<span>{value}</span>
</div>
)
}
計測
今回使用するベンチマークは、上記記事のハードモード v2です。
比較として、下記リンクにあるUIライブラリのアプリケーションを使用します。コードはライブラリ毎のマイクロチューニングを行っていない状態です。
自作したFiberworksも、アプリ側コードでは自然で簡潔な実装を行いました。
コードはこちらになります。
今回、筆者のMacBook Air (M1)で計測を行いました。
結果は、以下のとおりです。
ライブラリ | inputDelay | renderingOverhead |
---|---|---|
Fiberworks | 48.9 | 1,380.6 |
React | 158.0 | 1,374.8 |
Angular | 1,136.0 | 1,360.5 |
Svelte | 1,340.1 | 1,664.0 |
Preact | 1,465.2 | 1,848.9 |
Solid | 1,781.2 | 2,253.8 |
Vue | 1,894.9 | 2,461.5 |
見事、inputDelay
の値で1位を取ることができました! renderingOverhead
はReactに僅差で負けて、残念ながら3位となりました。が、僅差なので、総合的に実質Reactに勝ったと言えるのではないでしょうか。
感想
以上、「自作ライブラリでReact有利ベンチマークに挑んでみた」でした。
今回作成したFiberworksですが、Reactに勝つためのチューニングは一旦終了とし、今回実装を省略した箇所の実装をのんびりと行っていきたいと思います。例えば、key属性による差分検出アルゴリズムは未実装ですし、Reactの useEffect
相当の機能はインターフェースをどうするかの所から未定です。それらを実装した上で、いつかまたReactに挑戦できたらなと思います。
改めて、今回作成したライブラリはこちらです。
最後まで読んでいただき、ありがとうございました。
この記事が面白かった!という方は LGTMボタン を、Fiberworksが気になる!という方は 記事のストック をよろしくお願いします!