🙄

Expo RouterアプリでReact Server Componentsを使う

2024/11/21に公開

https://docs.expo.dev/guides/server-components/#server-components

  • 依存関係のエラー回避のため、expoに合わせてyarnを使った方がいい
  • デフォルトのテンプレートはclient componentでしか動かないAPIを多用しているのでwith-routerで最小のルータープロジェクトを作る
yarn create expo-app --example with-router
cd my-app
yarn add -D react-server-dom-webpack@beta # おまじない
  • "reactServerFunctions": true,がページコンポーネントからサーバーコンポーネントを呼び出すのに必要なフラグ
  • reactServerComponentRoutesはページコンポーネントをデフォルトでサーバーコンポーネントにする。境界値を調べたいので最初はオフにしておく
app.json
{
  "expo": {
    "scheme": "acme",
    "plugins": [
      "expo-router"
    ],
    "experiments": {
      "reactServerFunctions": true,
      "reactServerComponentRoutes": false
    }
  }
}
  • トップページを作る
  • このコンポーネントはクライアントコンポーネントになる
app/index.tsx
import React from "react";
import { View, Button, ActivityIndicator } from "react-native";
import { PokemonBox } from "../components/pokemon";

export default function Page() {
  return (
    <View style={styles.container}>
      <View style={styles.main}>
        <View style={{ padding: 8, borderWidth: 1 }}>
          <React.Suspense fallback={<ActivityIndicator />}>
            <PokemonBox columns={3} />
          </React.Suspense>
        </View>
      </View>
    </View>
  );
}
  • PokemonBox全体をサーバーコンポーネントで定義する
components/pokemon.tsx
"use server";

import React from "react";
import { Text, View, Image } from "react-native";
import { fetchRandomPokemon } from "../actions/pokemon";

type Pokemon = {
  name: string;
  sprites: {
    front_default: string;
  };
  abilities: {
    ability: {
      name: string;
    };
  }[];
};

export function PokemonView({ pokemon }: { pokemon: Pokemon }) {
  return (
    <View style={{ padding: 8, borderWidth: 1 }}>
      <Text style={{ fontWeight: "bold", fontSize: 24 }}>{pokemon.name}</Text>
      <Image
        source={{ uri: pokemon.sprites.front_default }}
        style={{ width: 100, height: 100 }}
      />
      {pokemon.abilities.map((ability) => (
        <Text key={ability.ability.name}>- {ability.ability.name}</Text>
      ))}
    </View>
  );
}

export function PokemonBox({ columns }: { columns: number }) {
  return (
    <>
      {Array.from({ length: columns }).map(async (_, index) => {
        const pokemon = await fetchRandomPokemon();
        return <PokemonView key={index} pokemon={pokemon} />;
      })}
    </>
  );
}
  • fetchRandomPokemonはServer Functionsになる。これ単体でクライアントから呼び出せる
  • API Routes(Web API)の代わりになる
actions/pokemon.tsx
"use server";

export async function fetchRandomPokemon() {
  const randomId = Math.floor(Math.random() * 100) + 1;
  const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${randomId}`);
  const json = await res.json();
  return json;
}
  • 開発サーバー起動
  • web, ios, androidとも動作した
yarn expo start
  • DevToolsで通信を見るとクライアントから/_flight/web/ACTION_file:...というエントリーポイントに何度もリクエストしているのがわかる
  • 一度で済ませて!

  • ここで満を持してreactServerComponentRoutesフラグをオン
    "experiments": {
      "reactServerFunctions": true,
      "reactServerComponentRoutes": true
    }
  • /_flight/web/index.txtから一発で一発でとって来るようになった
  • DevToolsで通信を見るとクライアントからPokemon APIにアクセスが行かずに、サーバーを経由してRSCで結果を受け取っているのがわかる

  • スタンドアロンなアプリにするにはexportする
  • まだwebしかexportできない
  • モバイルから使う時はおそらくbaseUrlを設定してバックエンドを指定する?
yarn expo export -p web
yarn expo serve dist # ローカルで確認
  • expo serve内部でNode.jsのサーバーが起動している
  • デプロイ用にDockerfileを書いた
ARG NODE_VERSION=20.15.1
FROM node:${NODE_VERSION}-slim as base

WORKDIR /app

ENV NODE_ENV="production"
ARG YARN_VERSION=3.6.4

RUN corepack enable && \
    yarn set version ${YARN_VERSION}

FROM base as build

RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3

COPY .yarnrc.yml package.json yarn.lock ./
RUN yarn install --immutable
RUN yarn expo export -p web

COPY . .

FROM base

COPY --from=build /app /app

EXPOSE 3000
CMD [ "yarn", "expo", "serve", "dist" ]

考察

  • この機能はAPI Routesの通信プロトコルをRSCペイロードに拡張したようなアーキテクチャだった
  • SSRだとサーバーは完全なHTMLを返しクライアントでJSコードをでハイドレートするがExpo RouterはRSCペイロードを単純に送ってくる。これはモバイルでReact Nativeコンポーネントを再生する必要があるからだと思われる。現状のWeb版も一旦コンテンツなしのSPAのシェルをロードして、ペーロードを受け取り画面に描画する。
  • 不変なViewはクライアントコンポーネントにして、動的な部分だけサーバーで動作させる。キャッシュする。などの戦略が必要
  • 現状はRSCペイロードを使ったServer-Driven UIが実現できるもの(動いてるだけですごい)といったものだった

Discussion