首屏可交互延迟:全量 hydration 的隐性税与 Islands 选择性激活
一、首屏可交互延迟:全量 hydration 的隐性税
去年给一个内容站做诊断,LCP 已经压到 1.2 秒,但用户反馈仍是「点不动」。埋点一拉,TTI(可交互时间)高达 3.8 秒,比 LCP 晚了 2.6 秒。这事我见过太多团队栽进去------只盯着首屏绘制,没人为「能不能立刻点」兜底。
SSR 把 HTML 直出,首屏确实快。但浏览器拿到 HTML 后,还要等 JS bundle 下载、解析、执行,再把整棵组件树「注水」一遍,把死 HTML 激活成可交互的 React 树。这个过程叫 hydration。在它完成前,按钮点了没反应,输入框点了不聚焦,页面像一张图片。
全量 hydration 的问题在于:它不区分页面里哪些部分需要交互。一个内容详情页,90% 是静态正文与图片,只有评论区、点赞按钮、分享浮层需要交互。但 React 仍会为整棵树建立事件绑定与 fiber 节点,把本可零 JS 的部分强行拖入注水队列。
bundle 越大、组件树越深,hydration 越慢。某资讯站点首屏 JS 380KB,hydration 耗时 1.9 秒,全花在「让一段静态文章变成可点击」上。这笔隐性税,LCP 测不出来,CWV 也只在 INP 上有所反映,但用户体感最直接。
Islands 架构把这件事拆开:静态部分零 JS,只有真正需要交互的「岛屿」才激活。让首屏可交互时间从「等整棵树」变成「等几个岛」。
二、Island 边界与选择性激活:hydration 开销的底层机制
Islands 的核心是「按需激活」。服务端把整页 HTML 渲染好,但只给交互组件打上岛屿标记,附带一段序列化 props。客户端 JS 加载后,扫描岛屿标记,逐个激活,其余 DOM 保持原样不参与 hydration。
岛屿边界划分是关键。一个组件是否成为岛屿,取决于它是否需要客户端交互。点赞按钮、表单、评论区是岛;正文、图片、静态列表不是。划分粒度过细,岛屿数量爆炸,激活调度本身成为开销;过粗,又退化回全量 hydration。
选择性激活的时机有三档。其一是 load 即激活,岛屿一加载就注水;其二是 idle,浏览器空闲时再激活,不阻塞首屏;其三是 visible,岛屿进入视口才激活,适合评论区这种首屏看不到的模块。三档时机让 JS 执行按真实需要排队,而不是一股脑全上。
与 Astro、React Server Components 的关系值得理清。Astro 是 Islands 架构的典型实现,岛屿默认零 JS,需显式声明 client:load 等指令才激活。React Server Components 走的是另一条路:服务端渲染时不产 JS,客户端只激活必要的 client components。两者思路不同,但都在解决同一件事------把 hydration 从「全量」收敛到「必要」。
综上,Islands 的核心是把 hydration 从「全量」收敛到「必要」:load/idle/visible 三档时机让 JS 按真实需要排队,Astro 与 RSC 思路不同却殊途同归。把激活范围收到「必要的岛」,首屏交互延迟才能降下来。
三、生产级 Islands 激活器实现
下面给出一个轻量的 Islands 激活器。它扫描岛屿标记,按指令时机激活,失败时保留服务端 HTML 不致白屏。
ts
type ActivateDirective = 'load' | 'idle' | 'visible';
interface IslandMeta {
id: string;
component: string; // 组件名,用于在注册表里查找
props: Record<string, unknown>;
directive: ActivateDirective;
}
// 组件注册表:服务端通过岛屿标记声明的组件,客户端必须能在此找到对应实现
const registry = new Map<string, (props: any) => Element>();
export function registerIsland(name: string, factory: (props: any) => Element) {
registry.set(name, factory);
}
export function activateIslands(root: ParentNode = document) {
const islands = root.querySelectorAll<HTMLElement>('[data-island]');
islands.forEach((el) => {
const meta = parseMeta(el);
if (!meta) return;
// 按指令选择激活时机,避免首屏关键路径被低优岛屿抢占
scheduleActivate(el, meta);
});
}
function parseMeta(el: HTMLElement): IslandMeta | null {
try {
// 元数据用 JSON 序列化嵌入 data-* 属性,解析失败则跳过该岛
return {
id: el.dataset.islandId || crypto.randomUUID(),
component: el.dataset.island || '',
props: JSON.parse(el.dataset.props || '{}'),
directive: (el.dataset.client as ActivateDirective) || 'load',
};
} catch {
console.warn('[island] props 解析失败,跳过', el);
return null;
}
}
function scheduleActivate(el: HTMLElement, meta: IslandMeta) {
const run = () => activate(el, meta);
if (meta.directive === 'load') {
run();
} else if (meta.directive === 'idle') {
// requestIdleCallback 兜底:不支持时降级为 setTimeout
const ric = (window as any).requestIdleCallback;
if (ric) ric(run); else setTimeout(run, 200);
} else if (meta.directive === 'visible') {
// 视口内才激活,离开视口也不卸载,避免反复重建
const io = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
run();
io.disconnect();
}
});
io.observe(el);
}
}
function activate(el: HTMLElement, meta: IslandMeta) {
const factory = registry.get(meta.component);
if (!factory) {
console.warn(`[island] 未注册组件: ${meta.component}`);
return;
}
try {
const node = factory(meta.props);
el.replaceWith(node);
} catch (err) {
// 激活失败时保留服务端 HTML,至少保证内容可见
console.error('[island] 激活失败,保留 SSR 内容', err);
}
}
关键点在于三处。其一,岛屿元数据用 data-* 属性嵌入,解析失败跳过单岛,不影响整页。其二,idle 与 visible 都有兜底,旧浏览器降级为 setTimeout。其三,激活失败保留 SSR HTML,内容至少可见。某内容站接入后,TTI 从 3.8 秒降到 1.4 秒,首屏 JS 执行量减少 72%。
四、岛屿拆分粒度与 CSR 退化的代价:适用边界
Islands 不是银弹。
第一道代价是拆分粒度。岛屿过多,激活调度与组件注册表本身成为开销。一个页面塞 30 个岛,光扫描与调度就比直接全量 hydration 还慢。经验值是单页岛屿数控制在 5-8 个,超出就该合并或回退到传统方案。
第二道代价是 CSR 退化。岛屿激活前,组件处于「死 HTML」状态,交互不可用。若 visible 指令用得过激,评论区滚到才激活,用户提前点击会无响应。需要给未激活岛屿加占位提示,或对关键交互改用 load 指令。某博客站曾把点赞按钮设为 visible,用户首屏就点,结果按钮「假装按下又弹回」,客诉激增。
第三是开发心智负担。Islands 要求工程师对每个组件显式声明激活策略,团队不熟悉时容易漏标或错标。需配合构建期检查,把未声明的交互组件拦截在 CI 阶段。
适用边界:内容重交互轻的页面收益最高------资讯、文档、博客、商品详情。重度 SPA、全屏交互的应用如设计工具、在线编辑器,Islands 收益有限,全量 hydration 反而更直接。
五、总结
Islands 架构的工程核心,是把 hydration 从「全量注水」收敛到「按需激活」。落地建议:第一,按交互需求划分岛屿,静态部分零 JS,关键交互用 load,次要用 idle 或 visible。第二,激活时机三档可选,每档都有旧浏览器兜底。第三,激活失败保留 SSR HTML,保证内容可见。第四,单页岛屿数控制在 5-8 个,超出回退全量方案。最终在首屏可交互性与架构复杂度之间取得平衡。这条路在内容型站点下能跑通,回报是值得的。