Skip to content

深入探討 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。

client-component.tsx
"use client";
 
export default function ClientComponent() {
  console.log("client component run");
  return <div>ClientComponent</div>;
}

再將 client component 引入到 page (server component) 中。

page.tsx
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 和網站要顯示的資料。

    server.js
    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 端。程式碼如下:

    client.js
    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 裡面的程式碼直接貼過來這邊。

    server.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" };
      }
      // ...
    }

    修改後程式碼如下:

    server.js
    // ...
    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 是同步執行的。

    server.js
    // ...
    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,看看會發生什麼事。

    我們再修改一下讓變數可以傳到字串裡。

    server.js
    // ...
    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 的程式碼。

    server.js
    //...
    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 下。

    server.js
    // ...
    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 呢?完全可以的!

    server.js
    // ...
    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 有哪些好處,各位應該有從上面的優化過程中體會到吧~ 我來幫各位條列我們剛剛發現的優點:

    1. 可以善用 server 的算力
    2. 也可以減少資料量的傳輸 => 這裡的 case 是事先 random 選出我們要的貓名
    3. 甚至還降低 JS bundle size(在 client 端刪除了 fetch API 的程式碼)。
    4. 減少 Request 的次數 => 原本頁面請求一次,打 API 請求貓名又一次,共兩次。而現在只要請求頁面,就可以取得所有我們需要的資料。

    這裡補充一點:減少請求的數量可以有效幫助提 server 的效能,因為這樣可以減少因為大量請求導致的併發負擔。即使請求處理時間增加了 10-20%,如果能顯著減少請求數量,這樣的權衡通常是值得的。長時間的請求處理雖然會降低單個請求的效率,但伺服器能夠更有效地管理資源,減少了頻繁的上下文切換(context switch)和資源競爭。

    總結

    React 用 Server Components 完全打破了 MVC 的架構的設計模式。但以結果來看,無論是在開發體驗還是使用者體驗上絕對是更好的優化。 最後我只想說 Dan Abramov 真滴牛批!我們下回見~

    GitHub repo

    • Branch:
      • main => 程式碼最終版本
      • initial-state => 初始版本,未開始優化程式碼的狀態

    Reference

    © 2024 Eric Tsai