流式 HTML 的怪异而晦涩的艺术

2025-05-28

流式 HTML 的怪异而晦涩的艺术

我上次的目标是:在我们电子商务网站最快版本的演示中重用我们现有的 API......并将其保持在 20 千字节以下。

我认为这需要一个 MPA。(又名传统的 Web 应用程序。站点。Thang。非 SPA。无论如何。)

正是这个决定,注定了这个网站会变得缓慢而笨重

理论上,MPA 交互没必要像通常那样慢。但在实践中,原因有很多

举个例子。在 Kroger,产品搜索分为两个步骤:

  1. 将用户查询发送到 API 以获取匹配的产品代码
  2. 将这些产品代码发送到 API 以获取产品名称、价格等。

使用这些 API 生成搜索结果页面将如下所示:



const resultsData = fetch(`/api/search?${new URLSearchParams({
    query: usersSearchString
  })}`)
  .then(r => r.json())
  .then(({ upcs }) =>
    fetch(`/api/products/details?${new URLSearchParams({ upcs })}`
  )
  .then(r => r.json())

res.writeHead(resultsData.success ? 200 : 500, {
  'content-type': 'text/html;charset=utf-8'
})

const htmlResponse = searchPageTemplate.render({
    searchedQuery: usersSearchString,
    results: resultsData
  })

res.write(htmlResponse)
res.end()


Enter fullscreen mode Exit fullscreen mode

每次获取数据都需要时间,而且/api/products/details只有在完成 /api/search才会发生。此外,这些请求会从我的电脑传输到数据中心,然后再返回。真正的服务器会与其他服务器并排运行,以便快速处理请求

但在我的演示机器上,调用通常需要大约 200 毫秒,有时甚至会高达 800 毫秒。再加上目标 3G 网络、服务器处理时间以及其他一些不便的现实因素,我经常超出“自然连续地执行任务”所设定的 100 到 1000 毫秒的限制

所以问题在于第一个字节的时间太长,是吗?

不用担心!TTFB 时间过长是众所周知的性能问题元凶。浏览器开发者工具、Lighthouse 和其他测速工具都会对此发出警告,所以有很多修复建议!

除此之外,以下这些关于改进 TTFB 的建议都无济于事:

优化服务器应用程序以更快地准备页面

Node.js 处理请求和发送 HTML 的时间不超过 30 毫秒。这几乎没有什么好处。

优化数据库查询或迁移到更快的数据库系统

我不被允许接触我们的数据库或 API 服务器。

升级服务器硬件以获得更多内存或 CPU

它运行在具有大量未使用 RAM 和 CPU 的 MacBook Pro 上。

缓存昂贵的查找

缓存对最初的请求毫无帮助,除非我预先缓存所有已知端点或其他东西。即便如此,这对搜索来说也无济于事:用户可能会搜索从未见过的字符串。

问题:网络性能是别人的问题

如果仅仅调用两个 API 就让我头疼,那我可就完蛋了。以下是我们主页使用的一些数据源:

  • 身份验证和用户信息
  • 购物车中的商品
  • 选择商店、自取或送货等。
  • 推荐产品
  • 特价产品
  • 之前的购买
  • 赞助产品
  • 特定地点的促销活动
  • 推荐优惠券
  • A/B 测试
  • 订阅 Kroger Boost
  • ……等等。你懂的,有很多——而且这些还只是你能看到的部分。

与许多大公司一样,每个数据源可能由不同的团队负责,并有各自的时间表、SLA 和错误。

一名不悦的男人站在白板前,白板上画着各种形状,上面贴着“神奇宝贝”和“地狱代理”等愚蠢的标签,周围点缀着指向各个方向的箭头。

看过真正的 API 图表后,Krazam 那张讽刺性的微服务图会变得或多或少有点搞笑。我仍在琢磨究竟是哪一种。

假设我列出的 10 个数据源每个都对应一个 API 调用。我的服务器能够快速响应的概率有多大?

假设 1 个客户端请求会向受长尾延迟影响的子系统创建 10 个下游请求。并假设该子系统对单个请求响应缓慢的概率为 1%。那么,这 10 个下游请求中至少有 1 个受长尾延迟影响的概率,等于所有下游请求响应快速的概率(对任何单个请求响应快速的概率为 99%),即:

1 ( 0.99 )10 = 0.095 1 - (0.99)^{10} = 0.095

那是 9.5%!这意味着,单个客户端请求有近 10% 的概率会受到响应缓慢的影响。这相当于预计 100 万个客户端请求中,会有 10 万个客户端请求受到影响。这可是个不小的数目!

谁移动了我的第 99 个百分位延迟?

而且由于用户在 MPA 中访问多个页面,因此遭遇高 TTFB 时间的可能性“有保证”:

Gil 举了一个简单的假设示例:一个典型的用户会话涉及 5 次页面加载,平均每个页面加载 40 个资源。有多少用户不会遇到比 95 百分位更糟糕的情况?0.003%。

你所知道的关于延迟的一切都是错误

我相信这就是 Kroger.com 最初使用 SPA 的原因——即使不同团队的 API 不可信,至少它们不会影响其他团队的代码。(与其他团队组件的类似隔离可能是 React 被业界采用的原因之一。)

解决方案:流式 HTML

展示起来比解释起来更容易:

两个页面的搜索结果显示时间均为 2.5 秒。但体验肯定不一样。

并非所有网站都存在我提到的 API 瓶颈问题,但很多网站都存在类似的问题:数据库查询和文件读取。在数据源完成时显示页面片段对于几乎所有动态网站都很有用。例如……

  • 在可能造成速度缓慢的主要内容之前显示标题
  • 在侧边栏、相关文章、评论和其他非关键信息之前显示主要内容
  • 随着分页或批处理查询的进展,流式传输查询,而不是执行昂贵的大型数据库查询

除了明显的视觉加速之外,流式 HTML 还有其他好处:

尽快互动

如果用户访问主页并立即尝试搜索,他们只需等待标题即可提交查询。

优化资产交付

即使没有<body>要显示的内容,您也可以流式传输<head>。这样一来,浏览器就可以在等待其余 HTML 的同时下载并解析样式、脚本和其他资产。

减少服务器工作量

流式 HTML 占用更少的内存。它无需在 RAM 中构建完整的响应,而是立即发送生成的字节。

比通过 JavaScript 进行增量更新更强大、更快速

更少的往返,发生在 JS 启动之前/启动时,不受 JS 错误和1% 的访问破坏 JavaScript 的其他原因的影响……

而且由于它效率更高,所以我们运行的 JavaScript 会占用更多的 CPU 和 RAM ,更不用说绘画、布局和用户交互了。

但不要相信我的话:

希望您明白我为什么认为 HTML 流是必须的。

这就是为什么 Svelte 不行

之前…

也许如果我在 HTML 中添加足够的 CSS 就会看起来不错...并且如果我还有剩余空间,那么可以为从复杂交互中受益最多的部分添加一些激光聚焦的 JavaScript。

正是Svelte 的优势所在。那我为什么不使用它呢?

因为Svelte 不支持流式传输 HTML。(我希望将来有一天能实现。)

如果不是 Svelte,那是什么?

我在 NPM 上只发现了 2 个可以传输 HTML 的东西:

  1. Dust,一种似乎已经消亡两次的模板语言。
  2. Marko,这个库的名字谷歌都搜不到,还有个彩虹色的 logo……哦,还有类似 JSX 的语法?还有符合我预算的客户端虚拟 DOM?eBay已经把它用在自己的电商网站上测试过了?而且它只用客户端 JS 来实现state完整组件? 你可别说。

当决定自动做出时,感觉很好。

因此,马可。

Marko<await>让流媒体变得简单

Marko 使用其<await>标签来传输 HTML 。令我惊喜的是,它能够如此轻松地优化浏览器渲染,并且能够控制 HTTP、HTML 和 JavaScript。

免责声明
我现在在 eBay 工作,但是当我写这篇文章时我还没有工作。

缓冲页面在加载时不会显示内容,但 Marko 的流页面会逐步显示内容。

资料来源:markojs.com/#streaming

正如在骨架屏幕中所见,但速度很快



<SiteHead />

<h1>Search for “${searchQuery}”</h1>

<div.SearchSkeletons>
  <await(searchResultsFetch)> <!-- stalls the HTML stream until the API returns search results -->
    <@then|result|>
      <for|product| of=result.products>
        <ProductCard product=product />
      </for>
    </@then>
  </await>
</div>


Enter fullscreen mode Exit fullscreen mode

<await>为锦上添花

想象一下一个显示推荐产品的组件。获取推荐通常很快,但偶尔 API 会出现问题<await>



<await(productRecommendations)
    timeout=50> <!-- wait up to 50ms -->
  <@then|recs|>
    <RecommendedProductList of=recs />
  </@then>

  <@catch>
    <!-- don’t render anything; no big deal if this fails -->
  </@catch>
</await>


Enter fullscreen mode Exit fullscreen mode

如果您知道产品推荐能赚多少钱,您就可以进行微调,timeout以使性能损失的成本永远不会超过收入。

但这还不是全部!



<await(productRecommendations) client-reorder>
  <@placeholder>
    <!-- immediately render placeholder to prevent content jumping around -->
    <RecommendedProductsPlaceholder /> 
  </@placeholder>

  <@then|recs|>
    <RecommendedProductList of=recs />
  </@then>
</await>


Enter fullscreen mode Exit fullscreen mode

client-reorder属性将 转换<await>为 HTML 片段,不会延迟其后的页面其余部分,而是在准备就绪后异步渲染。client-reorder需要 JavaScript,因此您可以权衡使用它与没有回退的 的利弊timeout。(我认为您甚至可以将它们结合起来。)

Facebook 的 BigPipe 渲染器就是这样运作的,它曾经与 React 并驾齐驱。如果能兼得两者之长,岂不妙哉?

让我告诉你:这很好

Marko<await>很棒

最重要的是,这些<await>技术是 Marko 的黄金法则——哎呀,这正是它存在的意义所在。Marko 拥有其他渲染器无法轻松实现的流控制、使用 JavaScript 自动升级流式 HTML 的方法,以及 8 年以上处理不可避免的 bug 和边缘情况的经验。

(是的,我对马可很着迷。让我玩得开心点。)

然而,马尔科显然是我唯一的选择,这一事实确实引发了一个问题……

为什么 HTML 流不常见?

或者用我的演示之后另一位开发人员的话来说:“如果 ChunkedTransfer-Encoding如此有用,我怎么从未听说过它?”

这个问题问得挺有道理的。这并不是因为它支持得不好——HTML在 Netscape 1.0 (Beta Netscape 1.0)中是渐进式渲染的。也不是因为这项技术很少使用——比如,Google 搜索结果在顶部导航栏之后显示。

我认为其中一个原因是名称不一致

  • 史蒂夫·索德斯 (Steve Souders) 称之为“早期冲洗”,这并不是最好的名字。
  • “Chunked transfer-encoding” 是最独特的,但它只存在于 HTTP/1.1 中。HTTP/2、HTTP/3,甚至HTTP/0.9 的流式传输方式都不同。
  • 在 HLS、DASH 和其他形式的 HTTP 视频占据这一领域之前,它被称为“HTTP 流”。
  • 包罗万象的术语是“渐进式渲染”,但它适用于许多其他事物:隔行图像、可视化大型数据集、视频游戏引擎优化等。

许多语言/框架并不关心流

一些老的语言/框架早已能够处理 HTML 流,但效果并不。以下是一些例子:

PHP🐘

需要以严格的顺序调用难以理解的输出缓冲函数。

Ruby on Rails

ActionController::Streaming有很多注意事项。特别是:

这种方法是在 Rails 3.1 中引入的,并且仍在不断改进。一些 Rack 中间件可能无法正常工作,因此在进行流式传输时需要格外小心。这些问题很快就会得到解决。

Rails 在 2011 年达到了 3.1 版本。显然对于解决这些问题的需求并不大。

(Rails 的现代方式是Turbo Streams,但它们需要 JS 来渲染,所以不是一回事。)

Django🐍

Django 确实不喜欢流式传输

StreamingHttpResponse仅应在将数据传输到客户端之前绝对不需要迭代整个内容的情况下使用。
Perl🐪

$|Perl 的自动流行为由一个变量(没错,就是一个管道)控制,但这种胡闹对它来说很正常。天哪,我爱 Perl。

由于流式渲染从来都不是编程语言/框架的默认选择,因此它们将其视为最后的手段,以牺牲“真正的”渲染功能为代价来提升性能。以下是一段很有说服力的引言:

Response.Write您仍然可以使用和编写能够正确将数据流传输到浏览器的 ASP.NET 页面Response.Flush。但您无法在正常的 ASP.NET 页面生命周期内执行此操作。这或许是 ASP.NET 抽象层的自然结果。

无论如何,这对用户来说仍然很糟糕。

渐进式 HTML 渲染的失传艺术

Node.js 是个例外,这真是令人欣喜。正如Node 在其“关于”页面上自豪地描述的那样

HTTP 是 Node.js 中的一等公民,设计时考虑了流式传输和低延迟。

尽管如此,“新”热门 JavaScript 框架在流媒体方面仍面临一段时间的困难:

这些框架拥有充足的人员、资金和激励机制来支持流媒体,所以阻碍一定是其他原因造成的。或许很难将流媒体功能改造到它们的抽象框架上,尤其是在不破坏现有的第三方集成的情况下。

流媒体很少被提及作为 TTFB 修复

正如开头提到的,当检测到高 TTFB 时,几乎从不建议使用流式传输作为修复方法。

我认为这是最大的问题。一个名字不好听的 Web API,如果被提及得足够多,也可能变得流行起来。

就我个人而言,我只见过一次推荐在 TTFB 中使用流式 HTML ,是在《高性能浏览器网络》的第十章。在文末附注处。

(里面标有“小心豹子”<details>字样。)

所以这是银弹

我有流式HTML,但这替代不了其他999个支持它的“铅弹”。现在我必须……做网站。

你知道,编写组件、设计样式、构建功能。能有多难?(提示:有人是拿钱来做这些事的。)

文章来源:https://dev.to/tigt/the-weirdly-obscure-art-of-streamed-html-4gc2
PREV
使用 lxml 和 Python 进行 Web 抓取的简介
NEXT
路由:我不够聪明,无法实现 SPA