はじめに
こんにちは。
エンジニア2年目の栗原と言います。
この記事は、Next.js アドベントカレンダー24日目の記事です。
getServerSideProps を普段何となく使っていて、仕組みも気になったので、少し調べて実装(getServerSidePropsつきのReactのフレームワーク)もしてみました。
この記事で書くこと
- getServerSideProps の 簡単な実装
- Webpack で React のコードをまとめる実装
- React のフレームワークの作り方
この記事で書かないこと
- hydration などサーバーサイドレンダリング(この記事ではprops渡しはクライアントレンダリングで実装しました🙇)
- Next.js のソースコードの実装
公式ドキュメントに書いてあること
https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props
公式ドキュメントを見ると、getServerSideProps は以下の性質を持つものと説明されています。
- サーバーで動く
- page から export される必要がある
- JSON を返す
- ユーザーがページを訪れた時、
getServerSideProps
はデータをフェッチするために使われ、そのデータを使い初期HTMLをレンダリングするために使われます - などなど(詳しくは公式ドキュメントを見てみてください!)
React の props をサーバーから渡してあげるものであることが分かると思います。
でも、この「サーバーから渡してあげる」がどのようにやっているか分からないですよね。
この記事では、React のルートコンポーネントにどう「サーバーからのPropsを渡す」かを簡単に説明してみようと思います。
getServerSideProps を理解するのに知っておくといい知識
いくつかの ネット上にある ミニ Next.js のプロジェクトのコードや記事を読むと、
それらは、主に以下の処理で ミニNext.js を実装していました。
- getServerSideProps のコードを Fastify など(express, Koa などでもOK)の上にのせる
- サーバー上で getServerSideProps がのっかった express のパスを叩き、React のコンポーネントにProps を渡す(この記事ではクライアントで叩くことにしました)
- webpack で React のソースコードをまとめる
1つずつ見てみましょう。
getServerSideProps のコードを express の上にのせる
の記事の最初の方を見てみてもらえれば分かりますが、getServerSideProps は Fastify などサーバーの上にのっけることになっています。
function createRoute ({ handler, errorHandler, route }, scope, config) {
if (route.getServerSideProps) {
++ // If getServerSideProps is provided, register JSON endpoint for it
scope.get(`/json${route.path}`, async (req, reply) => {
++ reply.send(await route.getServerSideProps({
++ req,
++ ky: scope.ky,
++ }))
})
}
scope.get(route.path, {
// If getServerSideProps is provided,
// make sure it runs before the SSR route handler
...route.getServerSideProps && {
async preHandler (req, reply) {
req.serverSideProps = await route.getServerSideProps({
req,
ky: scope.ky,
})
}
},
handler,
errorHandler,
...route,
})
}
getServerSideProps の正体は、JSONのエンドポイントなだけだったようでした(暇があったら、Next.js のソースコードも読みたいと思います)。
この mini-next.js の記事では fastify-vite
を使って、同一ファイルの page の react コンポーネントと getServerSideProps を分けることをしています。
サーバー上で getServerSideProps がのっかった express のパスを叩き、React のコンポーネントにProps を渡す
これも上の jonasgalvez
さんの記事に書いてありますが、上で作った JSON のエンドポイントを叩くことで、React のコンポーネントに Props を渡しています。
const suspenseMap = new Map()
function fetchWithSuspense (path) {
let loader
// When fetchWithSuspense() is called the first time inside
// a component, it'll create the resource object (loader) for
// tracking its state, but the next time it's called, it'll
// return the same resource object previously saved
if (loader = suspenseMap.get(path)) {
// Handle error, suspended state or return loaded data
if (loader.error || loader.data?.statusCode === 500) {
if (loader.data?.statusCode === 500) {
throw new Error(loader.data.message)
}
throw loader.error
}
if (loader.suspended) {
throw loader.promise
}
// Remove from suspenseMap now that we have data
suspenseMap.delete(path)
return loader.data
} else {
loader = {
suspended: true,
error: null,
data: null,
promise: null,
}
++ loader.promise = fetch(`/json${path}`)
++ .then((response) => response.json())
++ .then((loaderData) => { loader.data = loaderData })
++ .catch((loaderError) => { loader.error = loaderError })
++ .finally(() => { loader.suspended = false })
// Save the active suspended state to track it
suspenseMap.set(path, loader)
// Call again for handling tracked state
return fetchWithSuspense(path)
}
}
このコードでは fetchWithSuspense は2回呼び出されることになっていて、fetchWithSuspense の1回目で fetch を呼び出して、なにもなければ2回目でデータを返すことになっています。
この結果がコンポーネントに渡されます(内部的には Suspense が使われていて複雑ですが)。
ここまで見て、getServerSideProps は、JSONのエンドポイントを作って、それを叩きにいっているだけなことが分かったと思います。
webpack で React のソースコードをまとめる
これだけでも動くものはできるはずなのですが、簡略化のためには、webpack を使って、本体のコードのみならず依存しているコードもまとめて配信するコードも必要になります。
そのためには、普段 CLI で叩いている webpack を CLI コード上で叩けるようにする仕組みが必要になり、その作り方は以下に書いてあります。
この webpack でコードをまとめれば、以下のような HTML のソースコードとまとめられた bundle.js だけで React の環境を構築することができます。
<!DOCTYPE HTML>
<html>
<head>
<title>Test</title>
<script defer src="bundle.js"></script></head>
<body>
<div id="root"></div>
</body>
</html>
まとめると、getServerSideProps は JSON のエンドポイントを作っているだけで、それを React から叩きに行くことで サーバーサイドからデータの受け渡しを行っています。言うだけなら簡単ですね。
そして、それらの依存関係をまとめるために、webpack で React の環境を構築しています。
簡易 getServerSideProps を実装してみる。
この記事では、サーバーサイドレンダリングではなくクライアントレンダリングで、getServerSideProps を実装しています。
サーバーサイドレンダリングについて知りたい方は、React.jsのSSRをTypeScriptで自前で実装してみた
らへんを参考にしてみてください。
ここでは、getServerSideProps を自作してみたいと思います。
ただ本題に入る前に、本家と違ういくつか仕様があります。
まず、getServerSideProps と page の Reactコンポーネントは別ファイルに分けました。
pages 配下は以下のようになる印象です。
getServerSideProps のコードは getServerSideProps.js に、React のコンポーネントは index.jsx に書く形になります。現状、[id].jsx
のような書き方はサポートしておらず、基本的に index.jsx
で ファイル名を決める形になっています。汗
またクライアントのコードは src 配下に書くことができます。
次に、TypeScript のサポートはしていません。全て jsx か js です。また、React のコードの上に
/* @jsx React.createElement */
を加えないと動かないかもしれません。
最後に、上でも書きましたが、サーバーサイドレンダリングではなく、クライアントサイドレンダリングで書きました。
簡易なので許してください
成果物はこちらになります。
では始めてみましょう!
まずは、pages 配下のファイルを解析して、getServerSideProps.js なら サーバーサイドの JSON のエンドポイントを作るコードを書きます。
const serverSideFiles = (await glob("pages/**/*"))
.filter((file) => file.match(/getServerSideProps\.js$/g))
.map((file) => ["../" + file, file]);
const loadServerSideFiles = (files) => {
return Promise.all(
files.map(async(filePath) => {
const filePathName = filePath[1].slice(6, -22);
serverSideFileModules[filePathName] = await import(filePath[0]);
}));
}
...省略
Object.keys(serverSideFileModules).forEach((filePath) => {
app.get(`/json/${filePath}`, cors(corsOption), async function(req, res, next){
++ res.json(await serverSideFileModules[filePath].default());
})
});
中身を見れば分かると思いますが、getServerSideProps JSON のエンドポイントに登録しているだけになりますね。
次に、クライアント側に配信するファイルを生成します。
クライアントサイドで JSON エンドポイントを叩きに行くコードは後から追加しています。
const clientSideFiles = (await glob("pages/**/*"))
.filter((file) => file.match(/\.jsx/g))
.map((file) => ["./" + file, file]);
await loadServerSideFiles(serverSideFiles)
... 省略
const loadClientFiles = (files) => {
return Promise.all(
files.map(async(filePath) => {
const originalFileContent = fs.readFileSync("./next/page.jsx", "utf-8");
const filePathName = filePath[1].slice(6, -10);
const filePathSlashCount = (filePathName.match(/(\/)/g) ?? []).length;
const appPath = filePathSlashCount === 0
? `../../pages/${filePathName}/index.jsx`
: `../..${"/..".repeat(filePathSlashCount)}/pages/${filePathName}/index.jsx`
const axiosPath = `http://localhost:3002/json/${filePathName}`
const fileHeader = `/** @jsx React.createElement */
import axios from "axios";
import React from "react";
import { createRoot } from 'react-dom/client';
import App from '${appPath}';
let props;
(async () => {
const axiosResult = await axios.get("${axiosPath}");
props = axiosResult.data;
`
const fileFooter = "\n})()";
const fileContent = fileHeader + originalFileContent + fileFooter;
const writeFolderPath = `./next_client/${filePathName}`
const writeFileName = filePath[1].slice(6);
const writeFilePath = `./next_client/${writeFileName}`;
await fs.mkdirSync(writeFolderPath, { recursive: true });
await fs.writeFileSync(writeFilePath, fileContent);
return writeFilePath;
})
)}
clientSideFilePath = await loadClientFiles(clientSideFiles);
このコードでは、JSON エンドポイントを叩きに行くコードを加えたファイルを next_client
に追加しています。
この next_client 配下のファイルを、React などの依存関係と共に webpack で1つにまとめます。これが下のコードです。
const srcFiles = (await glob("src/**/*")).map((file) => "./" + file);
const pagesFiles = (await glob("pages/**/*")).map((file) => "./" + file);
clientSideFilePath.forEach((clientFile, index) => {
const config = {
context: __dirname,
mode: "development",
entry: [...srcFiles, ...pagesFiles, clientFile],
output: {
path: path.resolve(__dirname, "dist"),
filename: `bundle_${index}.js`
},
module: {
rules: [
{
test: /\.jsx|js$/,
include: [/node_modules/, path.resolve(__dirname, 'src'), /pages\/[\w\/]+(?!getServerSideProps.js)/, /next_client/],
// /pages\/[\w\/]+(?!getServerSideProps.js)/
use: {
loader: 'babel-loader',
options: {
presets: ["@babel/preset-react", "@babel/preset-env"],
}
},
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
})
]
}
const compiler = webpack(config);
compiler.inputFileSystem = fs;
compiler.outputFileSystem = fs;
compiler.run((err, stats) => {
if (err) console.log(err);
// console.log(stats);
});
})
これで、bundle_0.js 〜 bundle_n.js のファイルが生成されるので、それを無理やり追加することで、React をクライアントで呼び出すことにしています。
clientSideFilePath.forEach(async (filePath, index) => {
const filePathName = filePath.slice(14, -10);
app.get(`/${filePathName}`, cors(corsOption), async function(req, res, next){
let HTMLContent = await fs.readFileSync(`dist/index.html`, "utf-8");
const scriptContent = await fs.readFileSync(`dist/bundle_${index}.js`, 'utf-8');
++ HTMLContent += `<script>${scriptContent}</script>`
res.send(HTMLContent);
})
});
以下のコードを pages/test に登録すると、以下の動画のような画面が出てきます。
import axios from "axios"
const getServerSideProps = async () => {
try {
const result = await axios({method: "get", url:"https://jsonplaceholder.typicode.com/todos/1"});
const props = {
title: result.data.title,
num: result.data.userId
}
return props;
} catch(e) {
console.log(e)
return {title: "", num: 0};
}
}
export default getServerSideProps;
/* @jsx React.createElement */
import React from "react";
import Test from "../../src/Test.jsx";
const App = (props) => {
return (
<div>
<Test title={props.title} num={props.num}/>
<h3>{props.title}</h3>
<p>{props.num}</p>
</div>
);
};
export default App;
感想
mini Next.js の実装はあるものの、fastify-vite を使っていて、魔法感がしていたので、こうやって自分で簡易なものを実装して仕組みを理解できるようになれたのは良かった。
Next.js は少しコードを読んだのですが、client, server はまだなんとかなっても、build の方が難しい印象だったのですが、今度はソースコードリーディングに挑戦して実装したいと思います。
また、今回は時間の関係で、hydrate らへんを実装できなかったので、時間がある時に研究したいと思います。