ブログを作り直した
こんにちは
どうも、@takurinton です。
何度目かのブログリプレイスをしました。
ブログ:blog.takurinton.dev
ソースコード: takurinton/old-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
としています。
<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
サーバ側の処理についてです。
処理の流れとして
-
index.ts
にリクエストがくる - 中で render 関数が呼ばれ、React component から string の html が生成される
- 取得した 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;
}
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
クライアント側の処理についてです。
サーバ側同様、処理の流れを示します。
-
main.tsx
で、サーバサイドレンダリング時に埋め込んだ JSON 取得し、hydration を行う -
App.tsx
でルーティングを行う - 各ページのレンダリングが行われる
といったようになっています。
通常の 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 に処理を書いています。
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: ./
大まかな流れとして、
- yarn install
- aws cli にログイン
- クライアント側の JS をバンドルして、S3 にアップロード
- サーバ側のバンドルと vercel へのデプロイ
となっています。
開発は、main ブランチで行い、main ブランチに push、または プルリクエストが main ブランチにマージされたらデプロイを行うように設定しました。
内製してるもの
GraphQL の client ライブラリである urql を fork し、少し改造して使っています。
コードは shared/@takurinton/urql にあります。
具体的には、ブログは GET しかしないので、GraphQL の mutation や subscription の機能は不要であるため、その部分を削りました。
やり残してることとリファクタリング
client/server の互換を保ったり、無駄なリクエストを削る処理がだいぶ荒いので修正したいです。
また、バンドルまわりであったり、初期読み込みをもっと速くできたりと課題がたくさんあるので今後改善していきます。
最後に
ブログを作り替えた話でした。
個人ブログは技術の素振りができるのでいいと思います。
荒削りな実装がだいぶ多いので、綺麗にしていきたいです。
Discussion