为什么 JavaScript 框架中的高效 Hydration 如此具有挑战性

2025-06-04

为什么 JavaScript 框架中的高效 Hydration 如此具有挑战性

Hydration 是 JavaScript 框架中页面在服务器渲染后,在浏览器中初始化页面的过程。虽然服务器可以生成初始 HTML,但我们需要使用事件处理程序来增强此输出,并初始化应用程序状态,使其能够在浏览器中交互。

在大多数框架中,这个过程在页面首次加载时会带来相当大的开销。根据 JavaScript 加载和 hydration 完成所需的时间,我们可能会呈现一个看似可交互但实际上并非如此的页面。这会严重影响用户体验,尤其是在低端设备和较慢的网络上。

你可能会认为有很多方法可以解决这个问题。确实有,但没有一个是万无一失的。多年来,库的作者们一直在围绕这个问题不断改进技术。所以今天,我想让我们专门讨论一下水合这个话题,以便更好地理解我们正在处理的问题。


揭穿服务器渲染是万灵药的真相

图片描述

你已经将你最喜欢的客户端渲染 JavaScript 框架迁移到了服务器端渲染。更好的 SEO,更佳的性能,两全其美。

...不,我们就此打住。

这是一个常见的误解。仅仅使用服务器渲染你的 SPA 并不能一下子解决所有问题。事实上,你很可能增加了 JavaScript 负载,并且你的应用程序可能需要比单纯使用客户端渲染时更长的时间才能实现交互。

什么?!...

我不是在跟你开玩笑。大多数框架的 Hydration Ready 代码都比它们典型的客户端代码要大,因为最终它们需要同时完成这两件事。它可能一开始只负责 Hydration,但由于你的框架允许客户端渲染,所以它也需要相应的代码来实现 Hydration。

现在,我们不再需要立即交付几乎空白的 HTML 页面,以便在加载数据时向用户显示一些反馈,而是需要等待整个页面在服务器上加载并渲染完毕,然后再运行类似于在浏览器中渲染的过程。由于包含了所有 HTML 以及应用程序引导所需的数据,该页面也变得更大。

这并非全是坏事。一般来说,你的主要内容应该能够更快地显示,因为你不必等待浏览器额外往返加载 JavaScript 才能开始工作。但你也延迟了资源的加载,包括应用所需的 JavaScript。

注意:这高度依赖于消费者网络和数据延迟。虽然有很多技术可以解决这种负载性能时序问题,例如流式传输。但我想说明的是,这并非绝对的胜利,我们还需要新的权衡和考量。


根本问题

说到客户端数据融合,有两个缺点非常明显。一是我们在服务器上渲染,现在基本上需要在浏览器中重新渲染一遍才能完成数据融合。二是我们倾向于将所有内容传输两次,一次是 HTML,一次是 JavaScript。

一般情况下,它会以 3 种形式传递:

  1. 模板 - 组件代码/静态模板
  2. 数据——我们用来填充模板的数据
  3. 已实现的视图 - 最终的 HTML

模板视图既存在于捆绑的 JavaScript 中,也存在于呈现的 HTML 中,数据通常以呈现在页面中的脚本标签的形式出现,也部分存在于最终的 HTML 中。

使用客户端渲染时,我们只需发送模板,并请求渲染所需的数据。没有重复。然而,在显示任何内容之前,我们必须依赖网络加载 JavaScript 包。

因此,从服务器获取已实现的 HTML 正是我们获得服务器渲染所有优势的地方。它让我们不必受制于 JavaScript 加载时间的束缚来显示网站。但是,我们如何应对服务器渲染固有的额外开销呢?


静态路由(无 Hydration)

图片描述

示例:RemixSvelteKitSolidStart

我们发现许多 JS SSR 框架都采用了一种想法,即<script>在某些页面上直接移除该标签。这些页面是静态的,不需要 JavaScript。没有 JavaScript 意味着没有额外的网络流量,没有数据序列化,也不需要数据融合。这很成功。

当然,除非你需要 JavaScript。你可以在页面上偷偷地添加一些原生 JavaScript,也许在某些情况下这样做没问题,但这远非理想之选。你实际上是在创建第二个应用层。

不过,这确实是解决这个问题的实用方法。但实际上,一旦你添加了动态内容,并且想要利用框架,你就得把所有东西都拉进来。这种方法我们几乎在所有现有的 SSR 解决方案中都能做到,但它也不太灵活。这是一个很酷的技巧,但它并不能真正解决大多数问题。


延迟加载 JavaScript(渐进式水合)

请提供图片!Crystallize 漫画

例如:Astro与Islands结合

这种方法就是我所说的“渐进式”或“延迟式”水合。这并不是说我们不加载 JavaScript,只是不会立即加载。让我们在交互时加载它,无论是点击、悬停还是滚动到视图中。这样做的额外好处是,如果我们从未与页面的某个部分进行交互,我们可能甚至永远不会发送该 JavaScript。但有一个问题。

大多数 JavaScript 框架都需要自上而下地进行数据加载。React 和 Svelte 都是如此。因此,如果您的应用包含一个公共根(例如单页应用),我们就需要加载它。除非我们的渲染树非常浅,否则您可能会发现,当您点击屏幕中间的按钮时,无论如何都需要加载并加载大量代码。将开销推迟到用户执行操作实际上并没有什么好处。这可能更糟,因为现在您肯定会让他们等待。但您的网站会获得更高的 Lighthouse 评分。

所以这或许对那些树宽而浅的应用有利,但这在现代单页应用 (SPA) 中并不常见。我们围绕客户端路由、上下文提供程序和边界组件(Suspense、Error 或其他组件)的模式,让我们能够构建更深层次的东西。

仅靠这种方法也无法避免序列化所有可能用到的数据。我们不知道最终会加载什么,所以所有数据都需要可用。


从 HTML 中提取数据

图片描述

示例:Prism 编译器

人们通常会立即想到的另一个想法是,也许我可以从渲染后的 HTML 中逆向工程我的状态。与其发送一个庞大的 JSON 对象,不如根据 HTML 中插入的值来初始化状态。表面上看,这个想法还不错。但挑战在于,模型和视图并不总是一对一的。

如果您已经导出了数据,尝试返回原始数据并重新导出,在很多情况下是不可能的。例如,如果您在 HTML 中显示格式化的时间戳,您可能没有对秒进行编码,但如果另一个 UI 选项允许您更改为已编码的格式,该怎么办?

不幸的是,这不仅适用于我们初始化的状态,也适用于来自数据库和 API 的数据。而且通常情况并非像不将整个数据序列化到页面中那么简单。记住,大多数 Hydration 会在浏览器自上而下初始化时再次运行应用程序。如果您不发送数据并使用数据设置某种客户端缓存,同构数据获取服务通常会在此时尝试在浏览器中重新获取数据。


岛屿(部分水合)

图片描述

例如:MarkoAstro

想象一下,一个网页主要由静态 HTML 组成,无需在浏览器中重新渲染或“水合”(hydrated)。其中,只有少数几个用户可以交互的地方,我们称之为“岛”。这种方法通常被称为“部分水合”,因为我们只需要“水合”这些“岛”,而不需要发送页面上其他内容的 JavaScript。

对于这样架构的应用,我们只需要将输入或 props 序列化到顶层组件,因为我们知道它们之上没有任何状态。我们 100% 确定它永远不会重新渲染。岛外的内容无法更改。因此,我们只需不发送不使用的数据,就能解决很多重复数据问题。如果它不是顶层输入,那么浏览器就不可能需要它。

但是我们应该在哪里设定边界呢?在组件层面设定边界是合理的,因为这是我们人类能够理解的。但是,岛屿越细粒度,其效率就越高。当岛屿下的任何内容都可以重新渲染时,您需要将该代码发送到浏览器。

一种解决方案是开发一个足够智能的编译器,能够在子组件级别确定状态。这样,不仅可以从我们的树中修剪静态分支,甚至还可以修剪嵌套在有状态组件下的分支。但是,这样的编译器需要一种专门的领域特定语言 (DSL),以便能够以跨模块的方式进行分析。

更重要的是,“孤岛”意味着导航时每个页面都会由服务器渲染。这种多页面 (MPA) 方法是 Web 的经典工作方式。但它意味着导航时无需客户端转换,也不会丢失客户端状态。实际上,“部分水合”是我们上面提到的“静态路由”的改进版本。您只需为使用的功能付费。


水合失序

图片描述

示例:Qwik

如果说部分数据同步是静态路由的升级版,那么无序数据同步则是延迟加载的改进版。如果我们不受典型的自上而下的渲染框架的限制,数据同步会怎么样呢?它可以让页面中段的按钮独立地进行数据同步,而无需你加载其上层页面上的一堆客户端路由和状态管理器。

这有一个相当严格的限制。要使其正常工作,组件必须具备初始运行所需的一切,而无需依赖其父级。但是组件与其父级有直接的关系,这通过其输入或“props”来表达。

一种解决方案是通过依赖注入来获取各个组件中的所有输入。父子组件之间无需直接通信。并且在服务器渲染时,所有组件的输入都可以序列化(当然,也进行了重复数据删除)。

但这也适用于传递到我们组件中的子组件。它们需要提前完全渲染。现有的框架不以这种方式工作,原因很充分。惰性求值使子组件能够控制如何以及何时插入。几乎所有曾经使用即时求值 (eager) 的框架现在都改为使用惰性求值。

这使得这种方法的开发体验截然不同,因为我们习惯的父子交互规则需要精心编排和限制。而且,与延迟加载类似,这种方法并不能避免数据重复,因为虽然它可以相当精细地进行数据填充,但它并不知道哪些组件实际上需要发送到浏览器。


服务器组件

图片描述

示例:React 服务器组件

如果你可以采用 Partial Hydration,然后在服务器上重新渲染静态部分,会怎么样?如果这样做,你就有了服务器组件。你可以获得 Partial Hydration 的许多好处,例如减少组件代码大小和删除重复数据,同时又不必放弃在导航时维护客户端状态。

挑战在于,要在服务器上重新渲染静态部分,您需要专门的数据格式,以便能够与现有 HTML 进行差异化。您还需要在初始渲染时维护正常的服务器 HTML 渲染。这意味着构建步骤更加复杂,并且服务器组件和客户端组件之间的编译和打包方式也有所不同。

更重要的是,即使你消除了增量开销,你也需要在浏览器中拥有更大的运行时才能实现这一点。因此,除非你拥有更大的网站和应用,否则该系统的复杂性可能无法抵消成本。但当你达到这个门槛时,感觉就像天空才是极限。这或许不是最大化初始页面加载速度的最佳方法,但却是一种独特的方法,可以保留 SPA 的优势,而无需将你的网站扩展到无限的 JavaScript 代码。


结论

这是一个持续发展的领域,新技术层出不穷。事实上,最好的解决方案或许是多种技术的结合。

如果我们采用一个能够自动生成子组件岛、能够无序水合、并且支持服务器组件的编译器会怎么样?这样我们就拥有了最好的一切,对吧?

或者,权衡可能过于极端,与人们的思维模式不符。或者,解决方案的复杂性可能过于极端。

实现这一目标有很多途径。希望现在你对过去几年为解决现代 JavaScript 最具挑战性的问题之一而开展的工作有了更深入的了解。

文章来源:https://dev.to/this-is-learning/why-efficient-Hydration-in-javascript-frameworks-is-so-challenging-1ca3
PREV
寻找你的第一个开发角色:一位年轻开发人员给年轻开发人员的建议
NEXT
如何使用 Next.js 和 Tailwind CSS 设置 Storybook