ブログを作り直した

2022/03/17に公開約12,600字

こんにちは

どうも、@takurinton です。
何度目かのブログリプレイスをしました。

ブログ:blog.takurinton.dev
ソースコード: takurinton/blog.takurinton.dev

https://blog.takurinton.dev
https://github.com/takurinton/blog.takurinton.dev

全体の構成

クライアントサイドのコードは client/、サーバ側(BFF)のコードは server/、共通で使うものは shared/ に入っています。

client

main.tsx をエントリポイントとして、App.tsx で react-router でルーティングをしています。また、components/ の下にコンポーネント群が入っています。

server

index.ts で express のサーバを立ち上げ、リクエストが来ると、React component を string の html にして返す処理を書いています。
render.ts では meta タグなどの共通の部分を書いています。SSR 時の style タグの埋め込みや、JSON を html に埋め込む処理もここで行なっています。

ちょっとした面白ポイントとして、Next.js の真似をして JSON を埋め込む際の id を __RINTON_DATA__、マウントするポイントを __rinton としています。

https://github.com/takurinton/blog.takurinton.dev/blob/b300f64fa6beb8eca20502d2059e3aea6a5108e0/server/render.tsx#L70-L71
<div id="__rinton">${props.htmlString}</div>
<script id="__RINTON_DATA__" type="application/json">${json}</script>

shared

共通で使うもの、主に GraphQL 関連のコードが入っています。

使用技術

以下の技術を使用しています。

分類 名前
言語 TypeScript
ライブラリ React
react-router
experss
urql(fork して少し変更)
Recoil
バンドル esbuild
その他 marked
highlight.js

SSR

サーバ側の処理についてです。

処理の流れとして

  1. index.ts にリクエストがくる
  2. 中で render 関数が呼ばれ、React component から string の html が生成される
  3. 取得した html をレスポンスとして返す

ということをやっています。

index.ts

まず、index.ts を見ます。
全部のエンドポイントは示せないので、Home についてだけ示します。
Home のエンドポイントである / にリクエストが来たら、そのエンドポイントに対応した html がレスポンスとして返されます。
また、サーバ側で GraphQL のリクエストを投げて、そのレスポンスも render 関数に渡し、html に埋め込みます。

// サーバ側で GraphQL のリクエストを投げ、レスポンスを取得
const getServersideQueryResponse = async ({
  query,
  variables,
}: {
  query: TypedDocumentNode<any, object>;
  variables?: any;
}) => {
  const ssr = ssrExchange({ isClient: false });
  const client = initUrqlClient({
    exchanges: [dedupExchange, cacheExchange, ssr, fetchExchange],
  });

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

  return ssr.extractData();
};

// リクエストが来たら html を返す。
app.get("/", async (req, res) => {
  try {
    const pages = req.query.page ?? 1;
    const category = req.query.category ?? "";
    const props = await getServersideQueryResponse({
      query: POSTS_QUERY,
      variables: { pages, category },
    });

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

    res.setHeader("Content-Type", "text/html");
    res.send(html);
  } catch (e) {
    console.log(e);
    res.setHeader("Content-Type", "text/html");
    res.send(e);
  }
});

render.ts

render.ts では、エンドポイントに対応した html を生成します。
ここには、render 関数と、createTemplate 関数がいます。

render 関数

render 関数を見ます。
render 関数では、必要な情報をもらい、それを元にして html を作る createTemplate 関数に渡します。
また、styled-components はサーバでのレンダリングのために、サーバ側のレンダリングの段階で style タグを生成してくれる関数を備えた ServerStyleSheet という class を提供しているので、それを使用して先に style タグを生成します。

export async function render({
  url,
  title,
  description,
  image,
  props,
}: {
  url: string;
  title: string;
  description: string;
  image: string;
  props?: any;
}) {
  const sheet = new ServerStyleSheet();
  const htmlString = ReactDOMServer.renderToString(
    sheet.collectStyles(
      <React.StrictMode>
        <StaticRouter location={url}>
          <App props={props} />
        </StaticRouter>
      </React.StrictMode>
    )
  );

  const styleTags = sheet.getStyleTags();
  const html = createTemplate({
    url,
    title,
    description,
    image,
    props,
    styleTags,
    htmlString,
  });

  return html;
}

https://styled-components.com/docs/advanced#server-side-rendering

createTemplate 関数

createTemplate 関数では、先ほど生成した style タグや、index.ts でもらったレスポンスを用いて html を生成します。
ここで作られた html がそのままレスポンスとしてユーザーの元に届けられます。
主に行なっていることは、meta タグの定義、JSON を 埋め込む、といったことです。

export const createTemplate = (props) => {
  const json = JSON.stringify(props.props);
  if (props.description == undefined)
    props.description = "たくりんとんのブログです。";
  if (props.image == undefined) props.image = "https://takurinton.dev/me.jpeg";
  return `<!DOCTYPE html>
    <html lang="ja">
        <head>
            <link rel="preconnect" href="https://blog.takurinton.dev/" />
            <title>${props.title}</title>
            <meta name="description" content="${props.description}" />
            <meta property="og:title" content="${props.title}" />
            <meta property="og:description" content="${props.description}" />
            <meta property="og:type" content="blog" />
            <meta property="og:url" content="https://photo.takurinton.dev" />
            <meta property="og:image" content="${props.image}" />
            <meta property="og:site_name" content="${props.title}" />
            <meta name="twitter:card" content="summary_large_image" />
            <meta name="twitter:url" content=${""} />
            <meta name="twitter:title" content="${props.title}" />
            <meta name="twitter:description" content="${props.description}" />
            <meta name="twitter:image" content="${props.image}" />
            <link rel="shortcut icon" href=${"https://takurinton.dev/me.jpeg"} />
            <link rel="apple-touch-icon" href=${"https://takurinton.dev/me.jpeg"} />
            <link
                rel="stylesheet"
                href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.10/styles/atom-one-dark-reasonable.min.css"
            />
            <meta name="viewport" content="width=device-width,initial-scale=1" />
            <style>
                body {
                    padding: 0; 
                    margin: 0;
                    margin-bottom: 50px;
                    font-family: Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
                }
                @media (max-width: 414px) {
                    font-size: 80%;
                }
            </style>
            ${props.styleTags}
        </head >
        <body>
            <div id="__rinton">${props.htmlString}</div>
            <script id="__RINTON_DATA__" type="application/json">${json}</script>
            <script async defer src="${S3_DOMAIN}/main.js"></script>
        </body>
    </html>
    `;
};

CSR

クライアント側の処理についてです。

サーバ側同様、処理の流れを示します。

  1. main.tsx で、サーバサイドレンダリング時に埋め込んだ JSON 取得し、hydration を行う
  2. App.tsx でルーティングを行う
  3. 各ページのレンダリングが行われる

といったようになっています。
通常の SPA の React App と同じ構成かと思います。(SSR は初期レンダリング以外は CSR になるのでそれはそうですが)

main.tsx

まず、main.tsx にて hydration を行います。
ここでは、先ほど埋め込んだ JSON を取得し、App.tsx に渡します。

(() => {
  // JSON を取得
  const props = document.getElementById("__RINTON_DATA__").textContent;
  ReactDOM.hydrate(
    <BrowserRouter>
      <App props={props} />
    </BrowserRouter>,
    document.getElementById("__rinton")
  );
})();

App.tsx

App.tsx では、react-router を用いてクライアント側のルーティングを行います。
サーバ側では StaticRouter、クライアント側では BrowserRouter を利用します。

export const App: React.FC<{
  props: any;
}> = ({ props }): JSX.Element => {
  const theme = createTheme();
  const client = initUrqlClient({});

  return (
    <>
      <RecoilRoot>
        <Provider value={client}>
          <ThemeProvider theme={theme}>
            <Routes>
              <Route path="/" element={<Home props={props} />} />
              <Route path="/post/:id" element={<Post props={props} />} />
              <Route path="/external" element={<External props={props} />} />
              <Route path="about" element={<About />} />
            </Routes>
          </ThemeProvider>
        </Provider>
      </RecoilRoot>
    </>
  );
};

Home.tsx

全部のページは示せないので、例として Home.tsx を用います。
Home.tsx は以下のようになっていて、もしサーバ側のレンダリングが入っていたら props にデータが入っているはずなのでそのデータを用いてレンダリングを行います。
逆に、クライアント側のレンダリングであった場合、props に入っているデータは Home のデータではないので、別途リクエストを投げて取得します。
最終的に posts に入っている値がレンダリングされるデータになります。

export const Home: React.FC<{ props: Props }> = Layout(({ props }) => {
  const query = useQuery();
  const { pathname } = useLocation();
  const isServer = typeof window === "undefined";

  const pages = query.get("page") ?? 1;
  const category = query.get("category") ?? "";
  const data = getHashByData(props, isServer);
  const posts = isServer ? data.getPosts : getPosts(data, { pages, category });

  useEffect(() => {
    window.scrollTo(0, 0);
    if (!isServer)
      document.querySelector("title").innerText = "Home | たくりんとんのブログ";
  }, [pathname, query]);

  return (
    <Container>
      <Heading>全ての投稿一覧</Heading>
      {posts.results.map((p) => (
        <div key={p.id}>
          <h2>
            <Link to={`/post/${p.id}`}>{p.title}</Link>
          </h2>
          <Link to={`/?category=${p.category}`}>
            <Label>{p.category}</Label>
          </Link>
          <Typography weight="bold" component="p">
            {datetimeFormatter(p.pub_date)}
          </Typography>
          <p>{p.contents}</p>
          <hr />
        </div>
      ))}
      <PageContainer>
        {posts.previous === posts.current ? (
          <></>
        ) : (
          <Link
            to={
              posts.category === ""
                ? `/?page=${posts.previous}`
                : `/?page=${posts.previous}&category=${posts.category}`
            }
          >
            <PrevButton>
              <Label>prev</Label>
            </PrevButton>
          </Link>
        )}
        {posts.next === posts.current ? (
          <></>
        ) : (
          <Link
            to={
              posts.category === ""
                ? `/?page=${posts.next}`
                : `/?page=${posts.next}&category=${posts.category}`
            }
          >
            <NextButton>
              <Label>next</Label>
            </NextButton>
          </Link>
        )}
      </PageContainer>
    </Container>
  );
});

デプロイ

GitHub Actions を使用し、静的ファイルは S3 に、サーバは vercel の serverless 環境にデプロイしています。
deploy.yml に処理を書いています。

https://github.com/takurinton/blog.takurinton.dev/blob/main/.github/workflows/deploy.yml
name: deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [16.x]
    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v2
        with:
          node-version: ${{ matrix.node-version }}
          cache: "yarn"

      - run: cd ./shared/@takurinton/urql && yarn install --frozen-lockfile && yarn build
      - run: yarn install --frozen-lockfile

      - name: login to aws
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: client build and upload to s3
        env:
          S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
        run: |
          aws s3 rm s3://${{ secrets.S3_BUCKET_NAME }}/main.js
          yarn build/client
          aws s3 cp ./dist/main.js s3://$S3_BUCKET_NAME
      - name: server build
        run: yarn build/server

      - name: deploy
        uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-args: "--prod"
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID}}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID}}
          working-directory: ./

大まかな流れとして、

  1. yarn install
  2. aws cli にログイン
  3. クライアント側の JS をバンドルして、S3 にアップロード
  4. サーバ側のバンドルと vercel へのデプロイ

となっています。

開発は、main ブランチで行い、main ブランチに push、または プルリクエストが main ブランチにマージされたらデプロイを行うように設定しました。

内製してるもの

GraphQL の client ライブラリである urql を fork し、少し改造して使っています。
コードは shared/@takurinton/urql にあります。

https://github.com/takurinton/blog.takurinton.dev/tree/main/shared/%40takurinton/urql

具体的には、ブログは GET しかしないので、GraphQL の mutation や subscription の機能は不要であるため、その部分を削りました。

やり残してることとリファクタリング

client/server の互換を保ったり、無駄なリクエストを削る処理がだいぶ荒いので修正したいです。
また、バンドルまわりであったり、初期読み込みをもっと速くできたりと課題がたくさんあるので今後改善していきます。

最後に

ブログを作り替えた話でした。
個人ブログは技術の素振りができるのでいいと思います。
荒削りな実装がだいぶ多いので、綺麗にしていきたいです。

Discussion

ログインするとコメントできます