Skip to content

urqlでSSRするには

2022-02-04


urql で SSR な構成を作ろうと思ったけど、情報が想像以上に少なかった。
Next.js でのサンプル や、ドキュメントで Exchange の紹介(urql は middleware を Exchange という単位で扱い、いくつかデフォルトの定義されてるものもあるが、ユーザー側での拡張が可能になっている)こそあるものの、自分でサーバ定義して SSR + hydrate する構成についてはそれっぽいものが見当たらなかったのでやってみたログ。

そもそものアプリケーション

以下のようなディレクトリ構成を想定。
client には client side で使用する React コンポーネントが、server には SSR するための Express で立てたサーバが、shared には client/server の両方で利用する GraphQL の init 関数やクエリが入っている。

  • client
    • App.tsx
    • main.tsx
    • pages
      • ...
  • server
    • server.tsx
    • Html.tsx
    • render.tsx
  • shared
    • graphql
      • initUrqlClient.ts
      • queries.ts

SSR をするためには、client/server の接合が必要だが、react-router の StaticRouter と BrowserRouter を利用して App.tsx をラップしてエントリポイントを作っている。
client は main.tsx で、server は render.tsx で上記の接合部分を定義している。
server でレンダリングした際に、html に json を埋め込み、それを hydrate するときにとってくるという一般的な SSR を想定している。

// client/main.tsx
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";

(() => {
  const json = JSON.parse(
    document.getElementById("json").getAttribute("data-json")
  );
  ReactDOM.hydrate(
    <BrowserRouter>
      <App props={json} />
    </BrowserRouter>,
    document.getElementById("main")
  );
})();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// server/render.tsx
import React, { createElement } from "react";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { App } from "../client/App";
import Html from "./Html";

export async function render({
  url,
  title,
  description,
  image,
  props,
}: {
  url: string;
  title: string;
  description: string;
  image: string;
  props?: any;
}) {
  return ReactDOMServer.renderToString(
    <React.StrictMode>
      <StaticRouter location={url}>
        {createElement(
          Html({
            children: () => <App props={props} />,
            title,
            description,
            image,
            props,
          })
        )}
      </StaticRouter>
    </React.StrictMode>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

urql の init 関数を作る

client/server の両方で利用するような、初期化関数を定義する。 注意点として、urql はデフォルトでブラウザで実装している fetch API を利用する構成になっている。これは先に server で実行されるため、fetch が存在しない。urql は、先に認証を済ませておきたい場合や、諸々の手順を踏みたい場合に備えて、リクエストを投げる関数を上書きできる仕組みがある。今回はそれを利用して、fetch を node-fetch に置き換えて実装する。

サーバサイドで実行されて、かつ client が存在しないときに client を作成して返すようなことを想定している。
なお、インメモリで client は保持しておいて、2 回目以降のアクセスは定義した client を返す。

import { Client, ClientOptions } from "urql";
import fetch from "node-fetch";

let urqlClient: Client | null = null;

export function resetClient() {
  urqlClient = null;
}

export function initUrqlClient(clientOptions: ClientOptions): Client | null {
  const isServer = typeof window === "undefined";

  if (isServer || !urqlClient) {
    urqlClient = new Client({
      fetch, // node-fetch を渡している
      ...clientOptions,
    });
    (urqlClient as any).toJSON = () => null;
  }

  return urqlClient;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

server で使う

次に、上で定義した関数を server で利用する。
SSR をする際には、初期レンダリングのみ SSR をし、その後の画面遷移は CSR になるため、エンドポイントごとに init を呼ぶか、middleware を作って毎度呼ぶことが必要になる。今回は、エンドポイントに直接置いた。
まずは、express で単純なサーバを作成する。

ところどころ省略しているが、/ というエンドポイントにリクエストが来たら、ブログの投稿一覧を含めた html を返すようなエンドポイントを定義した。

// server/server.ts
import express from "express";
import { render } from "./render";
import { ssrExchange, dedupExchange, cacheExchange, fetchExchange } from "urql";
import { TypedDocumentNode } from "@graphql-typed-document-node/core";
import { initUrqlClient } from "../shared/graphql/initUrqlClient";
import { POSTS_QUERY } from "../shared/graphql/query/posts";

const app = express();
app.listen(3001);
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(cors());
app.use(express.static("dist"));

const SERVER_ENDPOINT = "https://api.takurinton.com";

app.get("/", async (req, res) => {
  try {
    const pages = req.query.page ?? 1;
    const category = req.query.category ?? "";

    // server 側のレンダリングなので { isClient: false } を渡す。suspense 用のオプションもある。
    // https://github.com/FormidableLabs/urql/blob/f9c57656a73dfd7e8ab203d3da004c10b47406e2/packages/core/src/exchanges/ssr.ts#L21-L26
    const ssr = ssrExchange({ isClient: false });
    const client = initUrqlClient({
      url: `${SERVER_ENDPOINT}/graphql`,
      exchanges: [dedupExchange, cacheExchange, ssr, fetchExchange],
    });

    await client.query(query, variables).toPromise();

    const renderd = await render({
      url: "/",
      title: "Home | たくりんとんのブログ",
      description: "Home | たくりんとんのブログ",
      image: "https://takurinton.dev/me.jpeg",
      props: ssr.extractData(),
    });

    res.setHeader("Content-Type", "text/html");
    const htmlString = "<!DOCTYPE html>" + renderd;
    res.send(htmlString);
  } catch (e) {
    console.log(e);
    res.setHeader("Content-Type", "text/html");
    res.send(e);
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

urql には、ssrExchange という、SSR をするための仕組みが提供されている。ここでは、server のレンダリングなので、isClient オプションを false に設定して client に渡している。
この Exchange は、extractData() という関数を提供しており、レスポンスのデータを取り出すことができる。
取り出したデータを props に渡して、最終的に各コンポーネントに流れるようになっている。

client で Provider に渡す

次に、urql を通常の SPA で利用するために踏む手順を踏む。 <Provider /> でラップする必要があるので、App.tsx で定義をする。
先ほど、server の方でも使った init 関数をこちらでも呼び、Provider に渡す。

// client/App.tsx
import React from "react";
import { Routes, Route } from "react-router-dom";
import { Home } from "./pages/Home";
import { Provider } from "urql";
import { initUrqlClient } from "../shared/graphql/initUrqlClient";

export const App: React.FC<{
  props: any;
}> = ({ props }): JSX.Element => {
  const client = initUrqlClient({
    url: "https://api.takurinton.com/graphql",
  });

  return (
    <>
      <Provider value={client}>
        <Routes>
          <Route path="/" element={<Home props={props} />} />
        </Routes>
      </Provider>
    </>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

クエリを投げる

実際にクエリを投げる。
Home.tsx を作り、そこでブログの最新の投稿 5 件を表示する。
(styled-component を利用しているが、定義は省略)

ここでは、ブログの投稿一覧を表示する。
コンポーネント自体の定義は簡単で、getPosts 関数で取得した情報を流すだけになっている。
page と category に関しては、クエリパラメータで取得して、ページネーションとカテゴリによるフィルタリングができるようになっている。

// client/pages/Home/Home.tsx
import React, { useEffect } from "react";
import { Layout } from "../../Layout";
import {
  Heading,
  Container,
  PageContainer,
  PrevButton,
  NextButton,
} from "./styled";
import { Link } from "../../components/utils/styled";
import { datetimeFormatter } from "../../../shared/utils/datetimeFormatter";
import { TypographyWrapper } from "../../components/Typography";
import { CategoryWrapper } from "../../components/Button/Category";
import { getHashByData } from "../../utils/getHashByData";
import { useQuery } from "./internal/useQuery";
import { useLocation } from "react-router";

export const Home: React.FC<{ props: Props }> = Layout(({ props }) => {
  const query = useQuery();

  const pages = query.get("page") ?? 1;
  const category = query.get("category") ?? "";
  const data = getHashByData(props); // props のデータを取得
  const posts = typeof window === 'undefined' ? data.getPosts : getPosts(data, { pages, category }); // 次に説明

  return (
    <Container>
      <Heading>
        <TypographyWrapper text="全ての投稿一覧" weight="bold" tag="h1" />
      </Heading>
      {p.results.map((p) => (
        <div key={p.id}>
          <h2>
            <Link to={`/post/${p.id}`}>{p.title}</Link>
          </h2>
          <Link to={`/?category=${p.category}`}>
            <CategoryWrapper text={p.category} />
          </Link>
          <TypographyWrapper
            weight="bold"
            tag="p"
            text={datetimeFormatter(p.pub_date)}
          ></TypographyWrapper>
          <p>{p.contents}</p>
          <hr />
        </div>
      ))}
  );
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

getPosts 関数は以下のようになっている。
多少複雑だが、やってることは簡単で、data.getPosts にはキャッシュ or SSR したときに埋め込んだ json が入っている。
そのため、data.getPosts があればそのまま使い、なければ CSR なので値を取得して流す、といった感じになっている。
また、ページネーションの際は、data.getPosts 自体は存在するが、中身が違うことになるので、page と category を見て、もし違ったら上書きをするという処理を入れている。

// client/pages/Home/internal/getPosts.ts
const initialState = {
  current: 0,
  next: 0,
  preview: 0,
  category: "",
  results: [
    {
      id: 0,
      title: "",
      contents: "",
      category: "",
      pub_date: "",
    },
  ],
};

export const getPosts = (data, variables) => {
  let res = undefined;
  if (!data.getPosts) {
    const [response] = useQuery({
      query: POSTS_QUERY,
      variables,
    });
    res = response;
  }

  if (data.getPosts) {
    // getPosts はあるけど、最新の値ではないから書き換えたいとき
    if (
      variables.category !== data.getPosts.category ||
      variables.pages !== data.getPosts.current
    ) {
      // 初期レンダリングがホームで、クエリパラメータで遷移するとき
      return res.data === undefined ? initialState : res.data.getPosts;
    }

    // hydrate 用
    return data.getPosts;
  }

  // 別の画面で初期レンダリングしてから、ホームに遷移するとき
  return res.data === undefined ? initialState : res.data.getPosts;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

キャッシュ

今回は 1 回目リクエストを投げたら 2 回目以降は投げない cache first というポリシーを選択した(選択したというよりは、これがデフォルト)
urql のキャッシュ戦略については 4 種類あり、生成したハッシュ値を元に判断している。
標準的なキャッシュについては、ここ にまとめられている。

まとめ

サンプルがなくて一般的な書き方などがいまいちわかっていなかったり、提供されてる関数で簡単に実現可能な部分がありそうだけど、ひとまずこれで簡単な SSR は実装することができた。
urql は軽くて、さらに自由度が高いため使いやすい。
例えば既に GraphQL のサーバがあるところに新規フロントエンドで情報を取りに行く場合や、サーバと別のライブラリを使いたい場合などは urql が力を発揮するのではないかと思う。
まだ試せていないオプションなども多いので時間を見つけて試したい。