這幾天我們陸續了解了各種渲染模式,也從渲染模式延伸到 Hydration、Streaming 等內容,今天讓我們再把鏡頭轉給第二男主角一下下,來看看是不是也能用 React 實作 SSR、SSG 這兩個渲染模式。
我想大家應該都知道 Next.js 的核心功能就是能夠更方便地使用 SSR、SSG 等的這些渲染模式,但一定也有人會有一個想法,那就是「React 沒辦法做到 SSR、SSG 這樣的渲染模式?」今天就讓我們讓第二男主角秀給大家看,用純 React 來實作 SSR 和 SSG。
今天先看了用純 React 實作 SSR、SSG 之後,明天我們會再來看使用 Next.js 框架,要怎麼去使用 SSR、SSG 等渲染模式。
所謂 SSR(Server-Side Rendering),指的是在伺服器上先將完整的 HTML 頁面渲染完成,然後再傳送給瀏覽器。因此,如果我們不使用像 Next.js 這類整合式框架,而是想單純用 React 來實作 SSR,就需要額外建立伺服器。
在這樣的實作架構下,重點會分成兩個部分:
這裡我們選擇使用 Express 來建立伺服器,前端則維持使用 React。
• 起手式:建置專案
先跑一些指令把該安裝的都裝上專案
初始化 node 專案npm init -y
安裝 express (express 5 會出現一些錯誤,所以使用 express 4)npm install express@^4.18.2
也預先安裝一些其他支援的套件npm install -D typescript ts-node @types/node @types/express
安裝打包的工具npm install -D vite @vitejs/plugin-react
初始化 TypeScript 的設定npx tsc --init
React 相關的部分當然也要裝上去,這裡就不使用 npx 建置專案了,因為我們要把整個專案和 SSR 渲染的模式結合在一起npm install react react-dom react-router@6.23.1 react-router-dom@6.23.1
• SSR 建立第一步:準備 node server
在指定資料夾中建立一個 server.ts 的檔案,建立伺服器相關設定,跑 build 轉譯成 js 檔案後,跑起來看看有沒有正常起一個 node server。
// server.ts
import express from "express";
const app = express();
const PORT = 3000;
app.get("/", (req, res) => {
res.send("Hello, Express!");
});
// 啟動伺服器
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
看到這段文字出現在 terminal 上,就表示有正常把伺服器跑起來了。
• 第二步:建立入口的 index.html 及 React 的部分
準備 router 相關的內容檔案 router.tsx。
import { Routes, Route } from "react-router-dom";
import Contact from "./pages/Contact.js";
import About from "./pages/About.js";
import Home from "./pages/Home.js";
export const AppRoutes = () => (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/contact-us" element={<Contact />} />
<Route path="/about" element={<About />} />
</Routes>
);
準備 App.tsx 檔案,也把 router 相關的設定放進去。
import React from "react";
import { AppRoutes } from "./routes.js";
export default function App() {
return <div>{<AppRoutes />}</div>;
}
另外再加上一個把 App 的內容 mount 到 HTML 上的指定 id 的 client.tsx 檔案。
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
hydrateRoot(
document.getElementById("root")!,
<BrowserRouter>
<App />
</BrowserRouter>
);
以及建立一個入口的 index.html 如下,裡面會有被當作根元素的 <div id="root"></div>
。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>用 React 實作 SSR</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client.tsx"></script>
</body>
</html>
• 第三步:修改 server.js,讓首次進入頁面時,能透過 server 將完整的 HTML 渲染出來。
這裡主要是會使用 renderToString 結合前面的 router.tsx 檔案的內容把 React 的內容都渲染出來,然後和基本的 HTML 組合起來後透過 response 回傳給瀏覽器。
修改後的 server.ts 會變成以下的內容:
import express from "express";
import { readFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { renderToString } from "react-dom/server";
import React from "react";
import { StaticRouter } from "react-router-dom/server.js";
import { AppRoutes } from "./routes.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const distPath = path.resolve(__dirname, "../dist");
const PORT = 3000;
app.get("*", (req, res, next) => {
const accept = req.headers.accept || "";
if (!accept.includes("text/html")) {
return next();
}
const html = readFileSync(path.join(distPath, "index.html"), "utf8");
const reactHtml = renderToString(
<StaticRouter location={req.url}>
<AppRoutes />
</StaticRouter>
);
// 這裡是結合完成的完整 HTML
const finalHtml = html.replace(
`<div id="root"></div>`,
`<div id="root">${reactHtml}</div>`
);
res.send(finalHtml);
});
app.use(express.static(distPath));
app.listen(PORT, () => {
console.log("✅ Server running at http://localhost:3000");
});
最後完成後,的資料夾結構會是這樣:
├── index.html # 入口 HTML
├── package.json
├── tsconfig.json
├── vite.config.ts
├── src/
│ ├── App.tsx # 包裝 Router 的入口元件
│ ├── client.tsx # 瀏覽器端 Hydration
│ ├── server.tsx # Express SSR 伺服器
│ ├── routes.tsx # Route 定義
│ └── pages/
│ ├── Home.tsx # 首頁元件
│ └── About.tsx # 其他頁面元件
├── dist/ # 編譯後檔案(自動產生)
• 第四步: build 檔案並且將 server 跑起來
在 package.json 加上這個指令"build:ssr": "vite build && tsc && node dist/scripts/ssg.js"
接著依序執行以下兩個指令後,就完成 SSR 了。npm run build:ssr
npm run serve
完成的結果就會像是以下這樣,回傳回來的 HTML 不會單純只是一個空白含有 root div 的內容,而是和畫面所顯示的內容相同的 HTML。
• 寫一個產出 HTML 的 script
// scripts/ssg.js
import fs from "fs";
import path from "path";
import { renderToString } from "react-dom/server";
import React from "react";
import { StaticRouter } from "react-router-dom/server.js";
import { AppRoutes } from "../routes.js";
// 定義要預產的頁面
const routesToPrerender = ["/", "/about"];
// 讀取 HTML 模板
const distDir = path.resolve("./dist");
const template = fs.readFileSync(path.join(distDir, "index.html"), "utf8");
routesToPrerender.forEach((url) => {
const html = renderToString(
<StaticRouter location={url}>
<AppRoutes />
</StaticRouter>
);
const finalHtml = template.replace(
'<div id="root"></div>',
`<div id="root">${html}</div>`
);
const filename = url === "/" ? "index.html" : `${url.slice(1)}.html`;
fs.writeFileSync(path.join(distDir, filename), finalHtml);
console.log(`✅ Pre-rendered: ${url} -> /dist/${filename}`);
});
也加上一個 build HTML 的指令在 package.json 裡面
"build:ssr": "vite build && tsc && node dist/scripts/ssg.js",
• 調整 server. tsx 檔案
import express from "express";
import fs, { readFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { renderToString } from "react-dom/server";
import React from "react";
import { StaticRouter } from "react-router-dom/server.js";
import { AppRoutes } from "./routes.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const distPath = path.resolve(__dirname, "../dist");
const PORT = 3000;
app.get("*", (req, res, next) => {
// 新增這段
const requestedPath = req.url === "/" ? "/index.html" : `${req.url}.html`;
const staticFilePath = path.join(distPath, requestedPath);
// 如果預先產好的 SSG 頁面存在,就直接回傳
if (fs.existsSync(staticFilePath)) {
console.log("SSG");
return res.sendFile(staticFilePath);
}
///
const accept = req.headers.accept || "";
if (!accept.includes("text/html")) {
return next();
}
const html = readFileSync(path.join(distPath, "index.html"), "utf8");
const reactHtml = renderToString(
<StaticRouter location={req.url}>
<AppRoutes />
</StaticRouter>
);
const finalHtml = html.replace(
`<div id="root"></div>`,
`<div id="root">${reactHtml}</div>`
);
res.send(finalHtml);
});
app.use(express.static(distPath));
app.listen(PORT, () => {
console.log("✅ Server running at http://localhost:3000");
});
這樣就會在如果有 HTML 檔案的時候,直接返回 HTML 給瀏覽器了。
完整的專案程式碼可以參考
https://github.com/jolinhappy/react-ssr-sample
以上就是嘗試用 React 實作 SSR 和 SSG 的部分,即使不使用 Next.js,只使用 React 的確還是可以實作出 SSR,但是看到這裡的人正常的反應應該都是「太多步驟、太麻煩了吧!」,而且因為伺服器的建置和打包轉譯都必須自己裝相關的套件處理,所以其實過程中我遇到不少奇奇怪怪的錯誤。這裡只是嘗試做的只是一個非常簡易的 SSR 和 SSG,過程中,就遇到不少問題了,如果還要細緻到連很多小細節都顧到,那就更不是件容易的事情了,而且還必須考慮到整體專案架構和維護性。不過只看 React 實作 SSR 和 SSG 就說這樣做好麻煩的話,可能還不夠客觀,明天我們就接著看 Next.js 實作 SSR、SSG 以及 ISG。