如何通过 HTTP 流式传输提升页面性能以及爱彼迎 (Airbnb) 在现有代码库上启用流式传输的实践。
介绍
在这篇文章中,我们将讨论如何通过使用 HTTP 流式传输,将 Airbnb.com 的字节流尽可能快地传输到你的浏览器中。
首先让我们了解一下什么是流式传输。想象一下我们手头有一个水龙头和两种选择:
先将一个大杯子装满,然后一次性全部倒入管道(“缓冲”策略)
将水龙头直接接到管道上(“流式”策略)
在缓冲策略中,所有的事情都是按顺序进行的:服务器首先生成整个响应并存入缓冲区(装满杯子),然后用更多的时间通过网络把响应发送到客户端(倒入管道)。而流式策略则是并行进行的,我们将响应分解成多个块,一旦某块准备好就立即发送出去。在前一个块仍在发送的同时,服务器就可以开始处理下一个块,而客户端(如浏览器)也可以在响应还未完全接收到之前就开始处理响应。
在爱彼迎中实践流式传输
虽然流式传输有明显的优势,但现在大多数网站仍然依赖缓冲策略来生成响应。原因之一是把页面拆分成独立的小块需要额外的工作量,并且将页面拆分为多块有时是行不通的。例如,当页面上的所有内容都依赖于一个慢速的后端查询时,那么在这个查询完成之前,我们就无法发送任何东西。
然而,有一种普遍适用的情况。我们可以利用流式传输来减少网络请求瀑布。网络请求瀑布是指一个网络请求触发了另一个网络请求,从而引起一系列连续的请求。这可以在 Chrome 的 Waterfall 工具中很容易看到:
Chrome Waterfall 展示了一系列连续的请求
大多数的网页依赖于在 HTML 中链接的外部 JavaScript 和 CSS 文件,这就产生了网络请求瀑布——下载 HTML 会触发下载 JavaScript 和 CSS 文件。因此,一个最佳实践是把所有 CSS 和JavaScript 标签放在 HTML 的 <head> 标签开头的地方,这样可以确保浏览器更早地看到它们。通过流式传输,我们可以首先发送 HTML 中 <head> 标签部分,更进一步减少这种延迟。
提前发送
先发送 <head> 标签最直接的方式是将标准响应分成两部分。这种技术被称为提前发送 (Early Flush),因为其中一部分先于另一部分被发送。
第一部分包含了那些能快速计算并且可以迅速发送的东西。在爱彼迎, 我们把字体文件,CSS 和 JavaScript 的标签包含在第一部分发送,这样就能享受到上文提到的浏览器的优势。第二部分则包含了页面的其余部分,包括那些需要 API 或数据库查询来计算的内容。最后的结果看起来像这样:
提前发送的块:
<html>
<head>
<script src=… defer />
<link rel="stylesheet" href=… />
<!--lots of other <meta> and other tags… ->
剩余内容的块:
<!-- <head> tags that depend on data go here ->
</head>
<body>
<! — Body content here →
</body>
</html>
我们调整了应用的结构来实现提前发送,因为爱彼迎用的是一个基于 Express 的 NodeJS 服务器,通过 React 来渲染网页,并且在此之前,整个的 HTML 文档都是用一个 React 组件渲染的。但这种做法有两个问题:
生成递增的内容块意味着我们需要处理未闭合的 HTML 标签。比如,之前看到的那些示例其实是无效的 HTML。<html> 和 <head> 标签在第一个块中并未被闭合,在第二个块中才被闭合。使用标准的 React 渲染函数是无法生成这样的输出的。
在拿到全部数据之前,我们无法渲染这个组件。
因此我们将单体组件拆分为三个来解决这些问题:
一个提前发送的 <head> 组件
一个稍后发送的 <head> 组件,用于需要依赖数据的标签
一个 <body> 组件
每个组件只负责渲染 <head> 和 <body> 标签中的内容,然后通过直接向 HTTP 响应流中写入开/闭标签来将它们拼接在一起。过程如下所示:
写入 <html><head>
渲染并把提前发送的 <head> 标签中的内容写入到响应中
等待数据
渲染并把稍后发送 (依赖数据) 的 <head> 标签中的内容写入到响应中
写入 </head><body>
渲染并把 <body> 标签的内容写入到响应中
最后通过写入 </body></html> 完成操作。
流式传输数据
提前发送优化了 CSS 和 JavaScript 的网络请求瀑布。然而,直到 <body> 标签到达之前,用户看到仍然是一个空白的页面。因此我们希望在没有数据的时渲染一个加载状态来改善用户体验,一旦接收到数据就立刻替换。幸运的是,我们在客户端路由的情况下已经有了加载状态,因此我们可以在不等待数据的情况下渲染应用!
然而,这就导致了另一个网络请求瀑布。浏览器必须先接收到服务器端渲染 SSR (Server-Side Render),然后再通过 JavaScript 触发另一个网络请求来获取实际的数据:
服务器端渲染和客户端数据获取依次发生时的网络请求瀑布
然而经过测试却发现这反而导致更长的总加载时间。
因此我们把这些数据直接包含在 HTML 中,这样就能让服务器端渲染和数据抓取同时进行:
服务器端渲染和客户端数据获取并行发生时的网络请求瀑布
鉴于之前已经在提前发送中把页面分成了两个块,因此加入第三个块做延迟数据就相对简单了。我们把延迟数据块放在了所有可见的内容之后,让它不阻塞渲染。同时我们让网络请求放在服务端发起,并把响应以流式写入到延迟数据块中。拆分后的三个块如下所示:
提前写入的块
<html>
<head>
<link rel=”preload” as=”script” href=… />
<link rel=”stylesheet” href=… />
<! — lots of other <meta> and other tags… →
Body 块
<! — <head> tags that depend on data go here →
</head>
<body>
<! — Body content here →
<script src=… />
延迟数据块
<script type=”application/json” >
<!-- data -->
</script>
</body>
</html>
在服务器端实现完成后,剩下的任务就是编写 JavaScript 来检测延迟数据块何时到达。我们使用了MutationObserver 来高效的监控 DOM 的变化。一旦检测到了延迟数据的 JSON 元素,就立马解析结果并插入到应用程序的网络数据存储中。从应用程序的角度来看,这就像完成了一次正常的网络请求。
小心 defer
你可能已经注意到,有些标签的顺序和提前发送的样例中不太一样。<script> 从提前发送的块移动到了Body 块,而且不再具有 defer 属性,这个属性通过推迟脚本执行来避免阻塞渲染。但当使用延迟数据时,这并不是最优做法。因为在 body 块结束时,所有的可见内容都已经接收完毕,无需担心阻塞渲染了。我们可以通过把 <script> 移到 Body 块的末尾,并移除 defer 属性来解决这个问题。虽然把 <stript> 移到文档的末尾会引发一个网络请求瀑布,但我们可以通过在提前发送块中加入 preload 标签来解决这个问题。
实现中的挑战
状态码和 Header
提前发送阻止了后续对 Header 的更改(例如重定向或更改状态码)。在 React + NodeJS 的世界中,常见的做法是在数据获取后再由 React 应用来处理重定向和抛出错误。但如果你已经提前发送了 <head> 标签和 200 OK 状态,这个方法就不再可行了。
我们通过把错误和重定向处理逻辑从 React 应用中挪出来来解决这个问题。目前这些逻辑会在尝试提前发送之前, 在 Express 服务器中间件中被执行。
缓冲
我们发现 nginx 会默认对响应进行缓冲,这对提高资源利用率是有益的。但是当目标是发送增量响应的时候就不适用了,因此我们需要在服务中禁用缓冲。我们原本预计这个改动可能会增加服务器所需的资源,但实际上发现影响微乎其微,可以忽略不计。
响应延迟
我们注意到,提前发送的响应大约多了 200 毫秒的意外延迟,并且禁用 gzip 压缩后,这个延迟就消失了。这其实是 Nagle 算法和 TCP 延迟确认之间相互作用的结果。这些优化尝试让每个数据包发送的数据最大化,但当发送少量数据时就会产生额外延迟。尤其是当你使用巨型帧(这会增加最大数据包大小)时,会更容易遇到这个问题,因为 gzip 把写入的数据大小降低到了一个无法填满一个数据包的程度。最终的解决的方案是在 haproxy 负载均衡器中禁用了 Nagle 算法。
结论
HTTP 流式传输在提升爱彼迎网页性能方面取得了巨大的成功。实验显示,提前发送在所有测试过的页面上都让首次内容绘制(FCP)的时间平均减少了大约 100 毫秒,这其中包扩了爱彼迎的首页。流式传输数据则进一步消除了由于后端查询慢导致的 FCP 延迟问题。虽然在这个过程中遇到了一些挑战,但我们发现将现有的 React 应用程序支持流式传输是非常可行且稳健的,尽管它最初并未以此为设计目标。我们也很高兴看到更多的前端生态系统在优先考虑流式传输的方向发展,从 GraphQL 中的 @defer 和 @stream 到 Next.js 中的流式服务端渲染。无论你是正在使用这些新技术,还是在扩展现有的代码库,我们都希望你尝试使用流式传输,为所有人打造一个更快的前端!
作者:Victor Lin
译者:Keyao Yang
感谢所有项目参与者的努力,合作与支持!
如果你想了解关于爱彼迎技术的更多进展,欢迎关注我们的 Github 账号(https://github.com/airbnb/) 以及微信公众号(爱彼迎技术团队)