Bài 24.1: SSR concepts trong TypeScript

Bài 24.1: SSR concepts trong TypeScript
Trong thế giới lập trình web hiện đại, việc xây dựng các ứng dụng nhanh, thân thiện với công cụ tìm kiếm (SEO) và mang lại trải nghiệm người dùng tốt là vô cùng quan trọng. Một trong những kỹ thuật mạnh mẽ giúp đạt được điều này là Server-Side Rendering (SSR). Kết hợp SSR với sức mạnh của TypeScript mang lại sự an toàn về kiểu dữ liệu, khả năng bảo trì và năng suất cao hơn cho các ứng dụng web phức tạp.
Trong bài viết này, chúng ta sẽ đi sâu vào các khái niệm cốt lõi của SSR, hiểu cách nó hoạt động, so sánh nó với Client-Side Rendering (CSR) truyền thống, khám phá những lợi ích và thách thức của nó, và đặc biệt là xem TypeScript đóng vai trò gì trong bức tranh này.
Server-Side Rendering (SSR) là gì?
Về cơ bản, Server-Side Rendering là kỹ thuật mà máy chủ (server) sẽ xử lý yêu cầu, render (dựng) trang web thành một chuỗi HTML hoàn chỉnh ngay trên server, và sau đó gửi chuỗi HTML đó về cho trình duyệt của người dùng. Trình duyệt chỉ việc nhận lấy chuỗi HTML đã được render sẵn và hiển thị nội dung ngay lập tức.
Điều này khác biệt đáng kể so với mô hình Client-Side Rendering (CSR), nơi máy chủ chỉ gửi về một file HTML rất nhỏ, chủ yếu chứa các thẻ <script>
để tải mã JavaScript. Toàn bộ việc dựng giao diện (DOM Manipulation) và hiển thị nội dung sẽ do trình duyệt (client) thực hiện bằng JavaScript sau khi đã tải xong code.
Hãy hình dung:
- CSR: Server gửi cho bạn một "công thức" (code JS) và "nguyên liệu" (dữ liệu - sau khi JS chạy đi fetch). Bạn (trình duyệt) phải tự "nấu" (dựng giao diện) dựa trên công thức và nguyên liệu đó.
- SSR: Server đã "nấu" (dựng HTML) sẵn một phần hoặc toàn bộ món ăn rồi gửi cho bạn. Bạn chỉ cần bày ra đĩa (hiển thị) và thưởng thức ngay. Sau đó, JavaScript sẽ chạy để "trang trí" thêm (làm cho trang tương tác).
Cơ chế hoạt động của SSR
Quy trình hoạt động của SSR thường diễn ra như sau:
- Người dùng gửi yêu cầu: Trình duyệt gõ địa chỉ URL hoặc click vào link, gửi yêu cầu đến máy chủ.
- Máy chủ nhận yêu cầu: Máy chủ Node.js (hoặc ngôn ngữ khác có khả năng chạy code rendering) nhận request.
- Máy chủ lấy dữ liệu (nếu cần): Nếu trang cần dữ liệu từ API, database... máy chủ sẽ thực hiện việc này ngay lập tức trước khi render.
- Máy chủ Render HTML: Máy chủ sử dụng code ứng dụng (thường là cùng framework/library front-end như React, Vue, Angular nhưng chạy trên server) để tạo ra một chuỗi HTML hoàn chỉnh chứa toàn bộ nội dung và cấu trúc trang.
- Máy chủ gửi HTML về trình duyệt: Chuỗi HTML này được gửi về trình duyệt như phản hồi của yêu cầu.
- Trình duyệt hiển thị HTML: Trình duyệt nhận HTML và bắt đầu hiển thị nội dung ngay lập tức. Người dùng có thể thấy nội dung trang rất nhanh.
- Trình duyệt tải JavaScript: Song song với việc hiển thị HTML, trình duyệt tải về các file JavaScript liên quan đến trang.
- Hydration: Sau khi JS được tải và thực thi, nó sẽ kết nối (hydrate) với cấu trúc HTML tĩnh đã có. Lúc này, các event listeners được gắn vào, các tương tác động được kích hoạt, và ứng dụng trở nên tương tác hoàn chỉnh giống như một ứng dụng CSR.
// Code minh hoạ (ý tưởng, không phải code chạy thực tế)
// Server-side (giả định chạy trên Node.js)
import { renderToString } from 'react-dom/server'; // Ví dụ với React
import App from './App'; // Component gốc của ứng dụng React
import { fetchDataForPage } from './api'; // Hàm lấy dữ liệu (có thể dùng async)
// Giả sử có một route handler
async function handleRequest(req: any, res: any) {
const pageData = await fetchDataForPage(req.params.id); // Lấy dữ liệu cho trang
// Render component React thành chuỗi HTML
const appHtml = renderToString(<App data={pageData} />);
// Đặt dữ liệu ban đầu vào script tag để client sử dụng khi hydrate
const initialStateScript = `<script>window.__INITIAL_DATA__ = ${JSON.stringify(pageData)};</script>`;
// Kết hợp HTML của ứng dụng vào template HTML cơ bản
const fullHtml = `
<!DOCTYPE html>
<html>
<head>
<title>SSR Example</title>
</head>
<body>
<div id="root">${appHtml}</div>
${initialStateScript}
<script src="/client.js"></script> <!-- Client-side JS bundle -->
</body>
</html>
`;
res.send(fullHtml); // Gửi HTML về trình duyệt
}
// Trong ví dụ này:
// - `fetchDataForPage` là nơi chúng ta lấy dữ liệu cần thiết trên server.
// - `renderToString` từ React (hoặc framework tương tự) biến component ảo thành chuỗi HTML.
// - Dữ liệu ban đầu (`pageData`) được nhúng vào một `<script>` tag với biến toàn cục (`window.__INITIAL_DATA__`) để client có thể truy cập ngay lập tức mà không cần fetch lại.
// - Client-side JS (`client.js`) sẽ tải về và sử dụng dữ liệu trong `window.__INITIAL_DATA__` để *hydrate* ứng dụng.
SSR so với CSR: Ưu và nhược điểm
Để hiểu rõ hơn giá trị của SSR, chúng ta hãy so sánh trực tiếp với Client-Side Rendering (CSR):
Client-Side Rendering (CSR):
- Cách hoạt động: Server gửi HTML tối thiểu và link JS. Trình duyệt tải JS, fetch data, dựng DOM.
- Ưu điểm:
- Đơn giản hơn cho server (chỉ phục vụ file tĩnh).
- Hiệu ứng chuyển trang nhanh sau lần tải đầu tiên (SPA - Single Page Application).
- Giảm tải cho server sau khi trang đầu tiên được tải.
- Nhược điểm:
- SEO kém: Search engine crawler gặp khó khăn khi index nội dung động được tạo bởi JS. Googlebot ngày càng thông minh hơn nhưng vẫn có giới hạn và chậm hơn.
- Thời gian tải ban đầu chậm: Người dùng thấy trang trắng (blank page) cho đến khi JS tải, phân tích, thực thi và dựng DOM.
- Performance ban đầu: Các chỉ số như First Contentful Paint (FCP) thường kém.
- Phụ thuộc JS: Không hoạt động nếu JS bị tắt hoặc có lỗi.
Server-Side Rendering (SSR):
- Cách hoạt động: Server dựng HTML hoàn chỉnh, gửi về trình duyệt. Trình duyệt hiển thị HTML, sau đó tải JS và hydrate.
- Ưu điểm:
- SEO tuyệt vời: Crawler nhận được HTML đầy đủ nội dung, dễ dàng index.
- Performance & Tốc độ tải ban đầu: Người dùng thấy nội dung ngay lập tức (Faster First Contentful Paint - FCP). Cải thiện trải nghiệm người dùng và các chỉ số Core Web Vitals.
- Cải thiện trải nghiệm trên mạng chậm hoặc thiết bị yếu: Nội dung hiển thị nhanh dù JS chưa tải xong hoặc chạy chậm.
- Hỗ trợ các trình duyệt hoặc thiết bị không hỗ trợ JS tốt: Nội dung vẫn hiển thị (dù không tương tác).
- Nhược điểm:
- Tăng tải cho Server: Server phải thực hiện việc render cho mỗi yêu cầu (trừ khi có cache). Điều này có thể tốn kém tài nguyên hơn, đặc biệt với lượng truy cập lớn.
- Độ phức tạp: Code base phức tạp hơn vì phải viết code chạy được cả trên server và client (Isomorphic/Universal code), quản lý state giữa server và client.
- Time To Interactive (TTI) có thể chậm: Mặc dù người dùng thấy nội dung nhanh, họ vẫn phải đợi JS tải và hydrate xong mới có thể tương tác đầy đủ. Nếu JS bundle quá lớn, TTI có thể bị ảnh hưởng.
Tại sao cần SSR? Lợi ích chi tiết
Chúng ta đã điểm qua các ưu điểm, giờ hãy phân tích sâu hơn lý do tại sao nhiều ứng dụng hiện đại chọn SSR:
- SEO (Search Engine Optimization): Đây là lý do hàng đầu cho nhiều website tĩnh hoặc có nội dung thay đổi thường xuyên như blog, tin tức, trang sản phẩm thương mại điện tử. Các công cụ tìm kiếm dễ dàng đọc và hiểu nội dung trong HTML được render sẵn, giúp trang của bạn xếp hạng tốt hơn trên kết quả tìm kiếm.
- Performance và Trải nghiệm người dùng:
- Hiển thị nội dung nhanh hơn (FCP): Người dùng không còn phải nhìn thấy một trang trắng. Nội dung xuất hiện gần như ngay lập tức, tạo cảm giác trang web nhanh và phản hồi tốt.
- Cải thiện Core Web Vitals: SSR giúp cải thiện các chỉ số quan trọng như Largest Contentful Paint (LCP), một chỉ số đo thời gian render phần tử nội dung lớn nhất trên màn hình, vốn là yếu tố quan trọng đánh giá trải nghiệm người dùng và SEO.
- Giảm "flash of unstyled content" (FOUC) hoặc "flash of layout shift": Vì HTML đã được render sẵn, ít có tình trạng nội dung "nhảy múa" khi JS tải xong và render lại.
- Truy cập cho Bot và Social Media: Khi chia sẻ link trang web trên các mạng xã hội (Facebook, Twitter, Slack...), các bot của nền tảng này sẽ "cào" (scrape) nội dung trang để tạo preview (tiêu đề, mô tả, hình ảnh). SSR đảm bảo các bot này nhận được HTML đầy đủ, giúp hiển thị preview chính xác và hấp dẫn hơn.
- Khả năng tiếp cận (Accessibility): Các trình duyệt cũ, các thiết bị đọc màn hình (screen readers) hoặc người dùng tắt JavaScript vẫn có thể truy cập và tiêu thụ nội dung cơ bản của trang web.
Các Khái niệm quan trọng trong SSR
Khi làm việc với SSR, bạn sẽ gặp một số thuật ngữ quan trọng:
Hydration: Đây là quá trình diễn ra trên trình duyệt sau khi HTML được render trên server và gửi về. JavaScript client-side sẽ được tải về, chạy và "đính kèm" các hàm xử lý sự kiện (event listeners) và logic tương tác vào cấu trúc HTML tĩnh đã có. Mục tiêu là biến trang HTML tĩnh ban đầu thành một ứng dụng web động, tương tác hoàn chỉnh mà không làm mất đi trạng thái hoặc cấu trúc ban đầu. Quá trình này cần đảm bảo code client-side khớp với HTML đã render trên server.
// Code minh hoạ Hydration (ý tưởng) // Trên Server: Render ra HTML và nhúng dữ liệu // <div id="app"><button>Click me</button></div> // <script>window.__COUNT__ = 0;</script> // Example initial state // Trên Client: declare const window: { __COUNT__?: number; } & Window; // Augment window type import ReactDOM from 'react-dom/client'; // React 18+ import App from './App'; const initialCount = window.__COUNT__ !== undefined ? window.__COUNT__ : 0; // Sử dụng hydrateRoot thay vì createRoot cho SSR const root = ReactDOM.hydrateRoot( document.getElementById('app') as HTMLElement, <App initialCount={initialCount} /> // Pass initial state ); // Trong ví dụ này: // - Server render ra HTML của component <App> và giá trị `initialCount` được lưu trong `window.__COUNT__`. // - Client-side JS tải về. // - `hydrateRoot` được gọi trên element gốc (`#app`). React sẽ cố gắng "đính" ứng dụng của nó vào cấu trúc HTML đã có, sử dụng `initialCount` làm trạng thái ban đầu. Nó sẽ gắn các event listener (như click button) để ứng dụng trở nên tương tác.
- Isomorphic / Universal Applications: Đây là thuật ngữ chỉ các ứng dụng web mà phần lớn code (đặc biệt là code dựng giao diện và logic nghiệp vụ) có thể chạy được cả trên môi trường server (Node.js) và môi trường client (trình duyệt). Việc này là nền tảng cho SSR vì bạn muốn sử dụng cùng một codebase để render trên server và hydrate trên client.
Server-Side Data Fetching: Thay vì fetch dữ liệu trên client sau khi trang tải xong, SSR thường yêu cầu fetch dữ liệu trên server trước khi render. Dữ liệu này sau đó được truyền xuống component để render thành HTML, và thường được nhúng vào trang HTML (ví dụ, qua
<script>
tag như ví dụ trên) để client có thể sử dụng ngay khi hydrate mà không cần fetch lại.// Code minh hoạ Server-Side Data Fetching với TypeScript interface UserProfile { id: number; name: string; email: string; } // Hàm fetch data (giả định chạy trên server) async function fetchUserProfile(userId: number): Promise<UserProfile> { // Logic fetch data từ database hoặc API... console.log(`Fetching profile for user ${userId} on server...`); return { id: userId, name: "John Doe", email: `john.doe${userId}@example.com` }; // Dữ liệu giả } // Hàm render trang user (giả định) async function renderUserPage(userId: number): Promise<string> { try { // Lấy dữ liệu với type safety trên server const user: UserProfile = await fetchUserProfile(userId); // Render HTML dựa trên dữ liệu (giả định) const html = ` <div> <h2>User Profile</h2> <p>ID: ${user.id}</p> <p>Name: ${user.name}</p> <p>Email: ${user.email}</p> </div> <script>window.__USER_DATA__ = ${JSON.stringify(user)};</script> `; return html; } catch (error) { console.error("Error rendering user page:", error); return "<p>Error loading user profile.</p>"; // Xử lý lỗi } } // Trong ví dụ này: // - Chúng ta định nghĩa kiểu `UserProfile` bằng TypeScript. // - Hàm `fetchUserProfile` được kỳ vọng trả về dữ liệu đúng kiểu `UserProfile`, đảm bảo tính an toàn. // - Khi gọi `fetchUserProfile` trong hàm `renderUserPage`, chúng ta biết chắc kiểu dữ liệu trả về là gì, giúp việc sử dụng nó để render HTML trở nên an toàn hơn, tránh các lỗi do sai kiểu hoặc thiếu thuộc tính. // - Dữ liệu này được nhúng vào HTML để client sử dụng khi hydrate, duy trì tính nhất quán về kiểu dữ liệu giữa server và client.
TypeScript đóng vai trò gì trong SSR?
Vậy TypeScript mang lại những giá trị gì khi xây dựng các ứng dụng SSR?
An toàn về kiểu dữ liệu (Type Safety): Đây là lợi ích lớn nhất. Trong môi trường SSR, bạn có code chạy cả trên server và client, thường xuyên truyền dữ liệu giữa hai môi trường này (ví dụ: dữ liệu fetch trên server được nhúng vào HTML để client sử dụng). TypeScript giúp đảm bảo cấu trúc dữ liệu và các props được truyền giữa server-side rendering logic và client-side hydration logic là nhất quán.
- Bạn định nghĩa kiểu dữ liệu cho dữ liệu fetch. Server fetch và render với kiểu đó. Client nhận dữ liệu đó và hydrate, TypeScript giúp đảm bảo client đang truy cập đúng các thuộc tính với đúng kiểu.
- Khi truyền props cho component được render trên server, TypeScript kiểm tra xem bạn có truyền đúng props với đúng kiểu hay không. Điều này giảm thiểu lỗi runtime khi hydrate trên client. ```typescript // Minh hoạ Type Safety trong SSR props
interface GreetingProps {
name: string; message?: string; // Optional property
}
// Component (chạy cả server và client) function Greeting(props: GreetingProps) {
return <h1>Hello, {props.name}! {props.message || "Welcome."}</h1>;
}
// Server-side rendering import { renderToString } from 'react-dom/server';
const serverRenderedHtml1 = renderToString(<Greeting name="Alice" message="Good morning" />); // OK const serverRenderedHtml2 = renderToString(<Greeting name="Bob" />); // OK, message là optional // const serverRenderedHtml3 = renderToString(<Greeting name="Charlie" age={30} />); // Lỗi TypeScript: 'age' không tồn tại trong GreetingProps // const serverRenderedHtml4 = renderToString(<Greeting />); // Lỗi TypeScript: Thiếu thuộc tính 'name' bắt buộc
console.log(serverRenderedHtml1); console.log(serverRenderedHtml2);
// Ở đây, TypeScript kiểm tra ngay lúc compile/development việc truyền props cho component, // đảm bảo rằng HTML được render trên server dựa trên props đúng kiểu, // từ đó giúp quá trình Hydration trên client diễn ra mượt mà hơn vì component client cũng kỳ vọng props có kiểu tương tự. ```
- Khả năng bảo trì (Maintainability): Codebase của ứng dụng SSR thường phức tạp hơn do tính Isomorphic. TypeScript giúp quản lý độ phức tạp này bằng cách cung cấp cấu trúc rõ ràng, dễ đọc và dễ refactor. Khi bạn thay đổi một interface hoặc kiểu dữ liệu, TypeScript sẽ báo lỗi ở tất cả những nơi bị ảnh hưởng, giúp bạn sửa lỗi proactively.
- Tooling và Năng suất: Các trình soạn thảo code hiện đại (VS Code, WebStorm...) hỗ trợ TypeScript rất tốt, cung cấp IntelliSense, kiểm tra lỗi ngay khi gõ, refactoring tự động. Điều này giúp tăng năng suất đáng kể khi làm việc với dự án SSR có quy mô lớn.
- Định nghĩa rõ ràng API: Khi làm việc nhóm, việc sử dụng TypeScript với các interface và type alias giúp định nghĩa rõ ràng các "API" nội bộ, ví dụ: kiểu dữ liệu mong đợi khi render một trang, cấu trúc của state ban đầu cần truyền từ server xuống client.
Thách thức khi triển khai SSR
Mặc dù SSR mang lại nhiều lợi ích, việc triển khai nó cũng đi kèm với những thách thức:
- Cấu hình và Triển khai phức tạp: Thiết lập môi trường server để chạy code front-end, tích hợp quy trình build cho cả server và client có thể phức tạp hơn so với CSR đơn thuần.
- Quản lý State: Việc chia sẻ và đồng bộ state giữa server và client (đặc biệt là state ban đầu được fetch trên server) cần có chiến lược rõ ràng.
- Truy cập các API chỉ có trong trình duyệt: Các code hoặc library chỉ hoạt động trong môi trường trình duyệt (ví dụ: truy cập
window
,document
) sẽ gây lỗi khi chạy trên server Node.js. Cần kiểm tra và xử lý các trường hợp này (ví dụ: sử dụngtypeof window !== 'undefined'
). - Quản lý Cache: Caching trên server cần được cân nhắc kỹ để tránh render lại cùng một nội dung nhiều lần không cần thiết nhưng vẫn đảm bảo tính kịp thời của dữ liệu.
- Thời gian Tới tương tác (TTI): Như đã đề cập, dù hiển thị nhanh, trang web có thể mất nhiều thời gian hơn để trở nên tương tác hoàn chỉnh nếu JS bundle lớn hoặc quá trình hydrate phức tạp.
Khi nào nên sử dụng SSR?
SSR không phải là giải pháp cho mọi bài toán. Nó phù hợp nhất cho:
- Các trang web mà SEO là yếu tố sống còn (website tin tức, blog, thương mại điện tử, trang giới thiệu sản phẩm/công ty).
- Các ứng dụng cần tối ưu tốc độ tải và hiển thị ban đầu để cải thiện trải nghiệm người dùng và giữ chân họ.
- Các trang tĩnh hoặc ít tương tác ban đầu, nơi nội dung là ưu tiên hàng đầu.
Ngược lại, với các ứng dụng web (web application) nặng về tương tác, dashboard, hoặc các trang chỉ dành cho người dùng đăng nhập (không cần SEO công khai), CSR có thể là lựa chọn đơn giản và hiệu quả hơn.
Comments