乱序 HTML 流式传输正在从前端框架的专属能力变成浏览器能力。页面可以先发送布局、导航和可交互区域,再把较晚返回的数据填入预留位置;即使数据完成顺序与文档顺序不同,浏览器也能在片段到达后完成补丁更新。该能力在 Chrome 151 中提供,并同时面向声明式 HTML 和对应的 JavaScript API。
它改变的不只是加载动画
传统服务端渲染经常被最慢的数据源拖住。假设页面依次包含用户资料、推荐商品和订单列表,而三个后端请求分别耗时 1.8 秒、0.6 秒和 1.1 秒。如果服务端必须按照最终 DOM 顺序生成完整 HTML,那么已经完成的推荐结果也可能被用户资料请求阻塞。
乱序流式传输把这个过程拆成两类内容:
- 页面骨架与占位符:尽快发送,浏览器可以立即解析、绘制并建立交互。
- 后续 HTML 片段:哪个数据先准备好,就先发送对应片段,由浏览器将其补到指定占位符。
因此,它优化的并不只是首字节时间。用户可能更早看到稳定布局,更早使用已经就绪的区域,而不必等待整页最慢的依赖。
这和简单地在响应末尾追加 HTML 不同。后到的片段需要明确指向先前的占位位置,浏览器或运行时负责执行补丁,而不是要求服务器严格按照 DOM 顺序输出。
从框架运行时下沉到浏览器
React、Vue、Svelte 等生态已经通过服务端渲染、Suspense、异步组件或框架自有协议实现过相似体验。浏览器原生支持的意义在于,补丁协议不必始终绑定到某个大型客户端运行时。
新的能力包含两条使用路径:
- 使用声明式 HTML 描述“这个后到片段属于哪个占位符”;
- 使用配套 JavaScript API,在需要自定义调度、错误处理或状态协调时执行更新。
这并不意味着框架会失去价值。框架仍然负责路由、缓存、组件边界、数据依赖和错误恢复。真正发生变化的是:乱序片段如何进入 DOM,可以逐渐成为浏览器提供的基础设施,而不是每个框架都维护一套私有传输协议。
工程上还应区分三个指标:
- 响应是否已经开始:服务器能否尽早发送页面外壳。
- 内容是否已经可见:关键区域是否被代理、压缩层或缓冲器延迟。
- 区域是否可交互:相关事件处理、表单状态和客户端代码是否已经就绪。
只有第一项变快,并不等于用户体验一定变好。
可运行实验:让后完成的区域先显示
下面的 Node.js 示例不假设尚可能调整的原生声明式语法,而是用一小段 JavaScript 模拟同样的“占位符加补丁”协议。它可以直接运行,也适合作为迁移到浏览器原生 API 前的服务端实验。
保存为 server.js:
const http = require("node:http");
const server = http.createServer((req, res) => {
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
"x-content-type-options": "nosniff"
});
res.flushHeaders();
res.write(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>乱序 HTML 流式实验</title>
<style>
body { max-width: 720px; margin: 40px auto; font: 16px/1.6 system-ui; }
section { min-height: 90px; margin: 16px 0; padding: 16px; border: 1px solid #ddd; }
[aria-busy="true"] { color: #666; background: #f6f6f6; }
</style>
<script>
window.applyPatch = function (targetId, templateId) {
const target = document.getElementById(targetId);
const template = document.getElementById(templateId);
if (!target || !template) return;
target.replaceChildren(template.content.cloneNode(true));
target.setAttribute("aria-busy", "false");
template.remove();
};
</script>
</head>
<body>
<h1>控制台首页</h1>
<section id="profile" aria-busy="true">正在加载用户资料……</section>
<section id="recommendations" aria-busy="true">正在加载推荐内容……</section>
`);
// 推荐内容先完成,尽管它在文档中的位置更靠后。
setTimeout(() => {
res.write(`
<template id="recommendations-patch">
<h2>推荐内容</h2>
<p>这个区域在 800ms 后到达,并立即替换占位符。</p>
</template>
<script>applyPatch("recommendations", "recommendations-patch")</script>
`);
}, 800);
// 用户资料更晚完成,不会阻塞推荐区域。
setTimeout(() => {
res.write(`
<template id="profile-patch">
<h2>用户资料</h2>
<p>这个区域在 1800ms 后到达。</p>
</template>
<script>applyPatch("profile", "profile-patch")</script>
</body>
</html>`);
res.end();
}, 1800);
});
server.listen(3000, () => {
console.log("Open http://localhost:3000");
});
运行:
node server.js
浏览器打开 http://localhost:3000,可以看到推荐区域先更新,用户资料稍后更新。也可以直接观察响应块到达的时间:
curl -N http://localhost:3000
这里的内联脚本只是兼容性演示,不代表 Chrome 151 原生声明式 API 的最终写法。接入原生能力时,可以保留服务器的“占位符 ID + HTML 片段”设计,只替换客户端补丁层,并根据实际浏览器文档确认语法。
上线前要检查的边界
流式补丁会让响应生命周期变长,也会暴露一些普通 SSR 不容易遇到的问题:
- 代理缓冲:CDN、反向代理和压缩中间件可能攒够一批字节后才转发,导致服务端虽然调用了
write(),用户仍看不到增量结果。 - 内容安全策略:示例使用内联脚本。生产环境应使用 nonce、哈希或原生声明式机制,不要为了流式渲染放宽 CSP。
- HTML 注入:来自数据库或模型输出的内容必须经过转义或可信清洗,不能直接拼入流式片段。
- 占位符稳定性:目标 ID 必须唯一,并且要定义重复片段、缺失目标和连接中断时的行为。
- 用户状态保护:补丁不应覆盖用户已经输入的表单值、当前焦点或已发生的交互。
- 布局偏移:占位符应预留合理尺寸,否则更快显示内容反而可能带来明显的页面跳动。
- 浏览器兼容性:Chrome 151 的能力不能自动代表所有浏览器。上线时应保留完整 SSR、顺序流式输出或轻量 JavaScript 补丁作为降级路径。
适合优先采用的页面通常具有多个相互独立、耗时差异明显的服务端数据源,例如控制台、商品详情、搜索结果和个性化首页。若页面只有一个主要数据请求,或者所有区域必须同时保持一致,乱序流式传输带来的复杂度可能大于收益。
更稳妥的落地顺序是:先确认代理链路确实转发响应块,再划分不会互相覆盖的更新边界,随后加入超时和错误占位内容,最后才切换到浏览器原生补丁机制。这样优化的就不只是服务器发送速度,而是用户真正看到并能够操作页面的时间。