自作の React 用仮想スクロール OSS を Vue に対応させました
前置き
数ヶ月前にも投稿した自作のOSSなのですが、あれ以降も時間を見つけては改善を続けています。
当時は割とベーシックな機能性しかなかったと思うのですが、window scroll対応、reverse infinite scroll対応、iOS対応、smooth scroll対応など諸々行った結果、著名なライブラリと比べて機能面で足りないということも概ね無くなったと思います。
と同時に、有難いことに実際のアプリケーションコードに投入してくださる事例も少しづつですが出てきています。
私が認知していてかつpublicな情報で知れる範囲だと、NoorがRustのTauriを基盤にしたSlack likeなチャットアプリに、Lemmy clientのVoyagerがIonicとCapacitorを基盤にしたモバイルファーストのクロスプラットフォームアプリに、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という近しい機能があったりします。
一通りVueのAPIを調べてみて、予想通りReactと似ている機能・概念が結構あるな、という印象を抱きました。なので、フレームワークに依存しないロジックは共通処理に切り出す、フレームワークに頼った方が簡単な部分は無理に共通化しない、しかし出来る限り似たAPIを使って見た目や挙動を揃える、という方針で進めることにしました。
検討にあたって、先駆者であるTanstack Virtual、AutoAnimate、urql、react-springなどのリポジトリを参考にしました。また弊OSSの詳細なアーキテクチャは過去記事を参照ください(今とは若干構成が違いますがほぼ変更ありません)。
2023/12/29追記: Ark UIも参考になります。
State更新
Tanstack Virtualに近い手法をとっています。具体的には、ReactのuseState/useReducerを単なる再レンダリングのトリガーとして使います。そうするとuseStateなどが直前のstateを保持して冪等性を保ってくれる仕組みには頼れないので、自前でmemo化なりcacheなりして、再レンダリングがトリガーされて以降は何度selectorが呼ばれても同じ値が返るように実装し、冪等性を保証します。
Vueにおいては、reactiveなobjectへの値のassignが再レンダリングのトリガーになるので、これを活用します。
JSX
ReactのJSXについてはVue3のh()
が使えます。細かい違いはありますがrefやstyleやchildrenなどもReactのものと大体同じでした。
VueはJSXに対応しているのでこれを使うこともできるのですが、設定の簡単さのため一旦採用しませんでした。
2023/12/29追記: @jsxImportSource vue
でJSXを有効にしました。h()
よりも型チェックが厳しくて良いです。
Props
Propsの定義はVueに用意されている定義方法をそのまま使います。
仮想スクロールでデータを受け取るためのAPIですが、Reactではrender prop、次いで(とはいえ稀ですが)Array.mapしたchildrenで受け取るのが一般的だと思います。Vueではこのどちらも主流ではないと思われるため、代わりにvue-virtual-scrollerと同様のScoped Slotsを採用しています。
Callback
onScroll
のようなcallbackの実行はemit
で置き換えています。
Lifecycle method
lifecycle methodについては、まずmount/unmountはそのままonMounted
/onUnmounted
があるのでこれを使っています。
useLayoutEffect
でcommit直後に同期的にDOM操作を行う使い方については、watch
のflush: "post"
optionを使って代替しています。
render中にuseState
/useReducer
のstate更新を行うことでrender結果を破棄する、特殊な使い方についてもwatch
で代替しています。
Instance method
ReactのuseImperativeHandle
はVue 3.2で追加されたexpose
にて置き換えました。TSのsatisfiesで型安全にしています。
TypeScript対応
React版と同様に、Componentのpropsやmethodに完全に型をつけた状態でpublishしたかったのですが、いまいちデファクトの方法が分からなかったです。もしより良い方法があれば教えてください。
Component定義にdefineComponent()
を使っているため、これの型引数を使うことを最初は考えたのですが、overloadが複雑で目的の型定義を推論させることが難しかったです。なので代わりに引数にasでComponentOptionsWithObjectProps
の型を与え、こちらからdefineComponent()
の型を導かせる方針に変更しました。
加えて、前述の通りAPIにScoped Slotsを採用したため、propsに渡す子要素のdataと、slotから拾い上げるdataを同一の型に推論させるため、genericsを使いたいです。なのですがこれをやる上手い方法が見つからなかっため、やむなくここだけanyにしています。
defineSlots
で出来そうにも見えるのですが、これはscript setupでしか使えない?ようで
defineComponent
でgenericsを使う方法も見つかったものの、使用側で関数実行の一手間を要求する必要がありそうなので断念しました。
Vue3.3以降のdefineComponent
の関数シグネチャーでgenericsが使えるようなのですが、試したところ何故かComponentの型がDefineComponent
ではなくanyになってしまうでした。
リポジトリ構成 & 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で修正される見込みのようです。
React用のStorybookをGitHub Pagesにhostingしているのですが、まだVue用のStorybookは上げられていません。これも可能であればURLを分けたくないので、Storybook Compositionを使おうかなとは考えています。複数フレームワークを同一のStorybook内で扱うには現状この方法が一番良い気がしています。
2024/01/25追記: Storybook Compositionを使って両方deployしました。
同様に@virtua/vue
のようなScoped Packagesによる細かいreleaseも現段階では時期尚早と考えていて、package.jsonのexportsでvirtua/vue
を生やす形で、React版とVue版を同一packageかつ別entry pointでpublishしています。React/Vue共に、使っているAPIがサポートされているversionをpeerDependencies
で指定したいのですが、しかしReact/Vueが同時に使われるケースは少ないだろうことからインストール必須にはしたくなかったので、peerDependenciesMeta
にoptional: true
を設定しています。
ESMやexportsの順番など諸々の状況を考慮してpublish用のpackage.jsonを定義するのに、publintとarethetypeswrongが参考になるのでおすすめです。
これから
個人的に本OSSで目指しているところとして、かつて標準化が検討され頓挫した virtual-scroller element に相当するようなことをやりたいと思っています。
つまりはheadless virtual scroll componentとして、仮想化に関することを出来る受け持って上手くやり、それ以外のことには出来る限り何も関与しないということです。仮想スクロールはあくまで最適化の一種であり、必要だったら使い、必要でなければ外す、これが簡単に出来ることが望ましく、仮想スクロールを導入すること自体が一大作業になりがちなのは良くないと思っています。
ブラウザ上における仮想スクロールは特定のmarkupでしか実現できません。しかし、わかりやすい例だとtrはtableやtbodyの配下にしか置けないといったように、HTML、CSS、WAI-ARIA、各種UIコンポーネントライブラリなどにもそれぞれのmarkupの要求があり、これらとぶつかって仮想化できない、仮想化できてもブラウザによって動作しなかったり実装が煩雑になってしまうケースが存在します。
直近の目標としては、これらを出来る限り幅広く対応でき、かつパフォーマンスを落とさない設計を調査検討していきたいです。あとはブラウザのバグに近い微妙な挙動(特にDesktop/iOS Safariは…)のフォロー処理の改善や、細かいbugfixなども引き続き行っていきます。
Discussion