📦

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

2023/12/21に公開

前置き

数ヶ月前にも投稿した自作のOSSなのですが、あれ以降も時間を見つけては改善を続けています。
当時は割とベーシックな機能性しかなかったと思うのですが、window scroll対応、reverse infinite scroll対応、iOS対応、smooth scroll対応など諸々行った結果、著名なライブラリと比べて機能面で足りないということも概ね無くなったと思います。

https://github.com/inokawa/virtua

と同時に、有難いことに実際のアプリケーションコードに投入してくださる事例も少しづつですが出てきています。

私が認知していてかつpublicな情報で知れる範囲だと、NoorがRustのTauriを基盤にしたSlack likeなチャットアプリに、Lemmy clientのVoyagerIonicCapacitorを基盤にしたモバイルファーストのクロスプラットフォームアプリに、Nostr clientのLumeがこれもTauriを基盤にしたTweetDeck likeなマルチペインのアプリで使っていただいているようです。
上記は全て有名なreact-virtuosoからの置き換えのようで、知名度の無いのOSSをわざわざ使ってくれているということは、実際にメリット/デメリットを比較した上で選んだり、将来の伸び代に賭けたりしてくれているのだなと思うと有難い気持ちです。その一方、仮想スクロールの性質上、性能劣化がクリティカルな箇所に投入されるケースが多いと思うので身が引き締まる思いです。これらは特にハードな使い方をしていただいている例として上げさせていただきましたが、それ以外のものもコードを見れるものは実際の使われ方を参考にさせていただいたりしています。ありがとうございます。

…と、このようにある程度安定して動作するようになってきたこともあり、前々から一度はやってみたいと思っていたReact以外のフレームワークの対応を本腰を入れてやってみました。ちょうど最近Vue3実装が欲しいというissueが立ったのと、比較的Reactと近いAPIを持っている(という個人的なイメージがある)ことから、ターゲットはVueにしました。調べてみたところ、Reactコミュニティ同様、Vueコミュニティにある既存の仮想スクロール実装も完璧な訳ではないようで、ただの興味本意だけでなくやる意義もあるのかなという所です。

使い方

Vue 3.2以上で下記のような感じで使えます。React版とロジックを共有しているためほぼ同じ挙動になっていますが、細かいバグがあるかもしれません。絶対動かせるだろう最低限のAPIしか実装していないため、React版と比べいくつか足りないAPIがあります。後述しますが型定義も改善の余地があると思います。絶賛PR welcomeです。

<script setup>
import { VList } from "virtua/vue";

const heights = [20, 40, 180, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
  id: i,
  height: heights[i % 4] + "px",
}));
</script>

<template>
  <VList :data="data">
    <template #default="item">
      <div
        :key="item.id"
        :style="{
          height: item.height,
          background: 'white',
          borderBottom: 'solid 1px #ccc',
        }"
      >
        {{ item.id }}
      </div>
    </template>
  </VList>
</template>

対応方針

では、どうやってライブラリをFramework Agnosticにするのか?これは正直ケースバイケースといいますか、React hooksのようなutilityレベルで共通にするのか、はたまたWeb Components製のデザインシステムのようにComponentの実装まで共通にしたいのか、目指しているレベル感によっても異なると思います。

今回目指す共通化というのは、ReactとVueの両方で、できる限り似通ったAPIを持つComponentを提供し、同一の仮想スクロール効果を得られるようにする、ということになります。既存の機能について考えていくと、仮想スクロール自体のアルゴリズムはフレームワークに依存しないことが考えられます。DOMのイベントハンドリングは、Reactのイベントシステムを使う場合はフレームワークに依存しますが、元々これを使わずaddEventListenerを用いて実装していたのでフレームワーク非依存です。stateやpropsは多くのフロントエンドフレームワークに共通する概念です。JSXやReact hooksは元々はReact固有の概念ですが、VueもJSXに対応していたり、Composition APIという近しい機能があったりします。

https://zenn.dev/poteboy/articles/ce47ec05498cfa

一通りVueのAPIを調べてみて、予想通りReactと似ている機能・概念が結構あるな、という印象を抱きました。なので、フレームワークに依存しないロジックは共通処理に切り出す、フレームワークに頼った方が簡単な部分は無理に共通化しない、しかし出来る限り似たAPIを使って見た目や挙動を揃える、という方針で進めることにしました。

検討にあたって、先駆者であるTanstack VirtualAutoAnimateurqlreact-springなどのリポジトリを参考にしました。また弊OSSの詳細なアーキテクチャは過去記事を参照ください(今とは若干構成が違いますがほぼ変更ありません)。

2023/12/29追記: Ark UIも参考になります。

State更新

Tanstack Virtualに近い手法をとっています。具体的には、ReactのuseState/useReducerを単なる再レンダリングのトリガーとして使います。そうするとuseStateなどが直前のstateを保持して冪等性を保ってくれる仕組みには頼れないので、自前でmemo化なりcacheなりして、再レンダリングがトリガーされて以降は何度selectorが呼ばれても同じ値が返るように実装し、冪等性を保証します。

Vueにおいては、reactiveなobjectへの値のassignが再レンダリングのトリガーになるので、これを活用します。

https://github.com/inokawa/virtua/blob/81a26ff0e85e4060f53eb1ba92f5357aceab5d8e/src/vue/VList.ts#L105-L111

JSX

ReactのJSXについてはVue3のh()が使えます。細かい違いはありますがrefやstyleやchildrenなどもReactのものと大体同じでした。

https://github.com/inokawa/virtua/blob/81a26ff0e85e4060f53eb1ba92f5357aceab5d8e/src/vue/VList.ts#L223-L235

VueはJSXに対応しているのでこれを使うこともできるのですが、設定の簡単さのため一旦採用しませんでした。
2023/12/29追記: @jsxImportSource vueでJSXを有効にしました。h()よりも型チェックが厳しくて良いです。

https://ja.vuejs.org/guide/extras/render-function

Props

Propsの定義はVueに用意されている定義方法をそのまま使います。

仮想スクロールでデータを受け取るためのAPIですが、Reactではrender prop、次いで(とはいえ稀ですが)Array.mapしたchildrenで受け取るのが一般的だと思います。Vueではこのどちらも主流ではないと思われるため、代わりにvue-virtual-scrollerと同様のScoped Slotsを採用しています。

https://github.com/inokawa/virtua/blob/81a26ff0e85e4060f53eb1ba92f5357aceab5d8e/src/vue/VList.ts#L207

https://ja.vuejs.org/guide/components/slots#scoped-slots

Callback

onScrollのようなcallbackの実行はemitで置き換えています。

https://github.com/inokawa/virtua/blob/81a26ff0e85e4060f53eb1ba92f5357aceab5d8e/src/vue/VList.ts#L113-L115

Lifecycle method

lifecycle methodについては、まずmount/unmountはそのままonMounted/onUnmountedがあるのでこれを使っています。

https://github.com/inokawa/virtua/blob/81a26ff0e85e4060f53eb1ba92f5357aceab5d8e/src/vue/VList.ts#L125-L137

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

https://github.com/inokawa/virtua/blob/81a26ff0e85e4060f53eb1ba92f5357aceab5d8e/src/vue/VList.ts#L146-L157

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

https://github.com/inokawa/virtua/blob/81a26ff0e85e4060f53eb1ba92f5357aceab5d8e/src/vue/VList.ts#L139-L144

https://ja.react.dev/reference/react/useState#storing-information-from-previous-renders

https://ja.vuejs.org/guide/essentials/watchers

Instance method

ReactのuseImperativeHandleはVue 3.2で追加されたexposeにて置き換えました。TSのsatisfiesで型安全にしています。

https://github.com/inokawa/virtua/blob/81a26ff0e85e4060f53eb1ba92f5357aceab5d8e/src/vue/VList.ts#L169-L182

https://ja.vuejs.org/api/composition-api-setup.html#exposing-public-properties

TypeScript対応

React版と同様に、Componentのpropsやmethodに完全に型をつけた状態でpublishしたかったのですが、いまいちデファクトの方法が分からなかったです。もしより良い方法があれば教えてください。

Component定義にdefineComponent()を使っているため、これの型引数を使うことを最初は考えたのですが、overloadが複雑で目的の型定義を推論させることが難しかったです。なので代わりに引数にasでComponentOptionsWithObjectPropsの型を与え、こちらからdefineComponent()の型を導かせる方針に変更しました。

https://github.com/inokawa/virtua/blob/81a26ff0e85e4060f53eb1ba92f5357aceab5d8e/src/vue/VList.ts#L252-L288

加えて、前述の通りAPIにScoped Slotsを採用したため、propsに渡す子要素のdataと、slotから拾い上げるdataを同一の型に推論させるため、genericsを使いたいです。なのですがこれをやる上手い方法が見つからなかっため、やむなくここだけanyにしています。

defineSlotsで出来そうにも見えるのですが、これはscript setupでしか使えない?ようで

https://ja.vuejs.org/api/sfc-script-setup#defineslots

defineComponentでgenericsを使う方法も見つかったものの、使用側で関数実行の一手間を要求する必要がありそうなので断念しました。

https://logaretm.com/blog/generic-type-components-with-composition-api/#generic-component-events

Vue3.3以降のdefineComponentの関数シグネチャーでgenericsが使えるようなのですが、試したところ何故かComponentの型がDefineComponentではなくanyになってしまうでした。

https://ja.vuejs.org/api/general#function-signature

リポジトリ構成 & package構成

個人的な嗜好として、趣味プロジェクトのセットアップは出来る限り単純にしておきたく、現時点の規模ではmonorepoにはしたくありませんでした。なのでpackage.jsonは1個のままで、これはこれで問題が起こる可能性があったのですが、前述のようにVueはh()で記述していることもあって、Storybook用に@storybook/vue3、Jest用に@testing-library/vueを追加したぐらいで今のところ何とかなっています。

一点、vue/jsx.d.tsがVueのJSXの型定義をglobalに読み込むようで、既存のReactのJSXの型定義とconflictしてtsc errorが発生するようになってしまったため、patch-packageで対処しました。この問題はVue3.4で修正される見込みのようです。

https://github.com/floating-ui/floating-ui/pull/2034

React用のStorybookをGitHub Pagesにhostingしているのですが、まだVue用のStorybookは上げられていません。これも可能であればURLを分けたくないので、Storybook Compositionを使おうかなとは考えています。複数フレームワークを同一のStorybook内で扱うには現状この方法が一番良い気がしています。

2024/01/25追記: Storybook Compositionを使って両方deployしました。

https://storybook.js.org/docs/sharing/storybook-composition

同様に@virtua/vueのようなScoped Packagesによる細かいreleaseも現段階では時期尚早と考えていて、package.jsonのexportsでvirtua/vueを生やす形で、React版とVue版を同一packageかつ別entry pointでpublishしています。React/Vue共に、使っているAPIがサポートされているversionをpeerDependenciesで指定したいのですが、しかしReact/Vueが同時に使われるケースは少ないだろうことからインストール必須にはしたくなかったので、peerDependenciesMetaoptional: trueを設定しています。

https://qiita.com/kyntk/items/b3726338e73172cf0db7

ESMやexportsの順番など諸々の状況を考慮してpublish用のpackage.jsonを定義するのに、publintとarethetypeswrongが参考になるのでおすすめです。

https://publint.dev/
https://arethetypeswrong.github.io/

これから

個人的に本OSSで目指しているところとして、かつて標準化が検討され頓挫した virtual-scroller element に相当するようなことをやりたいと思っています。

https://github.com/WICG/virtual-scroller

つまりはheadless virtual scroll componentとして、仮想化に関することを出来る受け持って上手くやり、それ以外のことには出来る限り何も関与しないということです。仮想スクロールはあくまで最適化の一種であり、必要だったら使い、必要でなければ外す、これが簡単に出来ることが望ましく、仮想スクロールを導入すること自体が一大作業になりがちなのは良くないと思っています。

ブラウザ上における仮想スクロールは特定のmarkupでしか実現できません。しかし、わかりやすい例だとtrはtableやtbodyの配下にしか置けないといったように、HTML、CSS、WAI-ARIA、各種UIコンポーネントライブラリなどにもそれぞれのmarkupの要求があり、これらとぶつかって仮想化できない、仮想化できてもブラウザによって動作しなかったり実装が煩雑になってしまうケースが存在します。

直近の目標としては、これらを出来る限り幅広く対応でき、かつパフォーマンスを落とさない設計を調査検討していきたいです。あとはブラウザのバグに近い微妙な挙動(特にDesktop/iOS Safariは…)のフォロー処理の改善や、細かいbugfixなども引き続き行っていきます。

Discussion