跳至主要內容

Hydration is Pure Overhead

njr工程化frontendSSR大约 16 分钟约 4844 字

Hydration 是一种为服务器渲染的 HTML 添加交互性的解决方案。

在网页开发中,水合或再水合是一种技术,客户端 JavaScript 通过在 HTML 元素上附加事件处理程序,将通过静态托管或服务器端渲染提供的静态 HTML 网页转换为动态网页。

上述定义是从将事件处理程序附加到静态 HTML 的角度来讨论水合的。然而,将事件处理程序附加到 DOM 并不是水合过程中具有挑战性或成本高昂的部分,因此这就忽略了为什么有人会将水合称为开销。在本文中,「开销」是指可以避免的工作,而且最终结果仍然相同。如果它可以被移除,而结果是一样的,那它就是开销。

img
img

深入了解水合

水合最困难的部分是需要 WHAT 事件处理器以及将它附加到 WHERE

  • WHAT:事件处理程序是一个包含事件处理程序行为的闭包,它是用户触发该事件时应该发生的事情。
  • WHERE:事件处理程序需要附加到的 DOM 元素的位置(包括事件类型)。

WHAT 是一个闭包,包含了 APP_STATEFRAMEWORK_STATE

  • APP_STATE:应用程序的状态。APP_STATE 是大多数人认为的状态。如果没有 APP_STATE,应用程序就没有任何动态信息可以向用户展示。
  • FRAMEWORK_STATE:框架的内部状态。没有 FRAMEWORK_STATE,框架就不知道要更新哪些 DOM 节点,也不知道框架应在何时更新这些节点。例如组件树和对渲染函数的引用。

那么我们如何恢复 WHATAPP_STATE + FRAMEWORK_STATE)和 WHERE 呢?通过下载并执行当前 HTML 中的组件。然而下载和执行 HTML 中的渲染组件是最昂贵的部分。

换句话说,「水合」是一种通过在浏览器中急切执行应用程序代码来恢复 APP_STATEFRAMEWORK_STATE 的方法:

  1. 下载组件代码
  2. 执行组件代码
  3. 恢复 WHATAPP_STATEFRAMEWORK_STATE)和 WHERE 以获取事件处理程序闭包
  4. WHAT(事件处理程序闭包)附加到 WHERE(一个 DOM 元素)
img
img

前三个步骤称为「恢复」阶段,该阶段尝试重建应用程序,需要下载并执行应用程序代码,因此重建成本很高。

Recovery 与水合页面的复杂程度成正比,在移动设备上很可能需要 10 秒钟。由于 RECOVERY 是昂贵的部分,因此大多数应用程序的启动性能都不理想,尤其是在移动设备上。

RECOVERY 也是纯粹的开销。开销是指不直接提供价值的工作。就水合而言,RECOVERY 是一种开销,因为它会重建服务器在 SSR/SSG 中已经收集到的信息。这些信息没有发送到客户端,而是被丢弃了。因此,客户端必须执行昂贵的 RECOVERY 来重建服务器已经拥有的信息。如果服务器将信息序列化,并连同 HTML 一起发送给客户端,就可以避免 RECOVERY。序列化信息将使客户端不必急于下载和执行 HTML 中的所有组件。

作为 SSR/SSG 的一部分,服务器已经在客户端重新执行了代码,这使得水合成为纯粹的开销:也就是说,客户端重复了服务器已经完成的工作。框架本可以通过将信息从服务器传输到客户端来避免这一开销,但它却将信息丢弃了。

总之,水合是通过下载并重新执行 SSR/SSG 渲染的 HTML 中的所有组件来恢复事件处理程序。网站会两次发送到客户端,一次是 HTML,另一次是 JavaScript。此外,框架还必须急切地执行 JavaScript 以恢复 WHATWHEREAPP_STATEFRAMEWORK_STATE。所有这些工作都只是为了检索服务器已经拥有但丢弃的内容!

为了理解为什么水合会迫使客户端重复工作,让我们来看一个包含几个简单组件的示例。

我们将使用一种很多人都能理解的常用语法,但请记住,这是一个普遍问题,并不是某个框架所特有的。

export const Main = () => <>
   <Greeter />
   <Counter value={10}/>
</>

export const Greeter = () => {
  return (
    <button onClick={() => alert('Hello World!'))}>
      Greet
    </button>
  )
}

export const Counter = (props: { value: number }) => {
  const store = useStore({ count: props.number || 0 });
  return (
    <button onClick={() => store.count++)}>
      {count}
    </button>
  )
}

在经过 SSR/SSG 后会生成一下 HTML:

<button>Greet</button>
<button>10</button>

HTML 中没有说明事件处理程序或组件边界的位置。生成的 HTML 不包含 WHATAPP_STATEFRAMEWORK_STATE)或 WHERE。这些信息在服务器生成 HTML 时就已存在,但服务器并未将其序列化。要使应用程序具有交互性,客户端唯一能做的就是通过下载并执行代码来恢复这些信息。我们这样做是为了恢复关闭状态的事件处理程序闭包。

这里的重点是,在附加任何事件处理程序并处理事件之前,必须下载并执行代码。代码执行会实例化组件并重新创建状态(WHAT(APP_STATE, FRAMEWORK_STATE) 和 WHERE)。

水合完成后,应用程序即可运行。点击按钮将按预期更新用户界面。

Resumability:无须耗费的水合作用

那么,如何设计一个没有水合作用的系统,从而避免开销呢?

要消除开销,框架不仅要避免「恢复」(RECOVERY),还要避免上述第四步。第四步是将 WHAT 附加到 WHERE,这是可以避免的成本。

要避免这种成本,你需要三样东西:

  1. 将所有需要的信息序列化,作为 HTML 的一部分。序列化信息需要包括 WHATWHEREAPP_STATEFRAMEWORK_STATE
  2. 依靠事件冒泡拦截所有事件的全局事件处理程序。该事件处理程序必须是全局性的,这样我们就不必急于在特定 DOM 元素上单独注册所有事件。
  3. 一个工厂函数,可以轻松恢复事件处理程序(WHAT)。
img
img

关键在于工厂函数,水合急切地创建 WHAT,是因为它需要 WHAT 将其附加到 WHERE。相反,我们可以延迟创建 WHAT 来响应用户事件,从而避免做不必要的工作。

上述设置是可恢复的,因为它可以在服务器中断的地方继续执行,而不会重做服务器已经完成的任何工作。更重要的是,该设置没有任何开销,因为所有工作都是必要的,没有任何工作是在重做服务器已经做过的工作。

了解推式系统和拉式系统的区别有一个很好的方法。

  • 推式(Hydration):急切地下载和执行代码,急切地注册事件处理程序,以防用户交互。
  • 拉(Resumability):什么都不做,等待用户触发事件,然后惰性创建处理程序来处理事件。

在 Hydration 中,事件处理程序的创建是在事件触发之前进行的,因此比较急迫。水合还要求创建和注册所有可能的事件处理程序,以防用户触发事件(可能是不必要的工作)。因此,事件处理程序的创建是投机性的。这是可能不需要的额外工作。(事件处理程序也是通过重做服务器已经完成的工作来创建的,因此也是开销)。

在 Resumability 系统中,事件处理程序的创建是懒惰的。因此,事件处理程序是在事件触发后创建的,而且严格按需创建。框架通过反序列化来创建事件处理程序,因此客户端不会重做服务器已经完成的任何工作。

Qwik 的工作方式就是惰性地创建事件处理程序,这样就能加快应用程序的启动时间。

可恢复性要求我们序列化 WHAT(APP_STATE, FRAMEWORK_STATE) 和 WHERE。可重续系统可生成以下 HTML 作为存储 WHAT(APP_STATE, FRAMEWORK_STATE) 和 WHERE 的可能解决方案。具体细节并不重要,重要的是所有信息都已存在。

<div q:host>
  <div q:host>
    <button on:click="./chunk-a.js#greet">Greet</button>
  </div>
  <div q:host>
    <button q:obj="1" on:click="./chunk-b.js#count[0]">10</button>
  </div>
</div>
<script>/* code that sets up global listeners */</script>
<script type="text/qwik">/* JSON representing APP_STATE, FRAMEWORK_STATE */</script>

当浏览器加载上述 HTML 代码时,它会立即执行内联脚本,以设置全局监听器。应用程序已准备好接受事件,但浏览器尚未执行任何应用程序代码。这是最接近零 JS 的做法。

HTML 包含作为元素属性编码的 WHERE。当用户触发一个事件时,框架可以使用 DOM 中的信息惰性地创建事件处理程序。创建过程包括对 APP_STATEFRAMEWORK_STATE 进行懒惰反序列化,以完成 WHAT。一旦该框架惰性地创建了事件处理程序,事件处理程序就可以处理事件了。请注意,客户端不会重做服务器已经完成的任何工作。

img
img

内存使用说明

DOM 元素在其生命周期内会保留事件处理程序。Hydration 会急切地创建所有监听器。因此,水合需要在启动时分配内存。

可恢复框架在事件触发后才会创建事件处理程序。因此,可恢复框架消耗的内存将少于水合框架。此外,可恢复方法不会在执行后保留事件处理程序。事件处理程序在执行后会被释放,并返回内存。

在某种程度上,释放内存与水合正好相反。这就好比框架惰性地为特定 WHAT 补充水分,执行后再将其脱水。处理程序的第一次执行和第 n 次执行并无太大区别。事件处理程序的惰性创建和释放不符合水合心理模型。

结论

水合是一种开销,因为它会重复工作。服务器会建立 WHEREWHATAPP_STATEFRAMEWORK_STATE),但这些信息会被丢弃,而不是序列化给客户端。这样,客户端就会收到没有足够信息来重建应用程序的 HTML。信息的缺乏迫使客户端急于下载应用程序并执行它,以恢复 WHEREWHATAPP_STATEFRAMEWORK_STATE)。

另一种方法是可恢复性。可重复性侧重于将所有信息从服务器传输到客户端。这些信息包括 WHEREWHATAPP_STATEFRAMEWORK_STATE)。附加信息允许客户端在不急于下载应用程序代码的情况下对应用程序进行推理。只有用户交互会迫使客户端下载代码来处理特定的交互。客户端不会重复服务器的任何工作,因此不会产生开销。

为了将这一想法付诸实践,我们创建了 Qwik,这是一个围绕可恢复性设计的框架,具有出色的启动性能。我们也很高兴听到您的意见!让我们继续交流,共同为用户打造更快的网络应用程序。

Q&A

  1. 为什么要创造一个新名词?

    可重复性并没有一个明确的界限来表明组件是否水合。如果你坚持说 Qwik 水合,那么你有两个选择:

    当全局事件处理程序被注册时,Qwik 应用程序就水合了。这感觉不对,因为没有下载应用程序代码,也没有执行任何工作。
    当第一次交互解析序列化状态时,Qwik 应用程序就完成了水合。水合是为交互附加事件监听器。反序列化状态是为了恢复应用程序的状态,与注册事件处理程序无关。在某些情况下,不需要进行反序列化,如果需要,也是在事件触发后进行。另一个问题是,对状态的反序列化甚至会恢复那些尚未下载或永远不会下载的组件的状态。因此,虽然我们很想假设这就是水合的意义所在,但我们认为这只是应用程序状态的懒惰反序列化,因为它与事件处理没有直接关系。
    这两种方案都不能令人满意,因此我们创造了一个新名词。您可以将水合定义为使应用程序具有交互性,但这样的定义过于宽泛,以至于适用于所有人,从而降低了它的价值。因此,虽然听起来像是在分化,但我们还是喜欢谈论水合性与可恢复性,因为我们相信它能更好地捕捉到使应用程序具有交互性所需的大量工作之间的巨大差异。

  2. 恢复能力仅仅是事后补充水分吗?

    这当然是一个有效的方法。不过,这两者之间有一个很大的区别。可重复性并不要求框架下载并执行组件来了解组件层次结构。可重复性要求将框架的所有信息序列化到 HTML 中,包括

    • 事件侦听器和事件类型的位置。
    • 下载事件的位置
    • 组件边界
    • 组件道具
    • 投影/子组件
    • 必要时下载组件重新渲染功能的位置

    由于框架会反序列化所有这些信息,并在服务器中断的地方继续执行,因此「可恢复」(resumable)是一个更好的词。

  3. 实际效果如何?我在哪里可以看到使用可恢复战略的网站?

    Builder.ioopen in new window 使用 resumable 策略(和 Qwik)重做了我们的网站。我们在启动过程中删除了 99% 的 JavaScript,由此产生的应用程序即使在移动设备上也感觉非常灵敏。通过使用 Qwik 和 Partytown,我们减少了网站中 99% 的 JavaScript,并获得了 100/100 的 PageSpeed 分数。(您仍然可以访问使用 hydration [PageSpeed 50/100]的旧页面,并与使用 resumability [PageSpeed 100/100]的新页面进行比较,亲自体验性能差异)。

    Qwik 的文档是在 Qwik 上运行的。你可以通过在浏览器中打开开发工具(隐身)来查看引擎盖下的情况,并注意到启动时没有使用 Javascript。(该页面还使用 Partytown 将第三方分析移至 Web Worker)。

    最后,看看在 Cloudflare edge 上运行的待办事项应用程序演示。该页面可在 50 毫秒内完成交互!

  4. 我的框架知道如何进行渐进式和/或懒惰式补水。这是一码事吗?

    不一样,因为渐进式/懒惰式水合仍无法继续执行服务器中断的操作。所有组件代码都需要下载并执行,以恢复和安装事件处理程序。

    有了可恢复性,许多组件将永远不会下载,因为它们永远不会改变。但这些组件可以向子组件传递道具,或创建子组件投射的内容。因此,即使不交互,也需要通过重新执行来恢复组件的状态。这就是为什么支持渐进/快速水合的岛屿不能任意缩小的原因。

    简而言之,与水合相比,可恢复性在处理用户交互时需要下载和执行的代码要少得多。

  5. 我的框架知道如何创建岛屿。这是一码事吗?

    岛屿架构将应用程序分割成多个岛屿。然后,每个岛都可以独立水合。现在,总工作量不再是一次大的水合,而是分散到许多较小的水合事件中。通常情况下,触发器会使岛在启动时懒散地水合,而不是急切地水合。

    这意味着基于孤岛的水合是一种改进,因为它可以将工作分解成更小的块并延迟执行,但这仍然是水合,与可恢复性不同。

  6. 我的框架知道如何序列化状态。它会产生开销吗?

    这里的问题是,state 一词被超载了。是的,有些元框架可以序列化状态。但这里的「状态 」指的是 APP_STATE,而不是 FRAMEWORK_STATE。我不知道有哪个流行的框架(或元框架)可以序列化 FRAMEWORK_STATE。此外,即使 FRAMEWORK_STATE 序列化了,WHAT 和 WHERE 也没有序列化。

    是的,状态(APP_STATE)的序列化很有用,可以避免客户端的大量工作。但它仍然会导致水合。

  7. 组件在首次交互时是否水合?

    如果你查看一下可恢复框架的内部状态,就会发现组件的首次交互与后续交互并无不同。唯一的区别是框架已经解析了序列化状态。一旦状态被解析,它就适用于所有组件,而不仅仅是用户与之交互的组件。在任何时候,框架都可以将状态序列化回 HTML。这是否意味着应用程序不再水合?从这个角度来看,状态的反序列化会使所有组件水合,即使它们的代码尚未被下载。

  8. 今天我如何利用可恢复性的优势?

    框架控制着应用程序交互所使用的恢复策略类型。因此,要利用可恢复性,您的应用程序必须使用支持它的框架之一。目前,我们只知道 Qwik 明确支持可恢复性。可重续性的优势不容忽视,因此我相信未来其他框架也会开始使用这一策略,无论是新框架还是选择迁移到可重续性的现有框架。

  9. 第一次互动会有延迟吗?

    使用预取就不会。Qwik 对预取没有任何主见--我们使用过多种预取策略(急切、可见、分析驱动),效果都很好。在大多数情况下,我们发现在 Web Worker(如 Partytown)中进行预取可实现 0 成本和高速度的最佳平衡。我们将在框架中加入最佳实践,并逐步提供具有推荐模式的示例。

  10. 我可以在 Qwik 中使用我的 React/Angular/Vue/Svelte 组件吗?

    重写应用程序是一项巨大的工程。为了降低入门门槛,我们正在研究与当今一些更流行的框架建立互操作性。Qwik 将成为您的协调器,提供即时的应用程序启动,但它仍将与大部分现有的代码投资一起使用。可以将 Qwik 视为您当前应用程序的协调器。这样,您就不必重写整个应用程序,但仍能获得一些好处。这项工作还在进行中,敬请期待。