HTML一直以来都支持流式传输。服务器在将页面发送给浏览器之前,并不需要先将整个页面内容加载到内存中。它可以先发送初始的HTML代码,然后等到后续部分准备就绪后再依次发送这些数据。浏览器会按照接收到的顺序解析这些数据并显示页面内容,这就是为什么HTML看起来运行速度很快的原因之一。

然而,传统的HTML流式传输方式存在一个严格的规则:所有内容必须按照文档中的顺序进行传输。如果浏览器先收到了页头信息,接着是侧边栏的内容,最后才是主体内容,那么它也会按照这个顺序来解析这些数据。如果数据库查询耗时过长,导致某些页面内容无法及时传输,那么后续的部分就不得不等待服务器完成处理才能继续发送。

多年来,JavaScript框架一直在努力解决这个问题。服务器端渲染框架能够处理页面结构的构建、加载状态的管理以及延迟内容的传输等问题。有些框架会使用内嵌脚本来修改现有的DOM结构,而像HTMX这样的库则允许开发者使用服务器生成的HTML代码来更新页面的特定部分。

不过,这些解决方案都需要依赖JavaScript才能实现。而“声明式部分更新”这种技术则提出了另一种思路:如果HTML本身能够有一种方式来指定,“当这部分内容到达时,应该将其放置在哪里”,那该多好呢?

这就是Chrome提出的声明式部分更新机制背后的理念。

在本文中,你将了解到声明式部分更新旨在解决哪些问题,这种技术所使用的占位符语法是如何工作的,非顺序传输的HTML内容与传统流式传输方式有何不同,相关的JavaScript API又是如何发挥作用的,以及为什么应该将这一功能视为浏览器的实验性特性,而不是可以直接投入生产的成熟技术。

目录

声明性部分更新试图解决什么问题

以产品页面为例。服务器已经知道了页面的标题、导航栏、页脚以及产品详情,但推荐信息部分的显示需要执行耗时的数据库查询。使用传统的服务器渲染HTML技术,有两种常见的处理方式:

  • 第一种方法是:服务器等待所有内容准备就绪后再发送完整的HTML响应。这种方式可以使代码结构保持简洁,但用户需要等待较长时间才能看到有用的内容。

  • 第二种方法是:服务器分阶段发送HTML内容。先发送页面的上半部分,然后随着后续内容的准备完毕再依次发送剩余部分。这种方法似乎可以提高性能,因为浏览器可以在完整的响应数据送达之前就开始进行渲染。

然而,仅仅采用分段传输的方式并不能完全解决这个问题。浏览器仍然会按顺序解析HTML代码。如果文档的开头部分包含耗时的推荐信息,那么后面的内容就会被延迟显示,除非对文档结构进行重新设计、添加JavaScript代码或使用框架来辅助处理。

WICG的相关说明指出了传统HTML分段传输技术的两个局限性:

  1. HTML内容是按照DOM的结构顺序进行分阶段传输的。

  2. 在完成最初的文档解析步骤之后,后续的分段传输操作就不会像最初那样频繁进行了。

声明性部分更新技术试图突破这一限制。它允许服务器先发送占位内容,然后再在响应中发送实际的内容。浏览器会将新的内容覆盖在原有的占位内容上。这种技术不需要任何自定义的客户端DOM操作代码。

示意图展示传统HTML分段传输的过程。

在上面的示意图中,服务器会按顺序发送不同的HTML片段。浏览器可以在响应数据完全送达之前就开始渲染前面的片段,但最终的渲染顺序仍然与服务器发送数据的顺序一致。

传统HTML分段传输的工作原理

在研究这一技术方案之前,首先需要了解它的基本结构。服务器会发送一个HTTP响应体,该响应体中包含HTML代码。浏览器一收到响应数据就会开始对其进行解析,而不必等到整个响应体全部送达后再开始处理。

下面是一个简单的Node.js示例:

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分段传输示例
      
      
        

传统HTML分段传输示例

这部分内容会首先被发送。

`); await sleep(2000); res.write(`

这部分内容会在两秒后发送。

这部分内容会在四秒后发送。

`); }); server.listen(3000, () => { console.log("服务器正在运行,地址为http://localhost:3000"); });

这个例子使用了Node内置的http模块来创建一个简单的HTTP服务器。当你访问该页面时,服务器会分三部分发送HTML内容作为响应。
首先,res.write()会立即发送文档的框架结构、标题以及第一段文字。然后sleep(2000)会让服务器暂停两秒钟,之后再通过res.write()发送第二段文字。经过另一次延迟后,res.end()会发送最后一段文字并关闭整个HTML文档。
浏览器会在所有响应内容全部送达之前开始渲染第一部分内容,随后随着更多HTML数据的到达,再逐步加载后续段落。
这说明了简单的HTML流式传输机制是可行的,但页面内容的显示顺序仍然与服务器发送内容的顺序一致。
现在,请在浏览器中打开这个页面。

http://localhost:3000

你会看到,在所有响应内容全部送达之前,页面的第一部分内容就已经显示出来了。随着服务器继续发送后续数据,浏览器会逐步加载剩余内容。
这种处理方式虽然比较传统,但功能上是可行的。注意观察其工作原理:服务器依次发送第一段、第二段、第三段文字,浏览器也会按照相同的顺序接收这些内容,并将它们以同样的顺序插入到DOM中。
传统的流式传输机制确实允许先发送前面的内容,但它并没有提供一种原生方式来确保后续内容会被插入到之前已经显示的部分中。而“声明性部分更新”技术正是为了解决这个问题而设计的。

为什么框架已经能够解决这个问题

现代框架已经能够实现这样的功能:即当页面的某部分内容准备好时,立即进行渲染。React的服务器组件以及基于“延迟渲染”机制的技术就是常见的例子。框架可以先发送文档的框架结构,显示替代内容,然后再逐步传输完整的内容。
不过,浏览器并不能将React生成的代码视为普通的HTML。因此框架需要自行设计编码协议。正如WICG团队所解释的那样,React利用内嵌的 `); } server.listen(3000, () => { console.log("服务器正在运行,地址为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访问这个演示程序。

运行服务器的方法如下:

npm run dev

然后在浏览器中打开该地址:

http://localhost:3000

现在你就可以看到这三个演示功能了。

测试正常数据流传输路由

访问以下路径:

http://localhost:3000/normal-stream

观察浏览器的显示效果:

  • 第一段内容会最早被显示出来。

  • 第二段内容会在稍后出现。

  • 第三段内容则会最后显示。

这种传输方式确实很有用,但数据仍然是按顺序发送和渲染的——浏览器会按照服务器发送数据的顺序来展示这些内容。

测试声明式部分更新路由

访问以下路径:

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

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

  • 一秒钟后,“个人资料”区域的内容会更新。

  • 再过一秒钟,“通知”区域的内容会更新。

  • 两秒钟后,“推荐内容”区域的内容会更新。

注意观察这个顺序:在文档中,“推荐内容”应该出现在“通知”之前,但实际上服务器发送数据时,“通知”模板是先于“推荐内容”模板被发送的。

这就是这种技术的关键所在——文档的结构和数据的传输顺序不再需要完全一致。浏览器会利用每个