•17 min read

Giao thức React Server Components Flight: Định dạng truyền tải luồng, tuần tự hóa & Hydration chọn lọc

Giao thức React Server Components Flight: Định dạng truyền tải luồng, tuần tự hóa & Hydration chọn lọc

React Server Components (RSC) thay đổi cơ bản mô hình tư duy phát triển ứng dụng React, chuyển phần lớn việc render và tìm nạp dữ liệu sang phía máy chủ. Mô hình này giới thiệu một giao thức giao tiếp mới, React Flight, quy định cách máy chủ truyền các bản cập nhật UI đến máy khách. Việc hiểu giao thức này, các cơ chế tuần tự hóa của nó và quá trình hydrate chọn lọc sau đó là rất quan trọng để tối ưu hóa các ứng dụng dựa trên RSC.

Audio Briefing
0:00 / 0:00

Giao thức React Flight: Định dạng truyền tải dạng luồng

Giao thức React Flight là một định dạng tùy chỉnh, giống JSON dạng luồng được thiết kế để truyền tải cây phần tử React một cách hiệu quả. Không giống như HTML truyền thống, vốn là một tài liệu tĩnh, Flight là một luồng động, dựa trên các lệnh. Nó không phải là HTML thô; nó là một biểu diễn tuần tự hóa của các phần tử React, bao gồm kiểu, props và các phần tử con của chúng, cùng với các hướng dẫn để máy khách tái tạo UI.

Nguyên tắc cốt lõi là máy chủ render các component và gửi một luồng "chunk" đến máy khách. Mỗi chunk có thể chứa các phần tử UI mới, cập nhật các phần tử hiện có hoặc hướng dẫn tải mã phía máy khách. Bản chất luồng này rất quan trọng đối với hiệu suất cảm nhận, cho phép máy khách bắt đầu render các phần của UI trước khi toàn bộ cây component được giải quyết.

Cấu trúc định dạng truyền tải

Định dạng truyền tải Flight là một chuỗi các mảng JSON, mỗi mảng đại diện cho một lệnh hoặc tải trọng dữ liệu cụ thể. Phần tử đầu tiên của mỗi mảng là một thẻ số cho biết loại lệnh.

Hãy xem xét một RSC đơn giản:

// app/page.tsx (Server Component)
import ClientComponent from './ClientComponent';

export default function Page() {
  const data = fetchData(); // Server-side data fetching
  return (
    <div>
      <h1>Welcome to RSC</h1>
      <p>{data}</p>
      <ClientComponent />
    </div>
  );
}

// app/ClientComponent.tsx (Client Component)
'use client';
import { useState } from 'react';

export default function ClientComponent() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>
      Client Count: {count}
    </button>
  );
}

Khi Page được render trên máy chủ, giao thức Flight có thể phát ra một luồng tương tự như thế này (được đơn giản hóa để dễ hiểu):

0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome to RSC"}],["$","p",null,{"children":"Server Data"}],["$","@1",null,{}]]}]
1:I{"id":"./app/ClientComponent.tsx","chunks":["app_ClientComponent_tsx"],"name":"default"}

Hãy cùng phân tích các loại lệnh phổ biến:

  • 0: [type, key, ref, props]
    • Đại diện cho một phần tử React.
    • type: Có thể là một chuỗi (đối với các phần tử nội tại như "div", "p") hoặc một tham chiếu đến một module component phía máy khách (ví dụ: "$@1").
    • key: Prop key của React.
    • ref: Prop ref của React.
    • props: Một đối tượng chứa các prop của phần tử.
  • 1: I{id, chunks, name}
    • Lệnh để import một module component phía máy khách.
    • id: Định danh module (ví dụ: đường dẫn đến tệp component phía máy khách).
    • chunks: Mảng tên chunk để tải cho module này.
    • name: Tên được export từ module (ví dụ: "default").
  • 2: [id, props]
    • Đại diện cho một instance component phía máy khách. id tham chiếu đến một module đã được import trước đó.
  • 3: [id, value]
    • Đại diện cho một giá trị đã được tuần tự hóa (ví dụ: một promise giải quyết thành một component).
  • 4: [id, error]
    • Đại diện cho một fallback error boundary.
  • 5: [id, promise_id]
    • Đại diện cho một promise cần được giải quyết. Máy khách sẽ đợi một chunk khác với promise_id để giải quyết điều này.

Runtime React phía máy khách phân tích luồng này, tái tạo DOM ảo, sau đó render nó. Khi gặp lệnh I, bundler phía máy khách (ví dụ: Webpack, Turbopack) được hướng dẫn tải chunk JavaScript tương ứng cho component phía máy khách.

Tuần tự hóa Props

Không phải tất cả các kiểu JavaScript đều có thể được tuần tự hóa trực tiếp qua đường truyền. Các hàm, trình xử lý sự kiện và các đối tượng phức tạp không phải là cấu trúc dữ liệu đơn giản không thể được truyền trực tiếp từ Server Component sang Client Component.

Props từ Server Component sang Client Component: Chỉ dữ liệu có thể tuần tự hóa (kiểu nguyên thủy, đối tượng đơn giản, mảng) mới có thể được truyền. Các hàm, phần tử JSX hoặc các instance class không thể tuần tự hóa.

// ServerComponent.tsx
import ClientComponent from './ClientComponent';

export default function ServerComponent() {
  const serverData = { message: 'Hello from server' };
  // This is fine: serverData is serializable
  return <ClientComponent data={serverData} />;
}

// ClientComponent.tsx
'use client';
export default function ClientComponent({ data }) {
  // data.message will be 'Hello from server'
  return <p>{data.message}</p>;
}

Props từ Client Component sang Server Component (thông qua children): Đây là một mẫu phổ biến. Một Server Component có thể render một Client Component và truyền một Server Component làm phần tử con. Server Component con được render trên máy chủ và đầu ra tuần tự hóa của nó được truyền cho Client Component.

// ServerComponent.tsx
import ClientWrapper from './ClientWrapper';

export default function ServerComponent() {
  // Server-rendered content passed as children to a Client Component
  return (
    <ClientWrapper>
      <h2>This is a Server Component child</h2>
      <p>It was rendered on the server.</p>
    </ClientWrapper>
  );
}

// ClientWrapper.tsx
'use client';
import { useState } from 'react';

export default function ClientWrapper({ children }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Click me: {count}</button>
      {children} {/* Server-rendered content */}
    </div>
  );
}

Trong trường hợp này, children không được tuần tự hóa dưới dạng một hàm hoặc đối tượng JSX. Thay vào đó, các phần tử <h2> và <p> được render trên máy chủ thành biểu diễn định dạng truyền tải Flight của chúng, và biểu diễn tuần tự hóa đó được truyền cho ClientWrapper. Runtime React phía máy khách sau đó xử lý children đã được tuần tự hóa này như một phần của các prop của ClientWrapper.

Advertisement

Phân vùng Client và Server Component

Sự phân biệt giữa Client và Server Component được thực thi ở bước build. Các bundler (như Webpack hoặc Turbopack trong Next.js) phân tích chỉ thị 'use client'.

  • Server Components: Không bao giờ được đóng gói cho máy khách. Mã của chúng chỉ nằm trên máy chủ. Bất kỳ import nào trong Server Component cũng được giả định là phía máy chủ trừ khi chúng rõ ràng import một Client Component.
  • Client Components: Được đóng gói cho máy khách. Khi một Server Component import một Client Component, bundler tạo một chunk JavaScript riêng cho Client Component đó. Server Component sau đó chỉ truyền một tham chiếu đến chunk này thông qua giao thức Flight.

Việc phân vùng này rất quan trọng để giảm kích thước gói phía máy khách và cho phép tìm nạp dữ liệu phía máy chủ mà không làm lộ logic hoặc thông tin đăng nhập nhạy cảm cho máy khách.

Streaming HTML Shells và Suspense

RSC tận dụng cơ chế Suspense của React để streaming. Khi một Server Component thực hiện một hoạt động bất đồng bộ (ví dụ: tìm nạp dữ liệu) chưa được giải quyết, React có thể tạm dừng render phần đó của cây.

Thay vì chờ tất cả dữ liệu được giải quyết, máy chủ có thể gửi một "shell" HTML ban đầu chứa các phần UI đã được giải quyết và các placeholder (như thẻ <template> hoặc <div> với thuộc tính data-rsc-id) cho nội dung bị tạm dừng.

<!-- Initial HTML Shell -->
<!DOCTYPE html>
<html>
  <head>...</head>
  <body>
    <div id="__next">
      <h1>Welcome to RSC</h1>
      <!-- Placeholder for suspended content -->
      <div id="rsc-123"></div>
    </div>
    <script src="/_next/static/chunks/main.js"></script>
  </body>
</html>

Khi dữ liệu bị tạm dừng được giải quyết trên máy chủ, React gửi các chunk giao thức Flight bổ sung. Các chunk này chứa UI thực tế cho các phần bị tạm dừng, mà runtime React phía máy khách sau đó sử dụng để thay thế các placeholder. Điều này cho phép Thời gian đến Byte đầu tiên (TTFB) và Vẽ nội dung đầu tiên (FCP) nhanh hơn.

// Later Flight chunk resolving rsc-123
3:["rsc-123",["$","p",null,{"children":"Resolved Server Data"}]]

Cơ chế này được cung cấp bởi ReactDOMServer.renderToReadableStream (đối với môi trường Node.js) hoặc renderToPipeableStream. Các API này trả về một luồng mà trước tiên phát ra shell HTML, sau đó tiếp tục phát ra các thẻ script chứa các chunk giao thức Flight.

Hydration chọn lọc

Hydration là quá trình mà React phía máy khách tiếp quản HTML được render từ máy chủ, gắn các trình lắng nghe sự kiện và làm cho UI có tính tương tác. Trong React truyền thống, toàn bộ cây ứng dụng sẽ được hydrate cùng một lúc. Nếu một component lớn, không quan trọng bị chậm trong quá trình hydrate, nó sẽ chặn tính tương tác cho toàn bộ trang.

Hydration chọn lọc, được giới thiệu với React 18, giải quyết vấn đề này bằng cách ưu tiên quá trình hydrate. Khi runtime React phía máy khách khởi động, nó không hydrate mọi thứ ngay lập tức. Thay vào đó, nó quan sát các tương tác của người dùng.

  1. Hydration ban đầu: React bắt đầu hydrate từ gốc, nhưng nó có thể tạm dừng nếu gặp ranh giới <Suspense> hoặc một component vẫn đang tải.
  2. Tương tác của người dùng: Nếu người dùng tương tác với một phần cụ thể của DOM (ví dụ: nhấp vào một nút), React ưu tiên hydrate component chịu trách nhiệm cho tương tác đó và các tổ tiên của nó. Điều này có nghĩa là các phần tử tương tác quan trọng trở nên phản hồi nhanh hơn nhiều.
  3. Hydration nền: Các component không quan trọng tiếp tục hydrate trong nền, mà không chặn các tương tác của người dùng.

Việc ưu tiên này đạt được bằng cách React theo dõi nội bộ các phần của cây bị "chặn" (ví dụ: chờ một chunk tải hoặc một promise giải quyết) và những phần nào "có tính tương tác". Khi một sự kiện xảy ra, React xác định tổ tiên chung thấp nhất của mục tiêu sự kiện và component cần xử lý nó, sau đó ưu tiên hydrate đường dẫn đó.

Luồng ví dụ:

  1. Máy chủ gửi shell HTML với một nút và một component lớn, tải chậm.
  2. Máy khách nhận HTML. Nút hiển thị nhưng không tương tác.
  3. Người dùng nhấp vào nút.
  4. React phát hiện nhấp chuột, xác định component của nút và ưu tiên hydrate component đó và các phần tử cha của nó.
  5. Nút trở nên tương tác gần như ngay lập tức.
  6. Trong khi đó, component tải chậm tiếp tục tìm nạp dữ liệu và hydrate trong nền, mà không chặn nút.
Advertisement

Kiến trúc & Đánh đổi

Tính năngSSR truyền thống (ví dụ: Next.js Pages)React Server Components (RSC)
Tìm nạp dữ liệugetServerSideProps, getStaticProps, useEffect trên máy kháchasync/await trực tiếp trong Server Components
Kích thước góiGói máy khách bao gồm tất cả mã componentGói máy khách chỉ bao gồm Client Components
HydrationTất cả hoặc không có gì (React 17), Chọn lọc (React 18)Hydration chọn lọc, được tăng cường bởi streaming
Tải trọng mạngHTML + dữ liệu JSON (cho props)Shell HTML + luồng giao thức Flight
Tính tương tácSau khi hydrate hoàn chỉnhTiến bộ, được ưu tiên theo tương tác của người dùng
Độ phức tạpPhân tách rõ ràng máy khách/máy chủMô hình máy khách/máy chủ kết hợp, mô hình tư duy mới
Bộ nhớ đệmBộ nhớ đệm HTML, CDNBộ nhớ đệm HTML, bộ nhớ đệm tải trọng RSC (thử nghiệm)

Những vấn đề cần lưu ý khi triển khai & Khắc phục sự cố

  1. Lỗi "Functions are not valid as a React child" / "Objects are not valid as a React child" với RSC:

    • Vấn đề: Cố gắng truyền một hàm, phần tử JSX hoặc đối tượng không thể tuần tự hóa trực tiếp từ Server Component sang Client Component dưới dạng prop. Giao thức Flight không thể tuần tự hóa những thứ này.
    • Cách khắc phục:
      • Nếu truyền JSX, hãy render nó trên máy chủ và truyền kết quả (tải trọng Flight đã tuần tự hóa) dưới dạng children cho Client Component.
      • Nếu truyền một hàm, hãy định nghĩa nó trong chính Client Component hoặc chỉ truyền dữ liệu có thể tuần tự hóa và để Client Component xây dựng hàm.
      • Đảm bảo tất cả các prop được truyền từ Server sang Client Component đều có thể tuần tự hóa JSON.
    • Ví dụ:
      // ❌ Bad: Passing a function from Server to Client
      // ServerComponent.tsx
      function handleClick() { console.log('clicked'); }
      <ClientButton onClick={handleClick} />
      
      // ✅ Good: Function defined in Client Component
      // ClientButton.tsx
      'use client';
      export default function ClientButton() {
        function handleClick() { console.log('clicked'); }
        return <button onClick={handleClick}>Click</button>;
      }
      
  2. Mã Client Component vô tình chạy trên máy chủ:

    • Vấn đề: Quên chỉ thị 'use client' ở đầu tệp. Bundler sẽ coi nó là Server Component, dẫn đến lỗi nếu nó sử dụng các API chỉ dành cho máy khách (ví dụ: window, useState).
    • Cách khắc phục: Luôn bao gồm 'use client' ở đầu bất kỳ tệp nào được dự định là Client Component.
    • Khắc phục sự cố: Kiểm tra stack trace để tìm các lỗi phía máy chủ bắt nguồn từ các tệp mà bạn mong đợi chỉ dành cho máy khách.
  3. Gói Client Component lớn do các dependency bắc cầu:

    • Vấn đề: Một Client Component nhỏ import một thư viện lớn, kéo toàn bộ thư viện đó vào gói máy khách.
    • Cách khắc phục:
      • Phân tích kích thước gói máy khách bằng các công cụ như Next.js Bundle Analyzer.
      • Tái cấu trúc để chuyển càng nhiều logic càng tốt vào Server Components.
      • Sử dụng dynamic imports (import()) với React.lazy cho các client component ít quan trọng hơn để tải chúng một cách lười biếng.
      • Đảm bảo các thư viện có thể tree-shake được.
    • Ví dụ:
      // ClientComponent.tsx
      'use client';
      import { SomeHeavyUtility } from 'heavy-library'; // This pulls heavy-library into client bundle
      
      // Consider if SomeHeavyUtility can be used in a Server Component
      // or if this Client Component can be dynamically imported.
      
  4. Tải trang ban đầu chậm mặc dù có streaming:

    • Vấn đề: Layout gốc hoặc các component quan trọng bị tạm dừng quá lâu, làm chậm shell HTML ban đầu.
    • Cách khắc phục:
      • Xác định các thao tác tìm nạp dữ liệu hoặc component chậm nhất ở gốc.
      • Di chuyển việc tìm nạp dữ liệu không quan trọng sâu hơn vào cây component, được bao bọc trong các ranh giới <Suspense>, để chúng không chặn shell ban đầu.
      • Đảm bảo việc tìm nạp dữ liệu phía máy chủ của bạn được tối ưu hóa (ví dụ: truy vấn cơ sở dữ liệu, gọi API).
      • Sử dụng loading.tsx trong Next.js để cung cấp UI fallback ngay lập tức.
  5. HTML không khớp trong quá trình hydrate:

    • Vấn đề: HTML được render từ máy chủ khác với những gì React phía máy khách mong đợi để render, dẫn đến lỗi hydrate (Warning: Prop 'className' did not match.). Điều này thường xảy ra với nội dung động hoặc logic chỉ dành cho máy khách vô tình chạy trên máy chủ.
    • Cách khắc phục:
      • Đảm bảo bất kỳ logic chỉ dành cho máy khách nào (ví dụ: truy cập window, localStorage) được bảo vệ bởi typeof window !== 'undefined' hoặc được đặt trong các hook useEffect.
      • Tránh sử dụng Math.random() hoặc Date.now() trực tiếp trong các component được render cả trên máy chủ và máy khách mà không có sự đồng bộ hóa cẩn thận.
      • Nếu sử dụng thư viện của bên thứ ba, hãy đảm bảo nó tương thích với SSR/RSC. Đôi khi, việc bọc các thư viện chỉ dành cho máy khách trong một component với suppressHydrationWarning (như một giải pháp cuối cùng) hoặc import động có thể giúp ích.

Các câu hỏi thường gặp

1. Tôi có thể sử dụng useState hoặc useEffect trong Server Component không?

Không. Server Components là stateless và không có quyền truy cập vào các React Hooks như useState, useEffect, useRef, hoặc useContext. Các hook này là các cấu trúc phía máy khách để quản lý trạng thái và các tác dụng phụ trong trình duyệt. Bất kỳ component nào sử dụng các hook này phải được đánh dấu bằng 'use client'.

2. Làm cách nào để chia sẻ dữ liệu giữa Server và Client Components?

Dữ liệu có thể được truyền từ Server Component sang Client Component thông qua props, miễn là dữ liệu có thể tuần tự hóa (kiểu nguyên thủy, đối tượng đơn giản, mảng). Để truyền dữ liệu từ Client Component sang Server Component, bạn thường cần sử dụng một server action hoặc một API route. Server actions cho phép bạn gọi các hàm phía máy chủ trực tiếp từ client components.

3. Điều gì xảy ra nếu Server Component import một thư viện chỉ dành cho máy khách (ví dụ: một thư viện sử dụng window)?

Nếu Server Component import một thư viện dựa vào các API dành riêng cho trình duyệt (như window hoặc document) mà không có biện pháp bảo vệ thích hợp, nó sẽ gây ra lỗi runtime phía máy chủ. Bundler sẽ không tự động loại trừ mã này khỏi bản build máy chủ trừ khi nó là một phần của Client Component. Bạn phải đảm bảo các import như vậy chỉ được thực hiện trong các tệp được đánh dấu 'use client', hoặc import động chúng bằng ssr: false nếu sử dụng next/dynamic của Next.js.

4. Giao thức Flight xử lý lỗi trong quá trình streaming như thế nào?

Khi lỗi xảy ra trên máy chủ trong quá trình render Server Component, React có thể gửi một lệnh lỗi (4: [id, error]) trong luồng Flight. Điều này cho phép máy khách hiển thị một fallback error boundary cho phần UI bị ảnh hưởng, thay vì làm sập toàn bộ ứng dụng. Điều này tương tự như cách các ranh giới <Suspense> bắt các promise.

5. Giao thức Flight có dành riêng cho Next.js không?

Không, giao thức React Flight là một phần cốt lõi của React Server Components và không dành riêng cho Next.js. Next.js là một trong những người tiên phong áp dụng và cung cấp một framework mạnh mẽ để triển khai RSC. Các framework khác hoặc các thiết lập tùy chỉnh về lý thuyết có thể triển khai giao thức Flight để tận dụng RSC, nhưng Next.js cung cấp các công cụ tích hợp (bundling, routing, data fetching) giúp nó trở nên thực tế.

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement