无需 JavaScript 即可更新页面
使用 template for 和处理指令,无需 JavaScript 即可流式更新 HTML。了解 Node 示例、浏览器支持、回退行为及其限制。
<template for="name"> 元素会填充同一文档中较早位置、由同名处理指令(processing instruction)标记的占位符。服务器在同一个 HTML 响应的末尾发送加载较慢的部分,浏览器的解析器会将它们移动到对应位置。整个过程不运行任何脚本。
大多数服务端渲染页面都有一个加载较慢的区域,例如订单数量或状态检查。常见做法是先放一个 loading div,再用脚本在页面加载后获取该片段。很多团队都希望去掉的正是这段脚本。
本文将介绍相关标记写法、一个可运行的流式推送更新的 Node 服务器、该模式与 htmx 的定位对比,以及不支持该特性的浏览器会渲染出什么。
核心要点
- 当 HTML 解析器解析到
<template for>时,它会替换<?start name="x">…<?end>范围内的内容,或填充<?marker name="x">插入点。 - 服务器必须先刷新(flush)页面骨架,之后再在同一响应中写入模板。任何缓冲整个响应的中间层都会导致占位符和替换内容同时送达。
- Chrome 150 默认启用乱序流式传输(out-of-order streaming)。配套的 JavaScript 方法(如
streamHTML)将在 Chrome 155 中推出。更早的版本需要开启 flag。 - 不支持该特性的浏览器会将处理指令解析为注释,因此回退内容会保留在页面上。
问题所在:获取、查找、替换
在此之前,更新服务端渲染页面中的某个区域需要三个客户端步骤:获取片段、查找目标节点、替换其内容。即使是下面这样精简的版本,也需要一段脚本、一次额外请求和一次 DOM 查找:
<div id="status">Loading…</div>
<script type="module">
const res = await fetch('/status.html');
document.querySelector('#status').innerHTML = await res.text();
</script>
服务器在渲染第一个响应时其实已经知道状态了。之所以多出这一次往返,只是因为 HTML 一直以来都是按文档顺序解析的。
声明式局部更新在标记中如何工作
声明式局部更新(declarative partial updates)使用处理指令作为命名锚点,使用 <template for> 作为补丁。Chrome 关于声明式局部更新的公告定义了三种锚点:<?marker name="x"> 表示插入点;<?start name="x"> 和 <?end> 包裹一段将被替换的范围。
如果占位符需要显示回退内容,请使用 <?start>…<?end> 配对。当匹配的模板到达时,两条指令之间的所有内容都会被替换。单独的 <?marker> 只标记一个位置,因此放在它旁边的回退文本永远不会被移除。
<div id="status"><?start name="status">Loading…<?end></div>
<!-- later in the same response -->
<template for="status">All systems <strong>normal</strong></template>
模板解析完成后,DOM 变为:
<div id="status">All systems <strong>normal</strong></div>
Chrome 的文档解释了为什么选择处理指令而非元素:通过处理指令打补丁不会留下多余的节点,这意味着它可以用在插入新元素会造成破坏的地方,例如 <head>(比如修改 <title>)或 <table> 内部。
服务器发送什么?
服务器立即写出完整的页面骨架,在等待数据期间保持响应打开,然后在关闭文档之前逐一写出每个 <template for>。下面的示例仅使用 node:http 和 ES 模块。将其保存为 server.mjs 并运行 node server.mjs:
// server.mjs — run with: node server.mjs, then open http://localhost:3000
import { createServer } from 'node:http';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
async function getStatus() { await sleep(1000); return 'All systems normal'; }
async function* getOrders() {
for (const o of ['Order #1001', 'Order #1002', 'Order #1003']) {
await sleep(700);
yield o;
}
}
createServer(async (req, res) => {
if (req.url !== '/') { res.writeHead(404).end(); return; }
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
// 1. Flush the shell immediately.
res.write(`<!doctype html>
<html><head><meta charset="utf-8"><title>Orders</title></head>
<body>
<h1>Your orders</h1>
<p id="status"><?start name="status">Checking status…<?end></p>
<ul id="orders"><?start name="orders"><li>Loading orders…</li><?end></ul>
`);
// 2. Templates are direct children of <body>, so they can reach the placeholders above.
const status = await getStatus();
res.write(`<template for="status">${esc(status)}</template>\n`);
// 3. Each template appends one item and leaves a fresh marker for the next.
for await (const order of getOrders()) {
res.write(`<template for="orders"><li>${esc(order)}</li><?marker name="orders"></template>\n`);
}
// 4. Close the document only after the last template.
res.end('</body></html>');
}).listen(3000);
每次调用 response.write() 都会发送响应体的一个分块,无需等待 end()。关于如何消费分块输出的背景知识,请参阅 Web Streams 详解一文。代码中的 sleep 调用用于模拟慢查询,并非性能基准测试。
之所以需要 esc() 辅助函数,是因为模板内容就是普通的 HTML。任何插值数据如果不经转义,都可能注入恶意标记。
流结束时,#orders 中会包含三个 <li> 元素,后面跟着一个尾随的 <?marker name="orders">。在 Chrome 150 或更高版本中打开该页面,即可看到列表逐步填充。
缓冲会让乱序流式传输失效
只有当字节随时间陆续到达浏览器时,乱序流式传输才能发挥作用。有三类常见的中间层会扣住整个响应:等待完整缓冲区的压缩中间件、开启了响应缓冲的反向代理或 CDN,以及在发送任何内容之前先渲染完整页面的框架。可以在每一层上运行 curl -N http://localhost:3000,检查分块是否是分开到达的。-N 参数会禁用 curl 的输出缓冲。会话回放(session replay)可以从用户视角呈现实际效果,例如由于代理扣住了数据流,页面一直白屏,直到整个页面一次性加载出来。
适用场景及其局限
声明式补丁只在服务器仍在写入文档时有效,因此响应的生命周期就是更新窗口。
- 实时列表。 在每个模板末尾放置一个同名的新
<?marker>,后续模板就能继续在该位置追加内容。列表只在连接保持打开期间保持实时更新。 - 状态栏。 使用单个
<?start>/<?end>范围配合一个模板,如上例所示。 - 表单结果。 一次普通的
POST导航返回一个页面,其骨架立即渲染,处理完成后再将结果流式推送进来。
任何在页面加载后响应点击操作的功能(筛选、行内编辑、乐观 UI)仍然需要客户端状态、JavaScript 或第三方库。
声明式局部更新 vs htmx
htmx 会响应用户事件发送新的 HTTP 请求,并将返回的片段替换到页面中。<template for> 只在正在流式传输的响应内部生效。一旦服务器结束该响应,后续更新就需要 JavaScript 或一次新的导航。
htmx 仍然是一个 JavaScript 库。htmx 4.0 新增了 <hx-partial> 标签,可以用一个响应更新多个目标元素。诸如使用 htmx 实现无限滚动(加载元素滚动进入视口时,htmx 会从服务器获取下一页)和使用 htmx 进行表单校验等模式,都依赖于用户触发的请求。
| htmx | <template for> | |
|---|---|---|
| 触发方式 | 用户事件 | 服务器写入顺序 |
| 请求数 | 每次交互一个 | 一个响应 |
| 运行环境 | JavaScript 库 | HTML 解析器 |
| 目标定位 | CSS 选择器属性 | 命名处理指令 |
| 响应结束后能否更新 | 能 | 不能 |
仍然需要 JavaScript 的情况
针对页面加载后才到达的内容,Chrome 正在新增 streamHTML、appendHTML 等配套方法及其 Unsafe 变体,并扩展现有的 setHTML。自 Chrome 148 起,开发者可以通过开启 chrome://flags/#enable-experimental-web-platform-features 来测试这些方法;它们已包含在 Chrome 155 中,该版本于 2026 年 9 月 16 日进入 Beta 阶段。
const el = document.querySelector('#feed');
const response = await fetch('/feed-fragment.html');
await response.textStream().pipeTo(el.streamHTMLUnsafe());
response.textStream() 从 Chrome 151 开始可用。在更早的版本中,请改用 response.body.pipeThrough(new TextDecoderStream())。
当你通过 streamHTMLUnsafe 管道传入标记时,其中的任何 <template for> 都会为当前页面打补丁。setHTML 和 innerHTML 的工作方式则不同:它们会先解析到一个独立的片段中,因此其中的模板只能修补该片段内的节点,而无法修补现有页面。
浏览器支持、回退与限制
基于 <template for> 以及 <?marker>、<?start>、<?end> 处理指令的乱序流式传输,从 Chrome 150 起默认启用,该版本于 2026 年 6 月 30 日进入稳定版。该特性在 ChromeStatus 上有跟踪记录,for 属性已纳入 WHATWG HTML Living Standard。目前尚无非 Chromium 浏览器发布该特性。在 WebKit standards-positions 讨论帖中,一位 WebKit 工程师提议将 WebKit 的立场定为“support”(支持)。
Intent to Ship 确认,不支持该特性的浏览器会将处理指令解析为注释,因此任何回退内容都会保留在页面上。由于标准 <template> 元素的内容不会被渲染,流式推送的数据在这些浏览器中永远不会显示出来。
| Chrome 150+ | 不支持 | 使用 template-for-polyfill | |
|---|---|---|---|
| 回退文本 | 被替换 | 保持可见 | 被替换 |
| 流式内容 | 打补丁插入 | 隐藏在 <template> 中 | 打补丁插入,但受解析器限制 |
Chrome 在 npm 上发布了 template-for-polyfill。它能处理常见情况,但由于无法介入浏览器自身的解析器,仍存在一些限制。而且它本身也是 JavaScript,因此一个需要在所有浏览器中工作的“无 JS”页面仍然要依赖脚本。另一个 html-setters-polyfill 复刻了新 API 的方法名,但它会先收集全部内容再在最后一次性插入,因此并不能实现流式传输。
需要了解的 <template for> 限制:
- 模板只能作用于与其拥有相同父元素的处理指令。将模板直接放在
<body>下,即可作用于整个文档,包括<head>。 - 可以省略
<?end>。省略时,替换范围从<?start>一直延伸到父元素的结束位置。 - 如果在模板仍在向某个处理指令流式写入时移动了该处理指令,剩余内容可能仍会落到原来的位置。
总结
乱序流式传输将“获取、查找、替换”这套流程交给了 HTML 解析器,但仅限于单个尚未关闭的响应内部。无论在何处使用它,都应将真实数据放在 <?start>/<?end> 回退范围中,确保不支持该特性的浏览器仍能显示合理的内容。在生产环境中依赖它之前,请将 server.mjs 部署在你实际使用的代理和 CDN 之后,检查分块是否分开到达。
常见问题
template for 与 React Suspense 流式渲染有何不同?
React 流式 SSR 会将每个已解析的 Suspense 边界作为隐藏的 div 发送,随后跟一段内联脚本将其替换到回退内容的位置。如果脚本被禁用,或者内容安全策略(CSP)拦截了没有 nonce 的内联脚本,这些内容就永远无法移动到位。而使用 template for 时,替换由 HTML 解析器自身完成,不运行任何脚本,页面也无需为内联脚本放行。
是否有跨浏览器、无需 JavaScript 的乱序流式 HTML 方案?
有。使用带命名 slot 的声明式 Shadow DOM(Declarative Shadow DOM),可在 Chrome、Firefox 和 Safari 中运行。页面骨架是一个带有 shadowrootmode='open' 的 template,其中包含承载回退内容的 slot 元素。之后流式到达的、带有匹配 slot 属性的元素会填充这些 slot。代价是 shadow root 通常挂载在 body 上,因此文档级 CSS 无法为 shadow 树内部的骨架标记设置样式。
如果在页面骨架发送之后慢查询失败了怎么办?
一旦写出第一个分块,HTTP 状态码和响应头就已固定,服务器无法再切换为 500 错误、重定向或设置新的 Cookie。此时应在标记中处理失败:写入一个同名模板,其中包含错误信息,它会替换掉加载中的回退内容。在 Node 中,响应头发送后再调用 res.setHeader 会抛出 ERR_HTTP_HEADERS_SENT 错误。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k