深入探討 Server Components 傳遞資料到 Client 端的原理
Shout out to Dan Abramov. He’s a member of the React core team, and a co-author of Redux and Create React App. In this article, I reference a lot of content from his speech at React Conf 2022. Here is the link. I strongly recommand everyone to watch it. ( ˶ˆ ᗜ ˆ˵ )
在開始進入主題之前,想先告知讀者本文主要是為已經在使用 React,並且對 React Server Component 內部運作機制感到好奇的開發人員所寫的。倘若你對於 React 基本的運作機制都還不是很了解,勇敢地跳過吧!你有其它更重要的事要學習~
什麼是 Client Component 和 Server Component?
Component 本質上就是程式碼,而且程式碼必須在某個地方執行。但它們應該在哪邊執行呢?答案是可能在 server 端,也可能在 client 端。
以下都是以用 Next.js 為網站開發框架的前提所做的說明,不一定適用於其他框架的情境。
首先,想先跟各位談談 Server Components 和 Client Components 的差異。先讓大家能有相同的認知,再後續解釋透過 Server Components 傳遞資料到 Client 端的背後原理是什麼。
Client Component 是專門在 client 端(瀏覽器)處理互動和動態更新的元件。需要特別強調的是 Client Components 的 HTML 是可以在 server 端預渲染(pre-rendering)並傳送到客戶端的,並非只能在 client 端進行渲染。
但 Server Components 就只能在 server 端執行了。執行的時間點可以在 build time 或每次 Web Server 被 request 的時候(根據你的渲染策略決定,EX.SSG, SSR)。值得一提的是 Server Components 當中不包含 JS bundle。這也代表它們是不會被 hydration 和重新渲染(re-rendering)。
bundling:按照正確的順序將 modules(及其依賴項) 拼接到一個檔案(或一組檔案)的過程。讓 client 端不用單獨載入每個文件(script),降低文件的請求數量。並且透過移除不必要的程式碼來降低 bundle size。(例如空格、註解、換行符號等)
這裡我們再釐清一個重要的觀念,SSR(Server-Side Rendering) 和 Server Components 不是同一個東西,他們只是很常一起被使用。底下會說明 Server Components 分別與 SSR 和 CSR(Client-Side Rendering) 一起使用時,Server Components 所扮演的角色為何:
-
如果與 SSR(假設是 full page 的 SSR,先不考慮 Next.js 的 streaming 設計) 一起使用,Server Components 生成的 HTML 會在 server 端合成為完整的 HTML,然後發送到 client 端。
-
如果使用 CSR,Server Components 也是會執行渲染來生成 HTML 並傳送到 client 端。client 端則會將從 server 收到的部分 HTML 與其他頁面的元件合成為一個完整的頁面。所以不要單看 Client Components 這個名字就覺得它只會在 client 端執行喔~事實並非如此!即便是 Client Components 也是會在 server 端執行來進行渲染的(生成 HTML)。咦?你不相信?那我們來實驗一下吧!
先寫一個 client component,裡面放一個 console.log。
"use client";
export default function ClientComponent() {
console.log("client component run");
return <div>ClientComponent</div>;
}
再將 client component 引入到 page (server component) 中。
import ClientComponent from "@/components/client-component";
export default function Home() {
return (
<main>
<ClientComponent />
</main>
);
}
我們可以根據下圖的作法發現 Client Components 裡寫的 console.log,在 server 和 client 端都被執行了。
透過觀察 dev tools 的 network 頁面,我們可以發現 HTML 也是生成好送到 client 的
所以 Client Components 這名子取的很爛啊!很容易讓人誤解~我認真誤會了快一年的時間。
從 Server 傳遞資料到 Client
講了這麼多,我們回到原本的主題,就是 Server Components 到底是怎麼做到傳遞資料到 client 端的。 這裡用一個簡單的例子來說明(此例發想者為 Dan Abramov,並經過小弟我的修改)。為了讓大家好理解原理,這邊不用任何 React 的語法,僅用單純的 javascript 語法。
整個範例專案的資料夾結構如下:
- cats.txt
- client.js
- favicon.ico
- package.json
- server.js
- style.css
首先我們先建一個 server,並讓它在被 request 時回傳相應的檔案給瀏覽器。基本上就是一個網站基本所需要的檔案,包括 HTML、CSS、JavaScript 和網站要顯示的資料。
import { createServer } from "http";
import { readFile } from "fs/promises";
async function server(url) {
if (url === "/api/cat-names") {
const catFile = await readFile("./cats.txt", "utf8");
const catNames = catFile.split("\n");
console.log("catNames", catNames);
const json = { catNames };
return { content: JSON.stringify(json), contentType: "application/json" };
}
if (url === "/") {
const html = `<!doctype html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script src="client.js"></script>
<button onclick="onClick()">Reveal Cat Name</button>
</body>
</html>
`;
return { content: html, contentType: "text/html" };
}
if (url === "/client.js") {
const js = await readFile("./client.js", "utf8");
return { content: js, contentType: "application/javascript" };
}
if (url === "/style.css") {
const css = await readFile("./style.css", "utf8");
return { content: css, contentType: "text/css" };
}
// Return 404 for unknown routes
return { content: "Not Found", contentType: "text/plain", statusCode: 404 };
}
createServer(async (request, response) => {
try {
const {
content,
contentType,
statusCode = 200,
} = await server(request.url);
response.writeHead(statusCode, { "Content-type": contentType });
response.end(content);
} catch (error) {
response.writeHead(500, { "Content-type": "text/plain" });
response.end("Internal Server Error");
console.error(error);
}
}).listen(3000, () => {
console.log("Server is listening on port 3000");
});
然後我們建一個 script,這個 script 會透過 HTML 中 <head>
元素裡面的 <script>
從瀏覽器跟 server 請求 js 程式碼,並在瀏覽器上執行。所以我們就叫這個 file 為 client.js,讓我們區分它的運行環境是在 client 端。程式碼如下:
async function onClick() {
const response = await fetch("http://localhost:3000/api/cat-names");
const json = await response.json();
const { catNames } = json;
const index = Math.floor(Math.random() * catNames.length);
const catName = catNames[index];
document.body.innerText = catName;
}
基本上裡面就是一個 onClick 的 function,在點擊 button 時讓瀏覽器跟 server 請求資料,然後等待 server 隨機選擇一個名字送回給我們(瀏覽器),再將名字塞入 HTML 的 body 中。launch 網站後結果如下:
太好了,現在我們完成了一個簡單的網站。現在開始讓我們來改寫一下。首先,我們知道底下 highlight 那行是要載入 client.js
。所以這裡我們可以直接把 client.js
裡面的程式碼直接貼過來這邊。
// ...
async function server(url) {
// ...
if (url === "/") {
const html = `<!doctype html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script src="client.js"></script>
<button onclick="onClick()">Reveal Cat Name</button>
</body>
</html>
`;
return { content: html, contentType: "text/html" };
}
// ...
}
修改後程式碼如下:
// ...
async function server(url) {
// ...
if (url === "/") {
const html = `<!doctype html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script>
async function onClick() {
const response = await fetch("http://localhost:3000/api/cat-names");
const json = await response.json();
const { catNames } = json;
const index = Math.floor(Math.random() * catNames.length);
const catName = catNames[index];
document.body.innerText = catName;
}
</script>
<button onclick="onClick()">Reveal Cat Name</button>
</body>
</html>
`;
return { content: html, contentType: "text/html" };
}
// ...
}
這時我們可以發現這段 code 有一些可以優化的地方。比方說,我們可以移除 async 讓 onClick handler 是同步執行的。
// ...
async function server(url) {
// ...
if (url === "/") {
const response = await fetch("http://localhost:3000/api/cat-names");
const json = await response.json();
const html = `<!doctype html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script>
function onClick() {
const { catNames } = json;
const index = Math.floor(Math.random() * catNames.length);
const catName = catNames[index];
document.body.innerText = catName;
}
</script>
<button onclick="onClick()">Reveal Cat Name</button>
</body>
</html>
`;
return { content: html, contentType: "text/html" };
}
// ...
}
這時我們可以嘗試點擊一下 button,看看會發生什麼事。
我們再修改一下讓變數可以傳到字串裡。
// ...
async function server(url) {
// ...
if (url === "/") {
const response = await fetch("http://localhost:3000/api/cat-names");
const json = await response.json();
const html = `<!doctype html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script>
function onClick() {
const { catNames } = ${JSON.stringify(json)};
const index = Math.floor(Math.random() * catNames.length);
const catName = catNames[index];
document.body.innerText = catName;
}
</script>
<button onclick="onClick()">Reveal Cat Name</button>
</body>
</html>
`;
return { content: html, contentType: "text/html" };
}
// ...
}
我們重新點擊一下 button,看看會發生什麼事。
Bada Bing Bada Boom! 我們成功了!而且更重要的事,你們有發現嗎?點擊 button 並不會 call API 請求資料。因為在發送頁面的 HTML 時,資料已經塞在 script
元素中一起發送過去了。
所以我們可以直接刪除 API route,也就是下方 highlight 的程式碼。
//...
async function server(url) {
if (url === "/api/cat-names") {
const catFile = await readFile("./cats.txt", "utf8");
const catNames = catFile.split("\n");
const json = { catNames };
return { content: JSON.stringify(json), contentType: "application/json" };
}
// ...
}
並將讀取檔案的 code 寫在頁面的 route 下。
// ...
async function server(url) {
// ...
if (url === "/") {
const catFile = await readFile("./cats.txt", "utf8");
const catNames = catFile.split("\n");
const json = { catNames };
const html = `<!doctype html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script>
function onClick() {
const { catNames } = ${JSON.stringify(json)};
const index = Math.floor(Math.random() * catNames.length);
const catName = catNames[index];
document.body.innerText = catName;
}
</script>
<button onclick="onClick()">Reveal Cat Name</button>
</body>
</html>
`;
return { content: html, contentType: "text/html" };
}
// ...
}
但這裡我們還有一個可以優化的地方。那就是我們知道實際上 client 端其實只會用到一個貓的名字。傳所有貓的名字給 client 對 performance 滿不好的。那我們能不能把 random 的邏輯也提升(Lifting)到 server 呢?完全可以的!
// ...
async function server(url) {
// ...
if (url === "/") {
const catFile = await readFile("./cats.txt", "utf8");
const catNames = catFile.split("\n");
const index = Math.floor(Math.random() * catNames.length);
const catName = catNames[index];
const json = { catName };
const html = `<!doctype html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script>
function onClick() {
const { catName } = ${JSON.stringify(json)};
document.body.innerText = catName;
}
</script>
<button onclick="onClick()">Reveal Cat Name</button>
</body>
</html>
`;
return { content: html, contentType: "text/html" };
}
// ...
}
我們可以看到已經成功將 random 的邏輯移到 server 了。
有用過 Server Components 的大家應該已經能夠猜到,其實上面整個過程就是 Server Components 的運作原理。:.゚ヽ(*´∀`)ノ゚.:
Server Components 有哪些好處,各位應該有從上面的優化過程中體會到吧~ 我來幫各位條列我們剛剛發現的優點:
- 可以善用 server 的算力
- 也可以減少資料量的傳輸 => 這裡的 case 是事先 random 選出我們要的貓名
- 甚至還降低 JS bundle size(在 client 端刪除了 fetch API 的程式碼)。
- 減少 Request 的次數 => 原本頁面請求一次,打 API 請求貓名又一次,共兩次。而現在只要請求頁面,就可以取得所有我們需要的資料。
這裡補充一點:減少請求的數量可以有效幫助提 server 的效能,因為這樣可以減少因為大量請求導致的併發負擔。即使請求處理時間增加了 10-20%,如果能顯著減少請求數量,這樣的權衡通常是值得的。長時間的請求處理雖然會降低單個請求的效率,但伺服器能夠更有效地管理資源,減少了頻繁的上下文切換(context switch)和資源競爭。
總結
React 用 Server Components 完全打破了 MVC 的架構的設計模式。但以結果來看,無論是在開發體驗還是使用者體驗上絕對是更好的優化。 最後我只想說 Dan Abramov 真滴牛批!我們下回見~
- Branch:
- main => 程式碼最終版本
- initial-state => 初始版本,未開始優化程式碼的狀態