React StatelyとReact Aria ComponentにおけるCollectionオブジェクトの構築方法

はじめに

React Aria Components – React Ariaとは、アクセシブルなヘッドレスUIを提供するライブラリです。

React Aria ComponentsはReact Spectrum Librariesと呼ばれるライブラリ群の一部で、同じライブラリ群に含まれるReact AriaReact Statelyを利用して実装されています。

例えば、ListBox – React Ariaと呼ばれるコンポーネントは、React StatelyのuseListState HooksとReact AriaのuseListBoxを利用して実装されています。

ListBoxはReact StatelyではCollection componentsと定義されています。また、useListStateのInterfaceを見ると、Collection Interfaceが重要なように見えます。

Collection interfaceのBuilding a collectionを見ると、Collectionの構築方法にはいくつか方法があるとのことなので調査してみます。

React Ariaの例

useListStateと一緒に利用されるuseListBoxExampleには以下のような利用例があります。

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オブジェクトを得ています。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/@react-stately/list/src/useListState.ts#L52

useCollection()の実装を見ると、以下の通り、builder.build()を使ってnodesを取得し、factory()関数でCollectionを作成しているようです。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/@react-stately/collections/src/useCollection.ts#L30-L31

builder.build()の中で何をしているか見てみると、以下のようにchildrenそれぞれに対して、element.type.getCollectionNode()を呼び出しているようです。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/@react-stately/collections/src/CollectionBuilder.ts#L117

ここで、冒頭の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>

実装は以下のとおりです。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/ListBox.tsx#L104-L106

React Ariaの例にあった方法と同じかと思いきや、useListState()の呼び出し前にすでにCollectionが構築されていることがわかります(props.childrennullにされている)。

こちらのCollectionはuseCollection()によって構築されているようなので、そちらを確認してみます。ちなみに、ここのuseCollection()は先程見たReact StatelyのuseCollection()とは別物で、React Aria Componentから提供されているものになります。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/Collection.tsx#L713-L717

まずは、useCollectionDocument()の実装から見てみます。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/Collection.tsx#L745-L766

Documnetと呼ばれるオブジェクトを作成し、最終的に返すCollectionはuseSyncExternalStoreを使ってDocumentから取得しているようです。ただ、上のコードから分かる通り、このDocumentにはまだデータが登録されていなさそうなので、データが登録されているところを調べてみます。

次に、useCollectionPortal()の実装を見てみます。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/Collection.tsx#L771-L785

いろいろな処理をやっていますが、最終的にはChildrenを含むReactElementと先ほど作成したDocumentを引数にcreatePortalを呼び出しているようです。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/Collection.tsx#L784

createPortal()は任意のDOMにReact Componentをレンダリングする関数ですが、最低限のDOMのI/F(addEventListener()appendChild()など)を持っていればどんなオブジェクトにもコンポーネントを追加できるようです。自分が試しに実装したコードが以下になります。

React Aria ComponentにおけるDocumentクラスは以下の通りです。NodeのI/Fと同じものが含まれていることがわかります。

このDocumentはReact Aria Compoenentではfake DOMと呼ばれているようです。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/Collection.tsx#L20-L25

ちなみに、ListBoxのItemを定義する<ListBoxItem>の実装をみると以下のように、ref callback functionを使ってpropsの情報をelementにsetしています。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/Collection.tsx#L862-L894

<ListBoxItem>は先程のcreatePortal()を経由して、fake DOM内に作られます。fake DOMにはsetProps()が定義されているので、それがref callback functionとして呼び出されるようです。

https://github.com/adobe/react-spectrum/blob/cf0846eb49fcdde669e9dc4c0c2d2109b50e46dd/packages/react-aria-components/src/Collection.tsx#L318-L342

まとめ

ここまでの調査で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が活用されるか調べてみたいと思います。

参考