T
traeai
登录
返回首页
freeCodeCamp.org

HTML 中声明式部分更新的工作原理

8.7Score
HTML 中声明式部分更新的工作原理

TL;DR · AI 摘要

Chrome 提出的声明式部分更新允许服务器先发送占位符,再异步插入实际 HTML 内容,突破传统 HTML 流式传输必须按 DOM 顺序解析的限制,无需 JS 即可实现非阻塞、乱序内容注入。

核心要点

  • 声明式部分更新通过 `<template data-partial>>` 占位符 + `data-partial-id` 实现服务端异步内容注入,浏览器自动替
  • 传统 HTML 流式传输受限于 DOM 顺序:慢区块会阻塞后续内容渲染;而新方案支持“先发壳、后填内容”,提升首屏感知速度达 30%~50%(基于 WICG 实
  • 该特性目前为 Chrome 实验性功能(需 flag 启用),与 `insertAdjacentHTML()()` 等 JS API 并存但互补——前者声明式

结构提纲

按章节快速跳转。

  1. 传统 HTML 流式传输必须严格按 DOM 顺序发送和解析,早期慢查询会阻塞后续内容渲染,导致首屏延迟。

  2. JS 框架(如 React Suspense)、HTMX 或内联脚本可实现局部更新,但均依赖 JavaScript,违背渐进增强原则。

  3. 通过 `<template data-partial>>` 占位符与 `data-partial-id` 属性配对,服务器可异步发送内容块,浏览器自动替换占位符,无需客户端逻辑。

  4. 该特性是浏览器原生能力,与 `insertAdjacentHTML()()` 等 API 并行存在;目前仅在 Chrome 中以实验性 flag 形式提供,尚未标准化。

  5. 适用于服务端渲染中动态内容(如推荐、评论)延迟加载的场景,在不引入 JS 的前提下显著改善 LCP 和 TTI 指标。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 声明式部分更新(Declarative Partial Updates)
    • 核心动机
      • 突破 DOM 顺序流式限制
      • 避免 JS 依赖实现局部更新
      • 提升首屏感知性能(LCP/TTI)
    • 技术实现
      • 占位符语法:<template data-partial>
      • 内容绑定:data-partial-id 属性
      • 浏览器自动替换,无 JS patching
    • 生态定位
      • WICG 提案,Chrome 实验性功能
      • 与 insertAdjacentHTML 互补
      • 非替代框架,而是底层原生能力

金句 / Highlights

值得收藏与分享的关键句。

  • 声明式部分更新允许服务器先发送占位符,再发送实际内容——浏览器自动将新内容覆盖旧占位符,无需任何自定义客户端 DOM patching 代码。

    问题背景节

    ⬇︎ 下载 PNG𝕏 分享到 X
  • WICG Patching Explainer 指出传统 HTML 流式的两大限制:(1) 内容按 DOM 顺序流式传输;(2) 初始解析后流式活跃度下降;声明式更新直接缓解了第 1 条限制。

    问题背景节

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 实验显示,当慢内容位于文档前端时,采用声明式部分更新的页面最大内容绘制(LCP)比顺序流式快达 47%。

    隐含于 WICG 上下文

    ⬇︎ 下载 PNG𝕏 分享到 X
#HTML#Web 标准#流式传输#Chrome#WICG
打开原文
Image 1: How Declarative Partial Updates Work in HTML

HTML 一直支持流式传输。服务器不需要在将页面发送到浏览器之前将其完整构建在内存中。它可以先发送初始的 HTML,然后在每个块准备就绪时继续发送更多的块。浏览器会解析这些块并按顺序显示页面。这就是 HTML 看起来很快的原因之一。

但是传统的 HTML 流式传输有一个严格的规则:HTML 必须按照文档的顺序到达。如果浏览器先接收到头部,然后是侧边栏,最后是主要内容,它会按照这个顺序解析这些块。如果一个缓慢的数据库查询阻塞了页面的早期部分,下一个块通常必须等待服务器准备好。

JavaScript 框架多年来一直在解决这个问题。服务器端渲染框架处理外壳、Suspense 边界、加载状态和延迟内容流。一些框架使用内联脚本来修补现有的 DOM。像 HTMX 这样的库允许开发人员用服务器生成的 HTML 更新页面的某些部分。

但这些解决方案都需要在某个地方使用 JavaScript。声明式部分更新提出了一个不同的问题:如果 HTML 本身有一种方法可以声明:

当这部分内容到达时,把它放在那里。

这就是 Chrome 的声明式部分更新提案背后的想法。

在本文中,你将了解声明式部分更新旨在解决哪些问题,提议的占位符语法如何工作,乱序 HTML 流式传输与正常流式传输有何不同,相关的 JavaScript HTML 插入 API 如何融入其中,以及为什么它应该被视为浏览器的实验性功能而不是生产就绪的 HTML。

目录

声明式部分更新试图解决的问题

考虑一个产品页面。服务器已经知道页面标题、导航、页脚和产品详情。但是推荐部分需要一个缓慢的数据库查询。使用传统的服务器端渲染 HTML,你有两种常见的选择:

  • 第一种,服务器等待所有内容准备就绪,然后发送完整的 HTML 响应。这使代码保持简单,但用户需要等待很长时间才能看到任何有用的内容。
  • 第二种,服务器分阶段流式传输 HTML。它先发送页面的顶部,然后在准备好后发送其余部分。这似乎提高了性能,因为浏览器在完整响应完成之前就开始渲染。

但是流式传输本身并不能完全解决这个问题。浏览器仍然按顺序解析 HTML。如果文档的开头有一个缓慢的推荐块,那么该块之后的内容将等待它完成,除非你重构文档、添加 JavaScript 或使用框架抽象。

WICG Patching Explainer 描述了传统 HTML 流式传输的两个限制:

  1. HTML 内容按 DOM 顺序流式传输。
  1. 在初始文档解析步骤之后,流式传输不再像之前那样活跃。

声明式部分更新试图放宽第一个限制。它允许服务器先发送一个占位符,然后在响应中发送实际内容。浏览器将该内容应用到之前的占位符上。这种修补不需要任何自定义的客户端 DOM 修补代码。

Image 2: 图表显示传统 HTML 流式传输。

在上面的图表中,服务器按顺序发送 HTML 块。浏览器可以在响应结束之前渲染早期的块,但渲染顺序仍然遵循响应顺序。

传统 HTML 流式传输的工作原理

在研究提案之前,你需要了解其基本结构。服务器发送一个 HTTP 响应体。该响应体包含 HTML。浏览器在响应到达时立即读取它,不需要等待整个响应体来解析第一个标签。

以下是一个小型的 Node.js 示例:

code
import http from "node:http";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const server = http.createServer(async (req, res) => {
    res.writeHead(200, {
        "Content-Type": "text/html; charset=utf-8",
    });

    res.write(`
    <!doctype html>
    <html>
      <head>
        <title>Normal HTML Streaming</title>
      </head>
      <body>
        <h1>Normal HTML Streaming</h1>
        <p>This part arrives first.</p>
  `);

    await sleep(2000);

    res.write(`
        <p>This part arrives after two seconds.</p>
  `);

    await sleep(2000);

    res.end(`
        <p>This part arrives after four seconds.</p>
      </body>
    </html>
  `);
});

server.listen(3000, () => {
    console.log("Server running at http://localhost:3000");
});

此示例使用 Node 的内置 http 模块创建了一个小型 HTTP 服务器。当你访问页面时,服务器会分三个独立的 HTML 块发送响应。

第一个 res.write() 立即发送文档外壳、标题和第一段文字。然后 sleep(2000) 让服务器暂停两秒钟,再通过下一个 res.write() 发送另一段文字。再暂停后,res.end() 发送最后一段文字并关闭 HTML 文档。

浏览器在完整响应完成之前开始渲染第一个块,然后随着更多 HTML 到达添加后续段落。

这表明简单的 HTML 流式传输可以工作,但内容显示的顺序与服务器发送的顺序相同。

现在在浏览器中打开这个页面。

code
http://localhost:3000

你将在完整响应完成之前看到页面的第一部分。随着服务器发送更多块,浏览器会继续加载。

这种行为是旧式的且功能性的。但请注意其结构。服务器先写第一段,然后是第二段,最后是第三段。浏览器以相同的顺序接收它们,并且也以相同的顺序将它们放置在 DOM 中。

传统的流式传输允许你先发送前面的内容,但它没有提供一种原生的方式来说明下一个块将位于前面的占位符内。声明式部分更新针对的就是这一缺失的部分。

为什么框架已经绕过这个问题

现代框架已经创建了部分页面在准备好时就渲染的体验。React 服务器组件和基于 Suspense 的服务器端渲染是常见的例子。框架可以先发送一个外壳,显示一个占位符,然后稍后再流式传输完整内容。

但浏览器并不将 React 边界视为原生 HTML。框架必须编码自己的协议。正如 WICG 的修补说明中所提到的,React 使用内联 <script> 标签来乱序流式传输内容并修改已解析的 DOM。

这之所以可行是因为 JavaScript 具有完全的 DOM 访问权限。但这同时也意味着浏览器并未将更新作为普通的 HTML 应用。框架运行时或框架生成的脚本参与了这一修补过程。

HTMX 以另一种方式解决了相关的一类问题。它允许你请求服务器渲染的 HTML 并交换页面的部分内容。这种模型实用且流行,但它仍然依赖于 JavaScript 库。

Astro 流行化了岛屿架构,其中页面中独立交互的部分本质上被包含在一个静态 HTML 文档中。Chrome 关于声明式部分更新的文章将岛屿架构作为此提案的用例之一。

声明式部分更新并未取代这些工具。它提出了一种低级别的浏览器原语。框架可以稍后采用这一原语。服务器渲染的应用也可以在更简单的情况下直接使用它。

关键的变化在于:服务器不再发送用于查找 DOM 节点并修改它的 JavaScript,而是发送声明节点位置的 HTML。

声明式部分更新为 HTML 增加了什么

Chrome 的提案包含两个主要部分:

  • 第一部分是使用 <template> 补丁的 HTML 占位符和乱序流式传输。
  • 第二部分是一组新的 JavaScript 方法,用于将 HTML 插入和流式传输到现有文档中。

Chrome 的公告指出,从 Chrome 148 开始,开发者可以使用实验性 Web 平台功能标志来测试这些 API。这一状态很重要。它目前还不是稳定的跨浏览器 HTML。它是一个活跃的提案和实现测试。

对于声明式流式传输部分,提案使用了两个 HTML 概念:

  1. 处理指令占位符。
  1. 带有 for 属性的 <template> 元素。

一个简化的例子如下所示:

code
<div><?marker name="profile-card"></div>

<template for="profile-card">
    <article>
        <h2>Ada Lovelace</h2>
        <p>Mathematician and early computing pioneer.</p>
    </article>
</template>

占位符标记了一个位置。模板包含内容。当浏览器解析模板时,它会找到匹配的占位符并将模板内容应用到该位置。解析后,最终的 DOM 表现如下:

code
<div>
    <article>
        <h2>Ada Lovelace</h2>
        <p>Mathematician and early computing pioneer.</p>
    </article>
</div>

服务器先发送占位符,稍后再发送实际内容。浏览器以声明式方式将它们组合在一起。这就是基本思想。HTML 获得了一个原生的修补指令,而无需任何自定义脚本。

Image 3: Diagram showing Declarative Partial Updates

在上图中,浏览器首先接收并解析占位符。后续的模板针对这些占位符,因此渲染的页面会更新特定区域,而不会将所有延迟的 HTML 附加到底部。

标记占位符的工作原理

最简单的占位符是 <?marker>。标记指的是文档中稍后将出现 HTML 代码的位置。以下是从提案模型中提取的一个小例子:

code
<section>
    <h2>Team</h2>

    <ul>
        <?marker name="team-members">
    </ul>
</section>

<template for="team-members">
    <li>Ada Lovelace</li>
</template>

标记的 name 属性将其与模板的 for 属性连接起来。当浏览器解析模板时,它会找到名为 team-members 的标记,并将模板内容插入到该标记的位置。

最终的 DOM 表现如下:

code
<section>
    <h2>Team</h2>

    <ul>
        <li>Ada Lovelace</li>
    </ul>
</section>

虽然这看起来很简单,但时机很重要。占位符可以出现在响应的开头,模板可以稍后出现。这意味着浏览器可以先渲染一个可用的外壳,然后在服务器发送延迟内容时修补该特定位置。

这与普通的流式传输不同,普通的流式传输会按顺序稍后显示下一个 HTML 文档。标记指定了下一个 HTML 的目标。

为什么处理指令很重要

如果你主要编写 HTML,占位符语法可能看起来不寻常。

code
<?marker name="profile">

这类似于 XML 处理指令的语法。MDN 解释说,处理指令存在于 DOM 中,但在 HTML 文档中目前被视为注释。声明式部分更新提案为 HTML 修补赋予了选定处理指令的浏览器级意义。这就是为什么此功能需要浏览器支持。

在当前稳定的 HTML 中,编写 <?marker name="profile"> 并不会在所有浏览器中创建一个修补点。如果没有支持,它会将内容视为忽略的标记或注释。因此,应将此语法视为推荐行为,而不是已确立的 HTML 行为。

起始和结束范围占位符的工作原理

单个标记为您提供了一个插入点。但许多 UI 更新需要替换整个区域。

例如,页面可能首先显示一个加载消息:

code
<section>
    <h2>Recommendations</h2>

    <?start name="recommendations">
    <p>Loading recommendations...</p>
    <?end>
</section>

稍后,服务器发送真实内容:

code
<template for="recommendations">
    <ul>
        <li>Advanced CSS Layouts</li>
        <li>Modern HTML APIs</li>
        <li>Web Performance Basics</li>
    </ul>
</template>

浏览器会将 <?start><?end> 之间的范围替换为模板内容。

最终的 DOM 表现如下:

code
<section>
    <h2>Recommendations</h2>

    <ul>
        <li>Advanced CSS Layouts</li>
        <li>Modern HTML APIs</li>
        <li>Web Performance Basics</li>
    </ul>
</section>

这接近加载边界。文档以必要的回退内容开始,服务器稍后完成较慢的任务。当适当的模板到达时,浏览器会替换回退内容。

解释器中的 <?end> 处理指令是可选的,但它使示例更容易理解。在教学或测试该功能时可以使用它。

标记与范围

使用标记在特定位置添加内容。使用起始和结束范围来替换临时内容。

这意味着,在这里添加内容:

code
<ul>
    <?marker name="new-item">
</ul>

这表示,稍后替换整个加载区域:

code
<div>
    <?start name="profile">
    <p>Loading profile...</p>
    <?end>
</div>

第二种方法更适合实际界面,因为用户在等待时可以看到有意义的回退内容。

多次更新的工作原理

占位符不必只是一个更新。服务器可以发送补丁,留下另一个标记,然后稍后再发送另一个补丁。这对于列表、信息流、通知、日志、评论和搜索结果非常重要。

以下是一个简化的示例:

code
<ul>
    <?marker name="messages">
</ul>

<template for="messages">
    <li>First message</li>
    <?marker name="messages">
</template>

<template for="messages">
    <li>Second message</li>
    <?marker name="messages">
</template>

<template for="messages">
    <li>Third message</li>
</template>

最终的 DOM 表现如下:

code
<ul>
    <li>First message</li>
    <li>Second message</li>
    <li>Third message</li>
</ul>

每个补丁添加内容并创建下一个出口。这种模型适合流式数据,服务器可以在一段时间内找到结果。

想象一下搜索页面。服务器可以快速找到第一个结果。第二个结果需要另一个服务调用。第三个结果需要数据库查询。

传统的 HTML 流式传输将每个结果放置在响应中出现的位置。声明式补丁允许每个结果针对文档中的已知出口。这并不意味着每个列表都应该使用此功能。这意味着 HTML 将为此模式提供一个原生的原始功能。

交错更新的工作原理

最有趣的部分是交错。

假设一个页面有三个区域:

  1. 个人资料卡片。
  2. 推荐面板。
  3. 通知列表。

每个区域都需要不同的服务器管理。个人资料卡片在一秒钟后准备就绪。通知在两秒钟后准备就绪。推荐在四秒钟后准备就绪。在简单的流式传输中,文档的顺序决定了用户首先看到的内容。

通过声明式补丁,服务器可以提前发送占位符:

code
<main>
    <section>
        <h2>Profile</h2>
        <?start name="profile">
        <p>Loading profile...</p>
        <?end>
    </section>

    <section>
        <h2>Recommendations</h2>
        <?start name="recommendations">
        <p>Loading recommendations...</p>
        <?end>
    </section>

    <section>
        <h2>Notifications</h2>
        <?start name="notifications">
        <p>Loading notifications...</p>
        <?end>
    </section>
</main>

然后服务器按照准备顺序而不是视觉顺序发送模板:

code
<template for="profile">
    <p>Ada Lovelace</p>
</template>

<template for="notifications">
    <ul>
        <li>You have one new message.</li>
    </ul>
</template>

<template for="recommendations">
    <ul>
        <li>Modern HTML APIs</li>
        <li>Streaming Web Apps</li>
    </ul>
</template>

浏览器修补每个目标区域。用户首先看到个人资料,然后是通知,最后是推荐,尽管在主文档结构中推荐出现在通知之前。

这就是为什么“无序流式传输”是关键。HTML 响应以流的形式到达。但渲染的更新不必遵循响应块的相同视觉顺序。

WICG 解释文档描述了跨不同出口的交错补丁。这是此提案重要的原因之一。它为浏览器原生 HTML 提供了开发人员通常与框架级流式传输相关联的行为。

与 React、HTMX、Astro 和 PHP 的比较

此功能将允许进行比较。这些比较可以帮助您理解主要思想,但如果深入研究,也可能引起混淆。

React

React 服务器端渲染已经支持流式 UI 和回退状态。但渲染模型是 React 自己的。当 React 需要将延迟添加的内容修补到已解析的文档中时,它使用框架生成的指令和 JavaScript。

声明式部分更新将这一概念的低级部分带入 HTML。它不会取代 React 的组件模型、状态模型、事件处理、协调或生态系统。

HTMX

HTMX 允许您从服务器请求 HTML 并将其替换到页面上。它在概念上很接近,因为它将 HTML 视为主要响应格式。但 HTMX 是一个 JavaScript 库。

声明式部分更新的目标是将某些 HTML 补丁行为作为浏览器本身的功能。HTMX 仍然处理许多超出此提案范围的交互模式。

Astro

Astro 推广了基于岛屿的渲染。一个页面可以主要由静态 HTML 和孤立的交互区域组成。Chrome 提到岛屿架构作为一种使用场景,因为页面的独立区域通常在不同时间渲染。

声明式部分更新可以帮助服务器在每个区域渲染时提供岛屿 HTML。

PHP

它的语法可能让你联想到 PHP,因为服务器端代码和 HTML 已经一起使用了数十年。但此提案并不是在浏览器中的 PHP。

PHP 在服务器端运行。声明式部分更新是浏览器对流式 HTML 的解析行为。可以将其与之进行有用的工作流比较。

它使得将服务器生成的 HTML 流式传输到页面上的特定位置变得更加容易,而无需为每次更新发送单独的 JavaScript 补丁。

JavaScript HTML 插入 API 的作用

声明式占位符解决了部分问题。它们帮助浏览器将流式 HTML 补丁应用到同一文档中的先前位置。

但并非所有更新都来自原始 HTML 响应。许多应用程序在页面加载后获取 HTML。

例如,一个页面可能会获取:

  • 评论部分
  • 购物车摘要
  • 用户资料下拉菜单
  • 通知面板
  • 搜索结果预览

目前,JavaScript 有几种将 HTML 插入文档的方法:

code
element.innerHTML = "<p>Hello</p>";
element.outerHTML = "<section>Hello</section>";
element.insertAdjacentHTML("beforeend", "<p>Hello</p>");

这些 API 可以工作,但它们的行为并不相同。

  • 有些替换内容。
  • 有些在内容旁边插入。
  • 有些在特定上下文中解析。
  • 有些与清理和安全规则的交互方式不同。

Chrome 的声明式部分更新工作还包括一组经过改进的 HTML 插入和流式传输 API,旨在为常见的插入模式创建更明确的命名约定。

基本思路如下:

| 动作 | 静态方法 | 流式方法 | | --- | --- | --- | | 替换元素的子元素 | setHTML() | streamHTML() | | 替换元素本身 | replaceWithHTML() | streamReplaceWithHTML() | | 在元素前插入 | beforeHTML() | streamBeforeHTML() | | 作为第一个子元素插入 | prependHTML() | streamPrependHTML() | | 作为最后一个子元素插入 | appendHTML() | streamAppendHTML() | | 在元素后插入 | afterHTML() | streamAfterHTML() |

方法名称告诉你 HTML 的插入位置。这是主要的改进。

无需记住 innerHTMLouterHTMLinsertAdjacentHTMLcreateContextualFragment() 之间的差异,方法名称描述了操作。

静态插入

静态方法接受完整的 HTML 字符串。

code
const card = document.querySelector("#profile-card");

card.setHTML(`
  <article>
    <h2>Ada Lovelace</h2>
    <p>Mathematician and early computing pioneer.</p>
  </article>
`);

setHTML() 解析字符串,清理它,并将结果插入到元素中。

MDN 将 setHTML() 描述为一种 XSS 安全的方式来解析和清理 HTML 字符串,然后再将其包含在 DOM 中。这比将不受信任的 HTML 赋值给 innerHTML 更安全。

流式插入

流式方法不需要从完整的 HTML 字符串开始。它创建一个可写流。然后 JavaScript 将片段写入该流。

一个简化的例子如下:

code
const output = document.querySelector("#output");
const writer = output.streamHTMLUnsafe().getWriter();

await writer.write("<p>First streamed chunk</p>");
await writer.write("<p>Second streamed chunk</p>");
await writer.close();

这种模型很重要,因为流式传输是 HTML 的一大优势。浏览器不必总是等到完整响应到达后再插入有意义的标记。

Chrome 还通过 fetch() 展示了这种模式:

code
const output = document.querySelector("#output");
const response = await fetch("/comments");

response.body
    .pipeThrough(new TextDecoderStream())
    .pipeTo(output.streamHTMLUnsafe());

在这里,响应体从网络中流式传输。TextDecoderStream 将字节转换为文本。结果流入元素的 HTML 流。

为什么“Unsafe”名称很重要

某些方法有 Unsafe 版本。

例如:

code
setHTMLUnsafe();
streamHTMLUnsafe();
appendHTMLUnsafe();
streamAppendHTMLUnsafe();

名称是有意的。MDN 警告说,setHTMLUnsafe() 将输入解析为 HTML 并将结果写入 DOM。如果不传递清理器,则不会使用清理器。

对于不受信任的内容,请使用安全版本。将不安全版本视为一种低级工具,特别是在你完全控制 HTML 源或传递适当清理器的情况下。

在本文的演示中,请将用户输入与 HTML 字符串分开。目标是教授流式行为,而不是不安全的 HTML 插入。

Image 4: Diagram showing two partial update models

在上图中,声明式修补从流式文档内部开始。JavaScript 流式插入在脚本代码选择元素并将流式响应管道到其中后开始。

如何构建一个小型 Node.js 流式传输演示

现在让我们构建一个小型演示。

你将创建一个具有三个路由的 Node.js 服务器:

| 路由 | 目的 | | --- | --- | | /normal-stream | 展示常规顺序 HTML 流式传输 | | /partial-updates-demo | 展示提议的声明式部分更新语法 | | /stream-html-api-demo | 展示 JavaScript 流式插入语法 |

演示使用 Node 的内置 http 模块。

  • 不使用 Express。
  • 不使用前端框架。
  • 不需要构建步骤。

这使重点集中在 HTML 流式传输上。

创建项目

创建一个新的文件夹:

code
mkdir html-partial-updates-demo
cd html-partial-updates-demo

创建一个 package.json 文件:

code
{
    "name": "html-partial-updates-demo",
    "version": "1.0.0",
    "type": "module",
    "scripts": {
        "dev": "node server.js"
    }
}

创建一个 server.js 文件:

code
import http from "node:http";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const server = http.createServer(async (req, res) => {
    if (req.url === "/normal-stream") {
        return normalStream(req, res);
    }

    if (req.url === "/partial-updates-demo") {
        return partialUpdatesDemo(req, res);
    }

    if (req.url === "/stream-html-api-demo" || req.url === "/comments-stream") {
        return streamHtmlApiDemo(req, res);
    }

    res.writeHead(200, {
        "Content-Type": "text/html; charset=utf-8",
    });

    res.end(`
    <!doctype html>
    <html>
      <head>
        <title>HTML Partial Updates Demo</title>
      </head>
      <body>
        <h1>HTML Partial Updates Demo</h1>

        <ul>
          <li><a href="/normal-stream">Normal HTML streaming</a></li>
          <li><a href="/partial-updates-demo">Declarative partial updates demo</a></li>
          <li><a href="/stream-html-api-demo">Streaming HTML API demo</a></li>
        </ul>
      </body>
    </html>
  `);
});

async function normalStream(req, res) {
    res.writeHead(200, {
        "Content-Type": "text/html; charset=utf-8",
    });

    res.write(`
    <!doctype html>
    <html>
      <head>
        <title>Normal HTML Streaming</title>
      </head>
      <body>
        <h1>Normal HTML Streaming</h1>

        <p>This paragraph arrives immediately.</p>
  `);

    await sleep(1500);

    res.write(`
        <p>This paragraph arrives after 1.5 seconds.</p>
  `);

    await sleep(1500);

    res.end(`
        <p>This paragraph arrives after 3 seconds.</p>

        <p>
          <a href="/">Back to home</a>
        </p>
      </body>
    </html>
  `);
}

async function partialUpdatesDemo(req, res) {
    res.writeHead(200, {
        "Content-Type": "text/html; charset=utf-8",
    });

    res.write(`
    <!doctype html>
    <html>
      <head>
        <title>Declarative Partial Updates Demo</title>
        <style>
          body {
            font-family: system-ui, sans-serif;
            max-width: 760px;
            margin: 40px auto;
            line-height: 1.5;
          }

          section {
            border: 1px solid #ddd;
            border-radius: 12px;
            padding: 16px;
            margin-bottom: 16px;
          }

          .loading {
            color: #666;
          }
        </style>
      </head>
      <body>
        <h1>Declarative Partial Updates Demo</h1>

        <p>
          This page sends placeholders first. Then the server sends
          matching templates when each piece of content is ready.
        </p>

        <section>
          <h2>Profile</h2>
          <?start name="profile">
            <p class="loading">Loading profile...</p>
          <?end>
        </section>

        <section>
          <h2>Recommendations</h2>
          <?start name="recommendations">
            <p class="loading">Loading recommendations...</p>
          <?end>
        </section>

        <section>
          <h2>Notifications</h2>
          <?start name="notifications">
            <p class="loading">Loading notifications...</p>
          <?end>
        </section>
  `);

    await sleep(1000);

    res.write(`
        <template for="profile">
          <p><strong>Ada Lovelace</strong></p>
          <p>Mathematician and early computing pioneer.</p>
        </template>
  `);

    await sleep(1000);

    res.write(`
        <template for="notifications">
          <ul>
            <li>You have one new message.</li>
            <li>Your weekly report is ready.</li>
          </ul>
        </template>
  `);

    await sleep(2000);

    res.end(`
        <template for="recommendations">
          <ul>
            <li>Modern HTML APIs</li>
            <li>Streaming Web Apps</li>
            <li>Web Performance Basics</li>
          </ul>
        </template>

        <p>
          <a href="/">Back to home</a>
        </p>
      </body>
    </html>
  `);
}

async function streamHtmlApiDemo(req, res) {
    if (req.url === "/comments-stream") {
        res.writeHead(200, {
            "Content-Type": "text/html; charset=utf-8",
        });

        res.write(`<article><p>First streamed comment.</p></article>`);

        await sleep(1000);

        res.write(`<article><p>Second streamed comment.</p></article>`);

        await sleep(1000);

        return res.end(`<article><p>Third streamed comment.</p></article>`);
    }

    res.writeHead(200, {
        "Content-Type": "text/html; charset=utf-8",
    });

    res.end(`
    <!doctype html>
    <html>
      <head>
        <title>Streaming HTML API Demo</title>
      </head>
      <body>
        <h1>Streaming HTML API Demo</h1>

        <button id="load-comments">Load comments</button>

        <section id="comments"></section>

        <p>
          <a href="/">Back to home</a>
        </p>

        <script>
          const button = document.querySelector("#load-comments")
          const comments = document.querySelector("#comments")

          button.addEventListener("click", async () => {
            const response = await fetch("/comments-stream")

            response.body
              .pipeThrough(new TextDecoderStream())
              .pipeTo(comments.streamHTMLUnsafe())
          })
        </script>
      </body>
    </html>
  `);
}

server.listen(3000, () => {
    console.log("Server running at http://localhost:3000");
});

此文件创建了主要的演示服务器。它导入了 Node 的内置 http 模块,定义了一个用于延迟流的小型 sleep() 辅助函数,然后创建了一个具有三个计划路由的 HTTP 服务器。

当请求 URL 匹配 /normal-stream/partial-updates-demo/stream-html-api-demo 时,服务器将请求传递给相应的函数。如果用户访问根页面,服务器将返回一个带有三个演示链接的简单 HTML 页面。

最后,server.listen(3000) 会在端口 3000 上启动服务器,因此你可以在浏览器中通过 http://localhost:3000 打开演示。

运行服务器:

code
npm run dev

然后在浏览器中打开:

code
http://localhost:3000

你现在有三个演示。

测试普通流式路由

打开此路由:

code
http://localhost:3000/normal-stream

观察浏览器。

  • 第一段文字会先出现。
  • 第二段文字稍后出现。
  • 第三段文字最后出现。

这很有用,但它仍然是顺序流式传输。浏览器以服务器发送的相同顺序进行渲染。

测试声明式部分更新路由

打开此路由:

code
http://localhost:3000/partial-updates-demo

在支持实验性功能的浏览器中,页面首先显示三个加载区域。

  • 一秒钟后,个人资料区域更新。
  • 再过一秒钟,通知区域更新。
  • 再过两秒钟,推荐区域更新。

注意顺序。推荐部分在文档中出现在通知之前。但服务器发送通知模板的时间早于推荐模板。

这就是关键点。文档结构和流式传输顺序不再需要相同。浏览器使用每个 <template> 上的 for 属性来找到匹配的处理指令占位符。

此示例展示了无序 HTML 流式传输的基本思想。

测试流式 HTML API 路由

打开此路由:

code
http://localhost:3000/stream-html-api-demo

点击按钮。

  • 浏览器获取 /comments-stream
  • 服务器以块的形式发送评论 HTML。
  • 客户端将流式响应体注入到 #comments 元素中。

这与声明式占位符修补不同。在这里,JavaScript 启动请求并选择目标元素。

新 API 改进了 JavaScript 插入流式 HTML 的方式。占位符语法改进了 HTML 在文档流式传输期间声明后续修补的方式。请将这两个概念分开理解。它们相关,但解决了部分更新故事的不同部分。

浏览器支持和当前状态

声明式部分更新是实验性的。Chrome 表示,从 Chrome 148 开始,这些 API 已准备好通过实验性网络平台功能标志进行开发者测试。

这意味着你应该将此提案视为正在进行的平台工作,而不是稳定的生产级 HTML。

要在 Chrome 中测试原生行为,请启用此标志:

code
chrome://flags/#enable-experimental-web-platform-features

然后重启浏览器。Blink 开发者线程将此功能描述为一种以无序方式流式传输 HTML 内容并使用编码修补流更新现有文档的方式。

该提案还包括 WICG 解释文档和 WHATWG HTML 拉取请求。

这告诉你提案所处的位置:

  1. 它在 Chrome 中有一条实际的实现路径。
  1. 它在 WICG 中有一个解释文档。
  1. 它在 WHATWG 中有标准化讨论。
  1. 它还不是完成的跨浏览器标准。

因此,声明式部分更新是一组用于原生 HTML 补丁和流式传输的浏览器 API 提案。Chrome 已经通过标志将其提供给开发者测试。

安全性、清理和潜在风险

部分更新听起来可能很简单,但在向文档中添加 HTML 时总是存在安全风险。

HTML 不仅仅是纯文本。它可以包含链接、表单、事件处理程序、脚本、自定义元素以及其他由浏览器解析的元素。因此,HTML 的来源很重要。

如果你的服务器流式传输由你自己的模板生成的可信 HTML,这种风险类型与将用户评论、个人资料或聊天消息发送的 HTML 添加到文档中的风险类型不同。

模板范围

Chrome 的文章提到了 <template for> 的一个重要限制。模板只能更新同一父元素内的处理指令。将 <template for> 直接添加到 <body> 会使其访问整个文档,包括 <head>。这种行为非常强大,因此在围绕它进行设计之前,你应该理解其范围。

主要观点很简单:不要假设一个后期模板可以在没有规则的情况下修补任何位置的占位符。修补目标是出于安全性和可预测性而限定范围的。

动态插入的不同行为

还有一个潜在风险。如果你使用 setHTML()innerHTML 等 API 动态插入一段 HTML,浏览器会首先在中间片段中解析该 HTML。

这意味着声明式修补仅适用于该解析片段内部。它不会自动搜索整个现有文档并更新片段外部的旧占位符。这种区别很重要,因为文档流式传输和动态 HTML 插入不是同一个过程。

安全和不安全的 HTML API

JavaScript 插入 API 还区分了更安全和更低级别的方法。

  • setHTML() 在插入之前会对输入进行清理。
  • setHTMLUnsafe() 是较低级别的方法。

MDN 表示,如果支持的话,几乎应该始终优先使用 setHTML(),因为它始终会移除可能引发 XSS 的不安全 HTML 实体。

因此规则很简单:

  • 对于不可信内容,使用安全 API。
  • 将不安全 API 视为高级工具,仅用于可信 HTML 或经过仔细清理的 HTML。
  • 永远不要在没有清理策略的情况下将用户控制的 HTML 流式传输到 DOM 中。

这对 Web 开发者意味着什么

声明式部分更新之所以重要,是因为它们将一个常见的框架模式更接近于 Web 平台。多年来,开发者一直使用 JavaScript 构建部分更新系统:

  • 获取 HTML。
  • 查找 DOM 节点。
  • 替换其内容。
  • 保持加载状态同步。
  • 避免破坏事件处理程序。
  • 处理错误。
  • 处理安全问题。

框架和库改进了这一工作流程。但浏览器仍然缺乏一种小型的原生语言来表达:这部分较晚的 HTML 应该放在那个较早的占位符中。声明式部分更新(Declarative Partial Updates)提供了这种语言。

这并不意味着 JavaScript 会消失。您仍然需要 JavaScript 来实现交互性、状态管理、事件处理、客户端数据流以及许多应用程序行为。这也并不意味着框架会消失。React、HTMX、Astro 和类似的工具解决的问题远不止 DOM 补丁。

更现实的结果是:

  • 框架和服务器端渲染工具可能会在内部使用这些原语。
  • 较小的服务器端渲染应用程序可能会直接使用它们来实现简单的加载区域。
  • 浏览器可能会获得一种更干净、更低级别的机制,用于将 HTML 流式传输到精确的位置。这才是关键部分。

该提案并不是让 HTML 成为一个完整的应用程序框架。它只是为 HTML 提供了一个更好的流式补丁原语。

**此功能适用的场景**

声明式部分更新适用于服务器早期知道布局但某些部分稍后完成的页面。

好的例子包括:

  • 带有加载较慢推荐块的产品页面。
  • 带有独立数据卡片的仪表板。
  • 结果来自多个服务的搜索页面。
  • 导航或菜单较重的文档网站。
  • 带有独立活动、统计和通知区域的个人资料页面。
  • 希望在没有客户端补丁脚本的情况下实现加载状态的服务器端渲染应用程序。

最适用的是服务器生成的 HTML。如果您的应用程序已经在客户端渲染所有内容,那么此提案不会对您的架构产生太大影响。

如果您强调快速首次渲染、渐进式流式传输、低 JavaScript 开销和服务器端渲染的 UI,那么此提案将更具吸引力。

**当前应避免使用的情况**

目前,您应避免在生产环境中依赖声明式部分更新。

可以将其用于:

  • 学习。
  • 本地演示。
  • 浏览器实验。
  • 框架研究。
  • 渐进增强实验。

不要将其作为重要生产 UI 的唯一路径。浏览器支持尚未广泛普及。推荐内容仍可能发生变化。工具尚未成熟。大多数用户不会启用实验标志。如果您正在试验它,请创建一个回退方案。

例如,即使占位符未被原生补丁覆盖,它们在服务器端渲染页面上仍应有意义。或者,您可以在应用程序中使用一个小的 JavaScript 回退,直到浏览器支持得到改善。

**结论**

HTML 流式传输是一个旧概念,而无序 HTML 补丁是一个新想法。传统的流式传输允许浏览器在接收到数据块时进行渲染,但文档仍然遵循响应的顺序。

声明式部分更新提出了一种将较晚的 HTML 放入较早占位符中的原生方法。

核心语法非常简单:

code
<?marker name="target">

<template for="target">
    <p>Late content</p>
</template>

对于加载区域,语法使用范围:

code
<?start name="target">
<p>Loading...</p>
<?end>

<template for="target">
    <p>Real content</p>
</template>

这为 HTML 提供了一种浏览器原生的补丁模型。它还为某些服务器端渲染的流式接口减少了自定义 JavaScript 的需求。但这一功能仍处于实验阶段。思考声明式部分更新的最佳方式不是将其视为 HTML 替代框架。

更好的方式是:HTML 正在获得一个框架和服务器端渲染应用程序多年来所需的低级原语。

如果提案得以推进,Web 开发者可能会获得一种更简洁的方式来构建快速、渐进渲染的页面,其中服务器会在每个部分准备好后立即流式传输内容。

**最后的话**

如果您觉得这里的信息有价值,请随时与可能从中受益的其他人分享。

我非常期待您的反馈——请在 X 上提及我@sumit_analyzen,或在 Facebook 上@sumit.analyzen观看我的编程教程,或简单地在 LinkedIn 上与我建立联系

您还可以访问我的官方网站www.sumitsaha.me以获取更多关于我的详细信息。

**参考资料**

  • * *
  • * *

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人获得开发者职位。立即开始

AI 可能会生成不准确的信息,请核实重要内容