Server rendering có thể đưa một bài viết, trang sản phẩm hoặc khung dashboard hoàn chỉnh lên màn hình trước khi JavaScript tải xong. Tuy nhiên, nhiều ứng dụng SSR ngay sau đó lại gửi toàn bộ component tree sang browser và yêu cầu client dựng lại mọi listener, reactive effect và component instance. Trang trông như đã sẵn sàng trong khi main thread vẫn parse code và hydrate node. Một cú chạm trong khoảng trống đó có thể không tạo ra phản hồi nào.
Kiến trúc Islands bắt đầu bằng câu hỏi khác: vùng nào thực sự cần client runtime? Server tạo HTML hữu ích cho toàn route, nhưng chỉ những vùng tương tác độc lập mới trở thành ứng dụng JavaScript. Progressive hydration bổ sung thêm ranh giới thời gian: kể cả một island có tương tác cũng không nhất thiết hydrate lúc startup. Nó có thể đợi đến khi xuất hiện trong viewport, khi người dùng bộc lộ ý định, hoặc khi một media condition trở thành đúng.
Đây không chỉ là lazy-load component. Kiến trúc còn phải giữ nguyên server output, serialize state đáng tin cậy, xử lý event đến trước lúc kích hoạt, xếp lịch công việc mà không chặn input và chứng minh giao diện vẫn accessible trước khi JavaScript chạy.
Hydration có chi phí CPU và một contract về tính đúng
Hydration truyền thống duyệt DOM do server tạo trong lúc render component tree phía client. Framework liên kết virtual node với element sẵn có, tạo component state, gắn event handler và thường khởi tạo effect hoặc subscription. HTML được tái sử dụng, nhưng phần đáng kể công việc dựng tree vẫn xảy ra.
Chi phí có nhiều chiều:
| Chi phí | Browser phải làm gì | Biểu hiện với người dùng |
|---|---|---|
| Network | Tải framework, route và component module | Kích hoạt chậm trên mạng yếu |
| Parse/compile | Parse JavaScript và compile function | Long task trước khi code chạy |
| Execution | Tạo lại component, effect và listener | Input trễ, INP xấu |
| Memory | Giữ runtime object và reactive graph | Áp lực trên thiết bị ít RAM |
| Contention | Tranh tài nguyên với image, analytics và third party | Startup khó dự đoán |
Bundle nén 30 KB vẫn phát sinh chi phí giải nén, parsing, dependency và initialization. Ngoài transferred bytes, hãy đo main-thread time và long task trên thiết bị di động đại diện.
Hydration giả định lần render đầu ở client khớp server DOM. Date.now(), ID ngẫu nhiên, browser-only check, locale hay timezone khác nhau, cache cũ, HTML bị parser sửa và state thay đổi giữa SSR với hydration đều gây mismatch. Framework có thể cảnh báo, render lại subtree hoặc gắn behavior sai kỳ vọng.
// Server và client phải nhận cùng giá trị trong lần render đầu.
export type PriceProps = {
amountInCents: number;
currency: 'USD' | 'VND';
formattedAtBuild: string;
};
export function renderPrice(props: PriceProps): string {
return `<output>${escapeHtml(props.formattedAtBuild)}</output>`;
}
Server nên tính unstable presentation input một lần rồi serialize; component có thể refresh sau hydration. Warning mismatch là dấu hiệu contract bàn giao hỏng và client re-render có thể xóa lợi ích hiệu năng.
Warning
SSR không tự động tạo ra interactivity. Một button xuất hiện ở 800 ms nhưng đến giây thứ 3 mới hydrate vẫn có interaction gap. Hãy theo dõi thời điểm control thực sự dùng được, không chỉ thời điểm pixel xuất hiện.
Islands thay đổi đơn vị của interactivity
Một island là vùng có ranh giới rõ, gồm server-rendered markup, client entry module và input có thể serialize. Static content nằm giữa các island không bao giờ tham gia client component tree. Trang sản phẩm có thể hydrate image zoom, chọn tồn kho và giỏ hàng độc lập, trong khi title, description, specifications và related links vẫn chỉ là HTML.
Ranh giới nên bám theo behavior. Navigation menu và nút mở thuộc cùng island vì chia sẻ focus và expanded state. Tách từng button làm tăng request, payload và observer; gom toàn route lại xóa mất lợi ích.
| Chiến lược | HTML ban đầu | Kích hoạt phía client | Đánh đổi thường gặp |
|---|---|---|---|
| Full hydration | Hoàn chỉnh | Toàn bộ component tree | Mental model đơn giản, startup work lớn nhất |
| Selective hydration | Hoàn chỉnh | Framework ưu tiên subtree được chọn | Ít block hơn nhưng thường vẫn gửi runtime/code rộng |
| Progressive hydration | Hoàn chỉnh | Subtree kích hoạt theo thời gian hoặc trigger | Startup tốt hơn, phải xử lý interaction gap |
| Islands | Hoàn chỉnh quanh các app độc lập | Chỉ vùng tương tác đã khai báo | Ranh giới JavaScript mạnh, state liên island cần thiết kế |
| Resumability | Hoàn chỉnh kèm execution metadata | Tiếp tục handler/state mà không replay initial render | Ít reconstruction, nhiều server metadata và constraint framework hơn |
Các thuật ngữ này có phần giao nhau nhưng không đồng nghĩa. Selective hydration chọn server-rendered subtree nào hydrate trước. Progressive hydration chia activation theo priority hoặc condition. Islands định nghĩa các ranh giới client ownership độc lập và có thể dùng progressive trigger. Resumability serialize đủ execution context để tiếp tục mà không cần tái tạo component tree qua hydration pass thông thường. Streaming SSR là trục khác: nó thay đổi lúc HTML đến, không nhất thiết thay đổi lượng client code phải chạy.
Loader cho trigger visibility, interaction và media
Bootloader cần nhỏ, còn các import phải được bundler phát hiện tĩnh. Mỗi island module sở hữu hàm hydrate và chỉ nhận props tương thích JSON. Server có thể phát markup như sau:
<section
data-island="ProductGallery"
data-trigger="visible"
data-props-id="island-props-gallery-42"
>
<!-- gallery markup hoàn chỉnh từ SSR -->
</section>
<script id="island-props-gallery-42" type="application/json">
{"productId":"42","initialImage":0}
</script>
JSON script tránh executable inline code. Khi serialize, escape < thành \u003c để content không thể kết thúc script element; Content Security Policy vẫn phải kiểm soát script và connection.
Loader framework-neutral sau hỗ trợ visible, interaction và media:(query). Vue island có thể triển khai hydrate bằng createSSRApp(Component, props).mount(root). Island client-only dùng createApp nhưng không nên nhận SSR-owned markup.
type IslandModule = {
hydrate(root: HTMLElement, props: unknown): void | Promise<void>;
};
const modules = {
ProductGallery: () => import('./islands/ProductGallery.js'),
SearchBox: () => import('./islands/SearchBox.js'),
DesktopFilters: () => import('./islands/DesktopFilters.js'),
} satisfies Record<string, () => Promise<IslandModule>>;
type IslandName = keyof typeof modules;
const pending = new WeakMap<HTMLElement, Promise<void>>();
function readProps(root: HTMLElement): unknown {
const id = root.dataset.propsId;
if (!id) return {};
const node = document.getElementById(id);
if (!(node instanceof HTMLScriptElement)) {
throw new Error(`Missing island props: ${id}`);
}
return JSON.parse(node.textContent ?? '{}') as unknown;
}
function activate(root: HTMLElement): Promise<void> {
const existing = pending.get(root);
if (existing) return existing;
const name = root.dataset.island as IslandName | undefined;
const load = name ? modules[name] : undefined;
if (!load) return Promise.reject(new Error(`Unknown island: ${name ?? ''}`));
const task = load()
.then((module) => module.hydrate(root, readProps(root)))
.then(() => {
root.dataset.hydrated = 'true';
});
pending.set(root, task);
return task;
}
function onVisible(root: HTMLElement): void {
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
void activate(root);
},
{ rootMargin: '200px 0px' },
);
observer.observe(root);
}
function onInteraction(root: HTMLElement): void {
const warm = () => void activate(root);
root.addEventListener('pointerenter', warm, { once: true, passive: true });
root.addEventListener('focusin', warm, { once: true });
root.addEventListener('touchstart', warm, { once: true, passive: true });
}
function onMedia(root: HTMLElement, query: string): void {
const media = window.matchMedia(query);
const check = () => {
if (!media.matches) return;
media.removeEventListener('change', check);
void activate(root);
};
check();
if (!media.matches) media.addEventListener('change', check);
}
for (const root of document.querySelectorAll<HTMLElement>('[data-island]')) {
const trigger = root.dataset.trigger ?? 'visible';
if (trigger === 'interaction') onInteraction(root);
else if (trigger.startsWith('media:')) onMedia(root, trigger.slice(6));
else onVisible(root);
}
rootMargin tải trước khi island vào viewport; hover, focus hoặc touch cũng warm-up module. Đây không phải correctness guarantee: người dùng keyboard có thể nhấn Enter ngay sau khi tab tới control, còn mạng chậm vẫn kéo dài.
Loader production cần event policy. Giữ native link và form hoạt động trước hydration. Khi không thể degrade thành HTML, capture event cần thiết, queue mô tả tối thiểu, activate một lần rồi replay qua application command. Không giữ raw DOM event hoặc redispatch generic: target có thể mất, default action chạy hai lần và dữ liệu pointer đã cũ.
Tip
Ưu tiên platform behavior thay cho bộ máy replay. <a href>, <form action>, <details> hoặc checkbox do server render vẫn hữu ích trong lúc activation và khi script lỗi.
State, scheduling và giao tiếp giữa các island
Serialized props là public boundary. Chỉ đưa vào state cần để tái tạo server view: stable ID, selected value, chuỗi locale đã render và version chống cache lệch. Loại secret, authorization decision, field thừa, class, function và cyclic graph. Validate JSON tại island boundary vì TypeScript không làm việc đó ở runtime.
Không serialize cùng object lớn vào nhiều island; dùng một payload tham chiếu bằng ID hoặc read-only cache. Mutable state cần owner rõ ràng. Cart service có thể publish cart:item-added; island không truy cập component instance hay DOM internal của island khác.
Sau trigger, interaction và above-the-fold control cần ưu tiên; below-the-fold enhancement có thể đợi. Có thể dùng scheduler.postTask với fallback, nhưng requestIdleCallback không bảo đảm deadline. Giới hạn import và hydration đồng thời để nhiều island không tạo CPU burst.
Thứ tự ưu tiên thực tế là:
- Giữ native navigation, form submission và focus behavior.
- Kích hoạt island đang tham gia input hiện tại.
- Kích hoạt visible control nhiều khả năng nhận input tiếp theo.
- Prefetch module gần viewport nếu network condition cho phép.
- Hydrate enhancement trang trí hoặc analytics sau cùng, thậm chí không hydrate.
Media trigger cần lifecycle rõ ràng. Nếu viewport thu nhỏ sau khi desktop filters hydrate, destroy island có thể mất state và focus. Thường nên activate một lần rồi dùng responsive CSS; nếu deactivate, module cần contract dispose.
Accessibility, kiểm thử và metrics
SSR state chính là trạng thái no-JavaScript và pre-hydration. DOM cần landmark, label, button type, link destination, form action và reading order đúng. Đừng chờ hydration bổ sung keyboard semantics cho <div>. Với disclosure, aria-expanded, panel và focus order phải đồng bộ từ server.
Hydration không được thay focused node, reset input hoặc tự chuyển focus. Stable key và markup khớp giúp tái sử dụng element. Thông báo kết quả async quan trọng qua live region, không announce bản thân hydration.
Kiểm thử ở bốn lớp:
| Lớp | Assertion |
|---|---|
| Server HTML | Content và native action chạy khi tắt JavaScript |
| Hydration contract | Không warning mismatch với fixture, locale và timezone cố định |
| Trigger behavior | Module chưa tải trước visibility, interaction hoặc media condition |
| End-to-end | Early click, keyboard input, mạng chậm, resize và script failure vẫn dùng được |
Unit test có thể stub IntersectionObserver và matchMedia. Browser test nên throttle CPU/network, tương tác trước khi activation xong và fail khi có hydration warning. Kiểm tra từng island là chunk riêng và static page không import full app.
Đo JavaScript transfer/execution, blocking time, long task, INP, LCP, memory, hydration duration, trigger-to-interactive latency và activation count. Tỷ lệ island không bao giờ activate cho biết bao nhiêu code thực sự được tránh. So sánh field data theo device và route.
Khi SPA là kiến trúc tốt hơn
Islands hợp với route lấy nội dung làm trung tâm nhưng có vài vùng tương tác: documentation, trang sản phẩm, tin tức và marketing. Chúng kém phù hợp khi mọi vùng chia sẻ state liên tục, navigation chủ yếu trong app, cần offline hoặc session thao tác dài.
Design tool, mail client, collaborative editor hay operations console thường đơn giản hơn dưới dạng SPA. Chia chúng thành nhiều island dễ sinh duplicated store, event bus và coordination code. SSR vẫn có thể cấp initial shell, nhưng một client app mới là ownership boundary trung thực.
Hãy chọn dựa trên interaction density và product behavior đã đo:
| Ưu tiên islands/progressive hydration khi | Ưu tiên SPA khi |
|---|---|
| Phần lớn route là HTML để đọc | Phần lớn vùng trên màn hình tương tác liên tục |
| Island có dependency giới hạn, tường minh | Feature dùng chung một mutable state graph phong phú |
| Search và first-load performance chi phối | Session đăng nhập dài chi phối |
| Native link/form cung cấp fallback hữu ích | Offline transition và optimistic update là cốt lõi |
| Nhiều visitor không dùng widget tùy chọn | Gần như mọi user tải gần như mọi feature |
Kiến trúc phải loại bỏ công việc, không chỉ đổi tên nó. Nếu mọi island activate ngay và import chung một mega-bundle, hệ thống đã dựng lại full hydration kèm thêm plumbing.
Những điều cần nhớ
- Hydration tái sử dụng HTML nhưng vẫn trả chi phí network, parse, execution, memory và scheduling.
- Output server và client phải khớp; serialize unstable input một lần và validate lúc bàn giao.
- Islands định nghĩa ownership boundary, còn progressive hydration quyết định lúc mỗi boundary activate.
- Trigger visibility, interaction và media cần loader nhỏ, static import map và fallback behavior có chủ đích.
- Event replay là công cụ correctness ở đoạn cuối; native HTML behavior thường an toàn hơn.
- Đo trigger-to-interactive latency, executed JavaScript, INP và số island không bao giờ activate.
- Dùng SPA khi sản phẩm thực sự là một client application dày đặc tương tác và state.