🪨

自作の React/Vue 用仮想スクロール OSS を Solid に対応させました

2024/02/14に公開

前回のVue対応に引き続き、自作のReact用OSSのSolid対応を行いました。

React対応時の初期アーキテクチャについての話、Vue対応時の話などは過去記事を参照ください。

https://github.com/inokawa/virtua

https://zenn.dev/inokawa/articles/6ba1308d364850

https://zenn.dev/inokawa/articles/94a73a2f035f0b

なぜやるか?

クロスブラウザで可変高さの仮想スクロールを上手く掃くコアロジックや、簡潔なセットアップを可能にする全体のデザインに意味があると思っていて、これらはフレームワークに依存せず価値を提供できるものだと思っています。これを活用し少しでもシェアを広げたい、フィードバックを得られる機会を増やしたい、というのが気持ちとしてはあります。

しかし、そのために全体のメンテナンス性などが犠牲になっては意味がありません。その点、本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などと似ているとしばしば言われているものの、これらとは異なる独自のコンセプトも多く持っています。動作するよう実装するにあたって、その辺りの癖を掴むのに少し時間がかかりました。

https://www.solidjs.com/guides/comparison

触った印象だと、Signalという仕組みをもとにボトムアップに全体が構築されているように感じられます。もしSolidを触る場合は作者のRyan CarniatoさんによるSignalの解説記事を読んでおくと理解が早いかもしれません。

https://dev.to/ryansolid/building-a-reactive-library-from-scratch-1i0p

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をセットアップしておくと、不正な記述をしにくく良いと思います。

https://github.com/solidjs-community/eslint-plugin-solid

Vueの挙動をベースに考えると、<div {...props}>のような記法は一見動作しなそうにも思えますが、正しくreactiveになってくれるようです。

https://github.com/solidjs/solid/issues/75

Callback

onScrollのようなcallbackの渡し方は、Reactと変わりない印象です。

Slice children

props経由で渡されたデータについて、画面に見えている範囲のデータのみを切り取り、これらをユーザーが定義したcomponentを使ってDOMにrenderingする。つまりは仮想スクロールという機能の根幹になりますが、Solid対応ではこの部分の実装に時間がかかりました。

まず、SolidにはReact/Vue/Svelte等のようなkeyという概念は存在しません。arrayをrenderingする組み込みのhelperであるForIndexは、データの参照がそのままDOMの実体と結びつく作りになっています。

https://github.com/solidjs/solid/discussions/366

これらのhelperを使えばOK、という訳にもいきません。何故なら、ForIndexに渡すarrayについても参照で同一性判定がなされるようで、arrayを切り取って作り直す度に、renderingした全てのDOMが毎回削除、および新規挿入されてしまいます。これでは逆にパフォーマンスが劣化してしまうので、あるデータから生成されたDOMは、新規挿入された後、画面外に出るまでは削除されないこと、また更新はpatchで行われることが望ましいです。

ではどうするか。前述したように、SolidはSignalという仕組みで全体が成り立っています。つまり、Forのような組み込みのhelperも、それぞれより低次のprimitiveを使って作られています。なので今回は、Forを構成する要素であるmapArrayの実装を参考に、今回の要件を満たす独自の機構を自作しました。

https://github.com/solidjs/solid/blob/c71b6fe278dae94e032f05c639c494949440d47d/packages/solid/src/render/flow.ts#L34-L49
https://github.com/solidjs/solid/blob/c71b6fe278dae94e032f05c639c494949440d47d/packages/solid/src/reactive/array.ts#L47-L51

その実装が以下になります。確認した限りは問題なく動いているように見えますが、細かいprimitiveの挙動についてはドキュメントもなく実装を間違っている可能性もあるので、もし問題が見つかりましたらPRいただけると助かります…

https://github.com/inokawa/virtua/blob/38f063531ca55b551e0d1bce4a63439245bec598/src/solid/RangedFor.tsx#L27-L82

createRootを使って、データに対応するreactive scopeの生成・更新・破棄を自前管理しているのが重要な点です。
createRootの概念を掴むにあたって、以下が参考になりました。

https://github.com/solidjs/solid/discussions/719
https://angularindepth.com/posts/1289/solidjs-reactivity-to-rendering

Lifecycle method

mountはonMountがあるのでこれを使います。

https://github.com/inokawa/virtua/blob/38f063531ca55b551e0d1bce4a63439245bec598/src/solid/Virtualizer.tsx#L209

unmountはonCleanupを使います。Vueなどと違い、onCleanupは現在のreactive scopeのcleanupという意味を持つため、onMountのcallbackの中で実行する必要があります。

https://github.com/inokawa/virtua/blob/38f063531ca55b551e0d1bce4a63439245bec598/src/solid/Virtualizer.tsx#L231-L241

useLayoutEffectでcommit直後に同期的にDOM操作を行う使い方については、createEffectを使って代替しています。

https://github.com/inokawa/virtua/blob/38f063531ca55b551e0d1bce4a63439245bec598/src/solid/Virtualizer.tsx#L255-L259

render中にuseState/useReducerのstate更新を行うことでrender結果を破棄する、特殊な使い方については、createComputedでrender直前に更新を行うことで代替しています。

https://github.com/inokawa/virtua/blob/38f063531ca55b551e0d1bce4a63439245bec598/src/solid/Virtualizer.tsx#L244-L253

Instance method

Solidには、ReactのuseImperativeHandleforwardRefのような、親が子のメソッドを呼び出すためのcomponent間の規約は特に定められていないようです。なので今回は、onMountのタイミングでReactのref callbackのように親にhandleを渡す形で、同等の機能性を実現しています。

https://github.com/inokawa/virtua/blob/38f063531ca55b551e0d1bce4a63439245bec598/src/solid/Virtualizer.tsx#L210-L225

Storybook

Storybookは公式のstorybook-solidjsstorybook-solidjs-viteを使っています。まだbetaですが特に問題なく使えました。Deployには例の如くStorybook compositionを活用しています。

https://github.com/storybookjs/solidjs

Build

ビルドには他フレームワークと同様rollupを使用しています。前提として、Solidはdom-expressionsというライブラリの上で動いています。JSXをdom-expressionsの実装に変換することは、TypeScript単体ではできず、現状babel pluginのbabel-preset-solidが必須なようです。これをビルド経路に挟んで変換をかけています。vite-plugin-solidなどもベースはこのpluginのラッパーです。

https://github.com/ryansolid/dom-expressions

https://github.com/solidjs/solid/tree/main/packages/babel-preset-solid

Test

Solid Startのリポジトリなどを参考Vitestをセットアップしました…が何故かsnapshot testが正しく取れない現象に見舞われており、テストを書けるところまで辿り着けていません。現在調査中です…

https://github.com/solidjs/solid-start/blob/main/examples/with-vitest/vitest.config.ts

フレームワークごとのビルド設定の出し分けには、Vitest workspaceを活用する予定でした。

https://vitest.dev/guide/workspace

https://zenn.dev/you_5805/articles/vitest-workspace

これから

大きな機能追加をするというよりは、より洗練させ削ぎ落としていく方向に進む予定です。基本性能の改善、edge caseの対応を地道に行っていきます。

Discussion