React StatelyとReact Aria ComponentにおけるCollectionオブジェクトの構築方法
はじめに
React Aria Components – React Ariaとは、アクセシブルなヘッドレスUIを提供するライブラリです。
React Aria ComponentsはReact Spectrum Librariesと呼ばれるライブラリ群の一部で、同じライブラリ群に含まれるReact AriaとReact Statelyを利用して実装されています。
例えば、ListBox – React Ariaと呼ばれるコンポーネントは、React StatelyのuseListState HooksとReact AriaのuseListBoxを利用して実装されています。
- https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/ListBox.tsx#L106
- https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/ListBox.tsx#L148-L152
ListBoxはReact StatelyではCollection componentsと定義されています。また、useListStateのInterfaceを見ると、Collection Interfaceが重要なように見えます。
Collection interfaceのBuilding a collectionを見ると、Collectionの構築方法にはいくつか方法があるとのことなので調査してみます。
React Ariaの例
useListState
と一緒に利用されるuseListBox
のExampleには以下のような利用例があります。
import {Item, useListState} from 'react-stately';
function ListBox<T extends object>(props: AriaListBoxProps<T>) {
// Create state based on the incoming props
let state = useListState(props);
// (中略)
return (
<>
// (中略)
</>
);
}
<ListBox label="Alignment" selectionMode="single">
<Item>Left</Item>
<Item>Middle</Item>
<Item>Right</Item>
</ListBox>
上記からuseListState()
の内部でprops.children
からCollectionが構築されていると予想できます。実際にuseListState()
の実装を見ると、以下の通り、useCollection()
を使ってCollectionオブジェクトを得ています。
useCollection()
の実装を見ると、以下の通り、builder.build()
を使ってnodes
を取得し、factory()
関数でCollectionを作成しているようです。
builder.build()
の中で何をしているか見てみると、以下のようにchildrenそれぞれに対して、element.type.getCollectionNode()
を呼び出しているようです。
ここで、冒頭のExampleのChildrenを改めて見てみると、<Item>
というReactElementが使われています。<Item>
のコードは以下の通りです。
https://github.com/adobe/react-spectrum/blob/main/packages/@react-stately/collections/src/Item.ts
戻り値はnull
となっていますが、getCollectionNode()
関数がオブジェクトに追加されているのがわかります。この関数がbuilder.build()
経由で呼び出されることで最終的にCollectionが構築されることが分かりました。
以上の内容は、Collection interfaceのBuilding a collectionの以下の記述とも一致します。
As discussed on the Collection Components page, React Spectrum implements a JSX-based API for defining collections. This is implemented by the useCollection hook. useCollection iterates over the JSX tree, and recursively builds a collection of Node objects. Each node includes the value of the item it represents, along with some metadata about its place in the collection.
These nodes are then wrapped in a class that implements the Collection interface, and exposes the nodes. State management hooks like useListState and useTreeState handle building the collection and exposing the necessary interface to components
React Aria ComponentのListBoxの実装
次にReact Aria ComponentのListBoxを見てみましょう。
利用例は以下のとおりです。
import {ListBox, ListBoxItem} from 'react-aria-components';
<ListBox aria-label="Favorite animal" selectionMode="single">
<ListBoxItem>Aardvark</ListBoxItem>
<ListBoxItem>Cat</ListBoxItem>
<ListBoxItem>Dog</ListBoxItem>
<ListBoxItem>Kangaroo</ListBoxItem>
<ListBoxItem>Panda</ListBoxItem>
<ListBoxItem>Snake</ListBoxItem>
</ListBox>
実装は以下のとおりです。
React Ariaの例にあった方法と同じかと思いきや、useListState()
の呼び出し前にすでにCollectionが構築されていることがわかります(props.children
もnull
にされている)。
こちらのCollectionはuseCollection()
によって構築されているようなので、そちらを確認してみます。ちなみに、ここのuseCollection()
は先程見たReact StatelyのuseCollection()
とは別物で、React Aria Componentから提供されているものになります。
まずは、useCollectionDocument()
の実装から見てみます。
Documnetと呼ばれるオブジェクトを作成し、最終的に返すCollectionはuseSyncExternalStoreを使ってDocumentから取得しているようです。ただ、上のコードから分かる通り、このDocumentにはまだデータが登録されていなさそうなので、データが登録されているところを調べてみます。
次に、useCollectionPortal()
の実装を見てみます。
いろいろな処理をやっていますが、最終的にはChildrenを含むReactElementと先ほど作成したDocumentを引数にcreatePortalを呼び出しているようです。
createPortal()
は任意のDOMにReact Componentをレンダリングする関数ですが、最低限のDOMのI/F(addEventListener()
やappendChild()
など)を持っていればどんなオブジェクトにもコンポーネントを追加できるようです。自分が試しに実装したコードが以下になります。
- https://github.com/yuqy42/react-sandbox/blob/21ae7823db3ccb8bd5d72eb2a14b3db0c7501464/create-portal-in-original-doc/src/App.tsx#L37-L41
- https://github.com/yuqy42/react-sandbox/blob/21ae7823db3ccb8bd5d72eb2a14b3db0c7501464/create-portal-in-original-doc/src/Portal.tsx
React Aria ComponentにおけるDocumentクラスは以下の通りです。NodeのI/Fと同じものが含まれていることがわかります。
- https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/Collection.tsx#L498-L648
- https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/Collection.tsx#L84-L268
このDocumentはReact Aria Compoenentではfake DOMと呼ばれているようです。
ちなみに、ListBoxのItemを定義する<ListBoxItem>
の実装をみると以下のように、ref callback functionを使ってpropsの情報をelementにsetしています。
<ListBoxItem>
は先程のcreatePortal()
を経由して、fake DOM内に作られます。fake DOMにはsetProps()
が定義されているので、それがref callback functionとして呼び出されるようです。
まとめ
ここまでの調査でCollectionオブジェクトを構築する方法がReact Statelyの層とReact Aria Compoenentの層では異なることが分かりました。
React Aria Componentで上記のような実装にしているのは、real DOMがレンダリングされる前にElementの情報を取得し、virtualized scrollingやkeyboard navigationに活用していることのことです。
We use this fake DOM to access the full set of elements before we render into the real DOM, which allows us to render a subset of the elements (e.g. virtualized scrolling), and compute properties like the total number of items. It also enables keyboard navigation, selection, and other features.
今後は実際にどのような機能を実現するためにfake DOMが活用されるか調べてみたいと思います。