自作の React/Vue 用仮想スクロール OSS を Solid に対応させました
前回のVue対応に引き続き、自作のReact用OSSのSolid対応を行いました。
React対応時の初期アーキテクチャについての話、Vue対応時の話などは過去記事を参照ください。
なぜやるか?
クロスブラウザで可変高さの仮想スクロールを上手く掃くコアロジックや、簡潔なセットアップを可能にする全体のデザインに意味があると思っていて、これらはフレームワークに依存せず価値を提供できるものだと思っています。これを活用し少しでもシェアを広げたい、フィードバックを得られる機会を増やしたい、というのが気持ちとしてはあります。
しかし、そのために全体のメンテナンス性などが犠牲になっては意味がありません。その点、本OSSは問題が起こるならコアロジックについてであり、各フレームワークとの接続部分でトラブルが起こる可能性は少なく、あっても大きな問題にはならないだろうと考えられました。実装もだいぶ成熟してきていて、かつ私の仮想スクロールドメインに対する理解もだいぶ深まってきていて、今後大きな変更が入る可能性もないだろうという予測もできました。なので、比較的低コストの対応で機会が増やせるならやるべきだろうと判断しました。
後は、以前Vueに対応した時にも感じたのですが、実際に各フレームワークの違いを触って確かめるのは学びがあるなと思いました。Solidについて、Reactと見た目が似ている、Vueと同じfine grained reactivity、Svelteのようにコンパイル前提、等々言われていますが、実際に触ってみるとパフォーマンスを最優先にしつつ書きやすさも確保したAPIデザインという印象で、それと同時に今後伸びる可能性を感じたのが、対応を決めた理由の1つではあります。
使い方
React/Vue用の実装と似たような感覚で使えると思います。もし他の実装と比較して怪しい挙動など発見した場合は、PRいただけると助かります。
import { VList } from "virtua/solid";
export const App = () => {
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => sizes[i % 4]);
return (
<VList data={data} style={{ height: "800px" }}>
{(d, i) => (
<div
style={{
height: d + "px",
"border-bottom": "solid 1px #ccc",
background: "#fff",
}}
>
{i}
</div>
)}
</VList>
);
};
対応方針
基本方針は前回記事と同じで、ReactとSolidの両方で、できる限り似通ったAPIを持つComponentを提供し、同一の仮想スクロール効果を得られるようにする、ということになります。前回のVue対応時にあらかた問題になる箇所を潰していたため、今回の対応ではコアロジックには全く手を加えずSolid対応を行うことができました。
ただ、公式に書かれている比較記事が結構的を得ていると言いますか、SolidはReact/Vue/Svelteなどと似ているとしばしば言われているものの、これらとは異なる独自のコンセプトも多く持っています。動作するよう実装するにあたって、その辺りの癖を掴むのに少し時間がかかりました。
触った印象だと、Signalという仕組みをもとにボトムアップに全体が構築されているように感じられます。もしSolidを触る場合は作者のRyan CarniatoさんによるSignalの解説記事を読んでおくと理解が早いかもしれません。
JSX
本リポジトリでは既にReactのJSX用の設定がセットアップされています。これとconflictしないよう@jsxImportSource solid-js
をファイル単位で指定しています。TypeScriptによる型チェックについては以上でOKで、ビルドには追加設定が必要になります。
細かい点では、style objectのfield名が、Reactのようなcamel caseではなくkebab caseになるため注意が必要です。
Props
propsについては、ReactのFunction Componentとほぼ同じ見た目ですが、Vueのようなgetterを使った依存のトラッキングがあります。eslint-plugin-solid
をセットアップしておくと、不正な記述をしにくく良いと思います。
Vueの挙動をベースに考えると、<div {...props}>
のような記法は一見動作しなそうにも思えますが、正しくreactiveになってくれるようです。
Callback
onScrollのようなcallbackの渡し方は、Reactと変わりない印象です。
Slice children
props経由で渡されたデータについて、画面に見えている範囲のデータのみを切り取り、これらをユーザーが定義したcomponentを使ってDOMにrenderingする。つまりは仮想スクロールという機能の根幹になりますが、Solid対応ではこの部分の実装に時間がかかりました。
まず、SolidにはReact/Vue/Svelte等のようなkeyという概念は存在しません。arrayをrenderingする組み込みのhelperであるFor
やIndex
は、データの参照がそのままDOMの実体と結びつく作りになっています。
これらのhelperを使えばOK、という訳にもいきません。何故なら、For
やIndex
に渡すarrayについても参照で同一性判定がなされるようで、arrayを切り取って作り直す度に、renderingした全てのDOMが毎回削除、および新規挿入されてしまいます。これでは逆にパフォーマンスが劣化してしまうので、あるデータから生成されたDOMは、新規挿入された後、画面外に出るまでは削除されないこと、また更新はpatchで行われることが望ましいです。
ではどうするか。前述したように、SolidはSignalという仕組みで全体が成り立っています。つまり、For
のような組み込みのhelperも、それぞれより低次のprimitiveを使って作られています。なので今回は、For
を構成する要素であるmapArray
の実装を参考に、今回の要件を満たす独自の機構を自作しました。
その実装が以下になります。確認した限りは問題なく動いているように見えますが、細かいprimitiveの挙動についてはドキュメントもなく実装を間違っている可能性もあるので、もし問題が見つかりましたらPRいただけると助かります…
createRoot
を使って、データに対応するreactive scopeの生成・更新・破棄を自前管理しているのが重要な点です。
createRoot
の概念を掴むにあたって、以下が参考になりました。
Lifecycle method
mountはonMount
があるのでこれを使います。
unmountはonCleanup
を使います。Vueなどと違い、onCleanup
は現在のreactive scopeのcleanupという意味を持つため、onMount
のcallbackの中で実行する必要があります。
useLayoutEffect
でcommit直後に同期的にDOM操作を行う使い方については、createEffect
を使って代替しています。
render中にuseState
/useReducer
のstate更新を行うことでrender結果を破棄する、特殊な使い方については、createComputed
でrender直前に更新を行うことで代替しています。
Instance method
Solidには、ReactのuseImperativeHandle
やforwardRef
のような、親が子のメソッドを呼び出すためのcomponent間の規約は特に定められていないようです。なので今回は、onMount
のタイミングでReactのref callbackのように親にhandleを渡す形で、同等の機能性を実現しています。
Storybook
Storybookは公式のstorybook-solidjs
とstorybook-solidjs-vite
を使っています。まだbetaですが特に問題なく使えました。Deployには例の如くStorybook compositionを活用しています。
Build
ビルドには他フレームワークと同様rollupを使用しています。前提として、Solidはdom-expressionsというライブラリの上で動いています。JSXをdom-expressionsの実装に変換することは、TypeScript単体ではできず、現状babel pluginのbabel-preset-solid
が必須なようです。これをビルド経路に挟んで変換をかけています。vite-plugin-solid
などもベースはこのpluginのラッパーです。
Test
Solid Startのリポジトリなどを参考Vitestをセットアップしました…が何故かsnapshot testが正しく取れない現象に見舞われており、テストを書けるところまで辿り着けていません。現在調査中です…
フレームワークごとのビルド設定の出し分けには、Vitest workspaceを活用する予定でした。
これから
大きな機能追加をするというよりは、より洗練させ削ぎ落としていく方向に進む予定です。基本性能の改善、edge caseの対応を地道に行っていきます。
Discussion