浏览器原生乱序 HTML 流式渲染:页面不必再等待最慢的数据

2026-09-22 38 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

乱序 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,可以逐渐成为浏览器提供的基础设施,而不是每个框架都维护一套私有传输协议。

工程上还应区分三个指标:

  1. 响应是否已经开始:服务器能否尽早发送页面外壳。
  2. 内容是否已经可见:关键区域是否被代理、压缩层或缓冲器延迟。
  3. 区域是否可交互:相关事件处理、表单状态和客户端代码是否已经就绪。

只有第一项变快,并不等于用户体验一定变好。

可运行实验:让后完成的区域先显示

下面的 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 补丁作为降级路径。

适合优先采用的页面通常具有多个相互独立、耗时差异明显的服务端数据源,例如控制台、商品详情、搜索结果和个性化首页。若页面只有一个主要数据请求,或者所有区域必须同时保持一致,乱序流式传输带来的复杂度可能大于收益。

更稳妥的落地顺序是:先确认代理链路确实转发响应块,再划分不会互相覆盖的更新边界,随后加入超时和错误占位内容,最后才切换到浏览器原生补丁机制。这样优化的就不只是服务器发送速度,而是用户真正看到并能够操作页面的时间。


相关推荐