iT邦幫忙

2025 iThome 鐵人賽

DAY 10
1
Modern Web

從 React 學 Next.js:不只要會用,還要真的懂系列 第 10

【Day 10】SSR 一定要用 Next.js ?能用 React 實作 SSR、SSG 嗎?

  • 分享至 

  • xImage
  •  

這幾天我們陸續了解了各種渲染模式,也從渲染模式延伸到 Hydration、Streaming 等內容,今天讓我們再把鏡頭轉給第二男主角一下下,來看看是不是也能用 React 實作 SSR、SSG 這兩個渲染模式。

我想大家應該都知道 Next.js 的核心功能就是能夠更方便地使用 SSR、SSG 等的這些渲染模式,但一定也有人會有一個想法,那就是「React 沒辦法做到 SSR、SSG 這樣的渲染模式?」今天就讓我們讓第二男主角秀給大家看,用純 React 來實作 SSR 和 SSG。

今天先看了用純 React 實作 SSR、SSG 之後,明天我們會再來看使用 Next.js 框架,要怎麼去使用 SSR、SSG 等渲染模式。

回顧 SSR 的概念及實作策略分析

所謂 SSR(Server-Side Rendering),指的是在伺服器上先將完整的 HTML 頁面渲染完成,然後再傳送給瀏覽器。因此,如果我們不使用像 Next.js 這類整合式框架,而是想單純用 React 來實作 SSR,就需要額外建立伺服器。
在這樣的實作架構下,重點會分成兩個部分:

  • 建立伺服器
  • 在伺服器上渲染並返回 React 頁面

這裡我們選擇使用 Express 來建立伺服器,前端則維持使用 React。

用 React 實際實作 SSR

• 起手式:建置專案
先跑一些指令把該安裝的都裝上專案

初始化 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 上,就表示有正常把伺服器跑起來了。
https://ithelp.ithome.com.tw/upload/images/20250901/20130914v4kyUXtj7d.png

• 第二步:建立入口的 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。
https://ithelp.ithome.com.tw/upload/images/20250901/20130914YBflP6s6dE.png

加上支援 SSG 的話呢?

• 寫一個產出 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。


上一篇
【Day 9】Next.js 的實驗性功能:兼顧效能及操作性的 Partial Prerendering
下一篇
【Day 11】用 Next.js 實作 SSR/SSG/ISR
系列文
從 React 學 Next.js:不只要會用,還要真的懂11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言