这都快React19了,快来学习一下服务端渲染吧!🦧🦧🦧

前端工程师的工作范畴其实不仅仅局限在客户端浏览器,特别是在处理性能优化问题时,往往需要站在全栈的角度去审视系统的每个细节。

随着前端技术的演进,Vue、React 等现代前端框架的出现,不但让大型项目的开发越来越简单高效,而且其合理的代码组织结构也让项目的维护成本显著降低。

如果深入去探讨这些现代前端框架的首屏渲染过程,你会发现其优势的背后隐藏着一个明显的性能缺陷。在本章的内容中我们首先对此缺陷的产生原理进行分析,然后引出相应的优化方案:服务端渲染,也就是我们常说的 SSR。

页面渲染的发展

在早些年还没有 Vue 和 React 这些前端框架的时候,做网站开发的主要技术栈基本就是 JSP 和 PHP,而渲染所需的 HTML 页面都是现在服务器端进行动态的数据填充,然后当客户端向服务端发出的请求后,客户端将响应收到的 HTML 文件直接在浏览器端渲染出来。

随着前端复杂度的增加与技术发展的迭代,若将所有逻辑都放在后端处理,则其开发效率和交互性能都会受到限制,所以这样的方式便被逐渐淘汰掉了。

现代前端框架出现后,基于 MVVM 及组件化的开发模式逐渐取代了原有的 MVC 的开发模式,这使得代码开发效率得到提高并且代码维护成本大幅度降低,于是前端工程师的关注点可以更多地放在业务需求的实现上,用户与页面的更改由框架以数据驱动的方式去完成。

除此之外,框架还提供了许多额外的便利,比如虚拟 DOM、前端路由、组件化等特性,这些特性的带来便利的背后也隐藏着一个明显的问题,就是基于框架开发出的业务代码依赖于框架,运行业务代码之前,首先需要等待框架的代码加载完成,接着执行框架将业务代码编译成索要展示的 HTML 文件后,才能进行页面渲染。

框架包含的特性越多,其代码包尺寸就会越大,这无疑会增加打开网站到渲染出页面之间的等待特性,如果所有前端页面都依赖于框架代码,那么等待期间页面的网站页面便会一直处于空白状态,这样的首屏用户体验是非常糟糕的。

这不就是和我们经常使用的编程语言一样,越靠近用户端的语言在执行效率以及性能上,都明显不急底层语言,但对开发者来说确是非常友好的,让开发者能够更好地编写出复杂的业务逻辑。所以在面对高性能和易维护扩展两方面,就需要做一个权衡取舍,而好的优化方案通常都是兼顾折中的。

前端在面对页面渲染性能与现代代码开发方式时,也需要进行类似的权衡,我们不可能仅为了更快的页面渲染就退回去 JSP/PHP 的开发方式。那样虽然能加快首屏渲染,但与现代前端框架相比,其不仅开发效率低而且代码维护成本高。

因此我们应该去思考,如何在现代前端框架内部去有效地改善首屏渲染,既兼顾性能体验又保证开发效率。

React 的一些优化方案

React 为了在框架层面做优化,经历了一系列的演进和改进。以下是 React 在框架层面做优化的主要里程碑,按照先后顺序进行讲解。

  1. React 16 引入了 Fiber 架构,它重新实现了 React 的核心算法,使得 React 能够更好地处理大型应用和复杂场景,提高了渲染性能和响应速度。

  2. Fiber 架构还引入了调度器和优先级的概念,使得 React 可以根据任务的优先级和类型来动态调度和分配任务,从而更好地控制渲染过程,提高了用户体验。

  3. React 16.6 引入了懒加载和 Suspense 特性,使得组件的按需加载更加方便,可以减少初始加载时间,并且提高了应用的性能。

  4. React 提供了服务器端渲染(SSR)的支持,可以在服务器端渲染 React 组件并将渲染结果直接返回给客户端,以提高首屏加载速度、改善 SEO 和用户体验。

  5. React 18 引入了增量渲染技术,可以先渲染出可见区域的内容,然后在后台继续加载和渲染其他内容,提高了页面的响应速度和用户体验。

React 在框架层面做了许多优化,包括引入 Fiber 架构、Hooks、懒加载和 Suspense、服务器端渲染、增量渲染等,这些优化使得 React 具备了更好的性能、开发体验和扩展性,成为了前端开发中最流行的框架之一。

SSR

说到 SSR,很多人的第一反应是"服务器端渲染",但我更倾向于称之为"同构",所以首先我们来对"客户端渲染","服务器端渲染","同构"这三个概念简单的做一个分析:

  1. 客户端渲染:客户端渲染,页面初始加载的 HTML 页面中无网页展示内容,需要加载执行 JavaScript 文件中的 React 代码,通过 JavaScript 渲染生成页面,同时,JavaScript 代码会完成页面交互事件的绑定,详细流程可参考下图:

  2. 服务器端渲染:服务器端渲染是一种在服务器端生成完整的 HTML 内容的技术。当用户请求页面时,服务器端会执行相应的逻辑和数据获取操作,将数据嵌入到页面模板中生成动态的 HTML 内容,然后将 HTML 内容发送给客户端浏览器。与客户端渲染相比,服务器端渲染的页面在首次加载时通常会更快显示内容,并有利于搜索引擎优化(SEO)。服务器端渲染的页面可以具有丰富的交互功能,但在某些情况下可能需要借助客户端脚本来实现更复杂的交互。

  3. 同构:同构应用(Isomorphic Application)是指能够同时运行于服务器端和客户端的应用程序。它结合了服务器端渲染(SSR)和客户端渲染(CSR)的优点,可以在服务器端生成初始 HTML 内容,并在客户端接管后进行进一步的交互和渲染,从而实现更快的首屏加载和更好的用户体验。

在大多数情况下,客户端渲染完全能够满足我们的业务需求,在前面的内容中我们已经提到过了,客户端渲染的方式可能会影响首屏渲染时间。

使用 SSR 技术的主要因素

我们先来聊聊客户端渲染,也就是 CSR:

  1. CSR 项目的 TTFP(Time To First Page)时间比较长,参考之前的图例,在 CSR 的页面渲染流程中,首先要加载 HTML 文件,之后要下载页面所需的 JavaScript 文件,然后 JavaScript 文件渲染生成页面。在这个渲染过程中至少涉及到两个 HTTP 请求周期,所以会有一定的耗时,这也是为什么大家在低网速下访问普通的 React 或者 Vue 应用时,初始页面会有出现白屏的原因。

  2. CSR 项目的 SEO 能力极弱,在搜索引擎中基本上不可能有好的排名。因为目前大多数搜索引擎主要识别的内容还是 HTML,对 JavaScript 文件内容的识别都还比较弱。如果一个项目的流量入口来自于搜索引擎,这个时候你使用 CSR 进行开发,就非常不合适了。

客户端呈现的主要缺点是,随着应用规模的扩大,所需的 JavaScript 数量往往也会增加,而这可能会对网页的 INP 产生负面影响。随着新的 JavaScript 库、polyfill 和第三方代码的添加,这一问题变得尤为困难,它们会争夺处理能力,通常必须先进行处理,然后页面内容才能呈现。

SSR 的出现主要是为了解决两个问题:首屏加载速度和 SEO(搜索引擎优化)。在 React 中使用 SSR 技术,可以让 React 代码在服务器端先执行一次,生成完整的 HTML 页面内容,用户下载的 HTML 已经包含了所有页面展示内容。这样,用户在浏览器中访问页面时,只需要经历一个 HTTP 请求周期,从而大大缩短了首屏加载时间(TTFP),提升了用户体验。

同时,由于服务器端生成的 HTML 内容已经包含了所有页面内容,有利于搜索引擎对页面内容的抓取和索引,从而提高了页面的 SEO 效果。

之后,我们让 React 代码在客户端再次执行,为 HTML 网页中的内容添加数据及事件的绑定,页面就具备了 React 的各种交互能力。

但是,SSR 这种理念的实现,并非易事。我们来看一下在 React 中实现 SSR 技术的架构图:

这张图展示了服务器端渲染(SSR)的一个典型流程,涉及客户端、Node 服务器和 API 服务器三个主要部分。下面是根据图示的流程的详细描述:

  1. 初始化请求:用户在浏览器中发起访问请求。

  2. 接受请求:Node 服务器接收到来自客户端的请求。

  3. 数据准备:Node 服务器可能需要先行请求 API 服务器获取必要的数据,这些数据用于渲染页面。在这个步骤中,Node 服务器作为客户端,向 API 服务器发起数据请求。

  4. 请求数据:API 服务器接收到来自 Node 服务器的请求。

  5. 返回数据:API 服务器处理 Node 服务器的请求,将请求的数据返回给 Node 服务器。

  6. 渲染页面:Node 服务器使用从 API 服务器获取的数据,结合模板引擎,生成动态的 HTML 内容。在这个步骤中,服务器端渲染的关键优势是页面在到达客户端之前就已经渲染好了,包括页面所需的 bundle.js 文件(包含了客户端应用逻辑的 JavaScript 文件)。

  7. 传输 HTML 和 JS:Node 服务器将渲染好的 HTML 和包括 bundle.js 在内的静态资源发送给客户端。

  8. 接收内容:客户端(用户的浏览器)接收到由 Node 服务器发送的 HTML 和 JavaScript 文件。

  9. 加载 bundle.js:浏览器开始解析 HTML,加载 bundle.js 文件。

  10. 执行 JavaScript:一旦 bundle.js 加载完成,浏览器会执行其中的 JavaScript 代码。这些代码可能会增强页面的交互性,比如附加事件处理器或是修改 DOM。

  11. JavaScript 发起 Ajax 请求:在客户端执行的 JavaScript 可能会根据需要发起 Ajax 请求,以获取额外的数据或功能。

  12. 接收 Ajax 请求:Node 服务器接收到由客户端发送的 Ajax 请求。

  13. 请求数据:对于需要 API 服务器处理的数据请求,Node 服务器会再次作为客户端,向 API 服务器发送请求。

  14. 返回数据:API 服务器处理 Node 服务器的数据请求,并将结果返回给 Node 服务器。

  15. Node 服务器返回 Ajax 请求数据:Node 服务器处理 API 服务器返回的数据,并将相应的数据作为 Ajax 请求的响应返回给客户端。

这个流程体现了 SSR 的主要特点:页面在服务器端被渲染,用户得到的是已经填充了数据的页面。这对 SEO 友好,并且提高了首屏加载性能。用户看到的是完整渲染的页面,然后客户端的 JavaScript 接管后续的交互,这个过程被称为混合渲染或同构渲染。

使用 SSR 这种技术,将使原本简单的 React 项目变得非常复杂,项目的可维护性会降低,代码问题的追溯也会变得困难。

所以,使用 SSR 在解决问题的同时,也会带来非常多的副作用,有的时候,这些副作用的伤害比起 SSR 技术带来的优势要大的多。

SSR 之所以能够实现,本质上是因为虚拟 DOM 的存在

服务端渲染组件为 string,拼接成 html 返回,浏览器渲染出返回的 html,然后执行 hydrate,把渲染和已有的 html 标签关联。

其实服务端渲染就是拼接 html 的过程,组件和元素分别有不同的渲染逻辑:

这里组件的话我们就不讲解了,直接看 string 的。

在上面的代码中,通过 pushStartInstance 函数创建并推入一个新的实例开始标记(如开标签 <div>)到 segment.chunks(这是存储输出的数组)中,并处理元素的属性。同时,它返回子元素,这些子元素可能会在后续的渲染过程中被处理。

通过 pushEndInstance(segment.chunks, type, props);:推入元素的结束标记(如闭标签 </div>)到输出中。

所以,renderHostElement 函数的职责是将一个原生 HTML 元素(及其子元素)渲染为服务器端渲染的一部分,包括处理元素的开始标签、属性、子元素以及结束标签,这样递归渲染一遍,结果就是字符串了。

到这里就是我们的服务端渲染部分了,但是现在只是返回了 HTML 页面,我们再来看看客户端渲染的 hydrate 部分。

在我们之前编写的 React 组件,它首先会被形成一个 VDOM,之后会把 VDOM 转换成 fiber 结构,这个过程会被成为调和阶段 reconcile:

这个处理分为两个阶段: beginWork 和 completeWork。

beginWork 里根据不同的 React Element(vdom)类型,做不同的处理:

在这个阶段,React 会根据当前 Fiber 节点的类型(如类组件、函数组件、宿主组件等)和新旧 props 的差异,执行相应的工作。例如,对于类组件,React 会实例化新组件或调用 componentWillMount / componentWillReceiveProps 生命周期方法(如果适用),然后调用 render 方法生成子节点。

转换完之后就到了 completeWork 的部分,在这里做的事情就是创建元素、添加子元素、更新属性、然后把这个元素放到 fiber.stateNode 属性上。

以下是该函数流程和关键逻辑:

  1. Pop Contexts: 根据 Fiber 节点的类型,可能会先弹出一些上下文(context)信息,比如处理 HostRoot 时会弹出与根容器相关的上下文。

  2. Switch Case on Tag: 函数中有一个大的 switch 语句,根据 Fiber 节点的 tag 属性执行不同的逻辑。tag 属性标识了 Fiber 节点的类型(如函数组件、类组件、DOM 元素等)。

  3. Host Component: 对于 HostComponent 类型的 Fiber(即 DOM 元素),会进行如下处理:

    • 如果 Fiber 节点是新创建的,会创建对应的 DOM 元素,并将其子节点附加上去。
    • 更新 DOM 元素的属性和事件处理函数。
    • 处理 ref。
  4. Class Component: 对于 ClassComponent 类型的 Fiber,主要是处理与上下文相关的逻辑。

  5. unction Component: 对于函数组件和其他一些简单组件类型(如 Fragment、ForwardRef 等),主要操作是 bubbleProperties,这个函数负责向上冒泡子 Fiber 树的一些属性,如副作用列表(effect list)。

  6. Host Root: 对于 HostRoot 类型的 Fiber(即 React 应用的根节点),会执行一些全局的清理和更新操作,比如清理旧的上下文信息、设置新的上下文、处理 hydration 状态等。

  7. Suspense Component: 对于 SuspenseComponent 类型的 Fiber,会处理与懒加载(lazy loading)和代码分割(code splitting)相关的逻辑,比如标记哪些组件处于加载状态,哪些组件已经加载完成等。

beginWork 和 completeWork 函数在 Fiber 的工作循环中相互配合,以递归方式遍历和处理整棵 Fiber 树。beginWork 负责开始处理一个节点,为节点及其子节点确定更新工作;而 completeWork 负责完成节点的工作,准备实际的 DOM 更新。

在更新过程开始时,React 从根 Fiber 节点开始,通过 beginWork 为每个节点及其子节点确定更新任务。一旦到达树的底部,即叶子节点,React 开始执行 completeWork,从底部向上完成每个节点的工作,并准备最终的 DOM 提交。

beginWork 阶段通过递归方式深入 Fiber 树,为每个节点及其子树规划更新。当它到达叶子节点,或者当一个节点没有更多的子节点要处理时,控制权转移到 completeWork。completeWork 则是通过回溯方式向上完成每个节点的工作,直到回到根节点。

可以将 beginWork 视为更新的准备阶段,而 completeWork 是更新执行的准备阶段。beginWork 决定了哪些工作需要完成,completeWork 则确保这些工作准备就绪,以便在提交阶段应用到 DOM 上。

beginWork 和 completeWork 是 React Fiber 架构中协同工作的两个关键环节,负责在更新过程中遍历和处理 Fiber 树的节点,从而实现高效的 UI 更新。

如果是 hydrate 则不需要创建新元素,它会在 beginWork 的时候对比当前 dom 是不是可以复用,可以复用的话就直接放到 fiber.stateNode 上了。

遍历真实 DOM 树的顺序和构建 workInProgress 树的顺序是一致的。都是深度优先遍历,先遍历当前节点的子节点,子节点都遍历完了以后,再遍历当前节点的兄弟节点。因为只有按相同的顺序,fiber 树同一位置的 fiber 节点和 dom 树同一位置的 dom 节点才能保持一致。

只有类型为 HostComponent 或者 HostText 类型的 fiber 节点才能 hydrate。这一点也很好理解,React 在 commit 阶段,也就只有这两个类型的 fiber 节点才需要执行 dom 操作。

enterHydrationState 函数是 React 在开始客户端水合过程时的准备步骤。它设置了一系列的全局变量来标记水合过程的开始,并准备了第一个可水合的子节点以供后续的水合过程使用。这些设置确保了 React 能够正确地将服务器端渲染的 HTML 与客户端的 React 应用同步。

  1. isHydrating。表示当前正处于 hydrate 的过程。如果当前节点及其所有子孙节点都不满足 hydrate 的条件时,这个变量为 false

  2. hydrationParentFiber。当前混合的 fiber。正常情况下,该变量和 HostComponent 或者 HostText 类型的 workInProgress 一致。

  3. nextHydratableInstance。下一个可以混合的 dom 实例。当前 dom 实例的第一个子元素或者兄弟元素。

找的顺序是先找到 firstChild,然后依次找 nextSibling,很明显,这是一个深度优先搜索的过程,一层层往下遍历:

getNextHydratable 会判断 dom 实例是否是 ELEMENT_NODE 类型(对应的 fiber 类型是 HostComponent)或者 TEXT_NODE 类型(对应的 fiber 类型是 HostText)。只有 ELEMENT_NODE 或者 HostText 类型的 dom 实例才是可以 hydrate 的。

在这个时候我们客户端应该是已经获取到 HTML 标签的了,然后 reconcile 的过程中会处理到这个标签,也就是 HostComponent 类型:

在前面的 enterHydrationState 函数中我们已经将 isHydrating 设置为 true 了,所以会进入 hydrate 逻辑:

假设当前 fiberA 对应位置的 dom 为 domA,tryToClaimNextHydratableInstance 会首先调用 tryHydrate 判断 fiberA 和 domA 是否满足混合的条件:

  1. 如果 fiberA 和 domA 满足混合的条件,则将 hydrationParentFiber = fiberA;。并且获取 domA 的第一个子元素赋值给 nextHydratableInstance

  2. 如果 fiberA 和 domA 不满足混合的条件,则获取 domA 的兄弟节点,即 domB,调用 tryHydrate 判断 fiberA 和 domB 是否满足混合条件:

    • 如果 domB 满足和 fiberA 混合的条件,则将 domA 标记为删除,并获取 domB 的第一个子元素赋值给 nextHydratableInstance
    • 如果 domB 不满足和 fiberA 混合的条件,则调用 insertNonHydratedInstance 提示错误:"Warning: Expected server HTML to contain a matching",同时将 isHydrating 标记为 false 退出。

这里可以看出,tryToClaimNextHydratableInstance 最多比较两个 dom 节点,如果两个 dom 节点都无法满足和 fiberA 混合的条件,则说明当前 fiberA 及其所有的子孙节点都无需再进行混合的过程,因此将 isHydrating 标记为 false。等到当前 fiberA 节点及其子节点都完成了工作,即都执行了 completeWork,isHydrating 才会被设置为 true,以便继续比较 fiberA 的兄弟节点。

结合前面的内容,该函数的主要流程有如下步骤:

  1. 检查是否处于水合状态:如果当前不处于水合状态(isHydrating 为 false),则函数直接返回,不执行任何操作。

  2. 获取下一个可水合的实例:let nextInstance = nextHydratableInstance; 尝试获取下一个可水合的 DOM 节点实例。

  3. 处理无可水合实例的情况:如果没有可水合的实例(nextInstance 为空),并且当前 Fiber 节点应该在客户端渲染时(由 shouldClientRenderOnMismatch 判断),则发出警告并抛出水合不匹配的错误。如果不应该在客户端渲染,则将当前 Fiber 节点标记为插入操作(非水合),并结束水合状态。

  4. 尝试水合:使用 tryHydrate(fiber, nextInstance) 尝试将当前 Fiber 节点与 nextInstance 进行水合。如果成功,则水合过程继续进行。

  5. 处理水合失败的情况:如果第一次尝试水合失败,则尝试获取 firstAttemptedInstance 的下一个可水合的兄弟节点(getNextHydratableSibling),并再次尝试水合。如果第二次尝试也失败,则同样将当前 Fiber 节点标记为插入操作,并结束水合状态。

  6. 处理多余的实例:如果第二次尝试水合成功,则假定第一次尝试的实例是多余的,需要将其删除。由于不能立即删除,所以需要安排一个删除操作。为此,需要为该多余的 DOM 节点关联一个虚拟的 Fiber 节点,并在之后进行删除。

tryToClaimNextHydratableInstance 函数是 React 在水合过程中的一个重要步骤,用于尝试将服务器渲染的 DOM 节点与客户端的 React Fiber 节点进行匹配和水合。这个过程涉及到多次尝试、错误处理和处理多余的 DOM 节点,以确保客户端的 React 应用能够正确地接管服务器渲染的 HTML。

接下来我们再来看看 tryHydrate 这个函数在干嘛?

ts 复制代码
function tryHydrate(fiber, nextInstance) {
  switch (fiber.tag) {
    case HostComponent: {
      const type = fiber.type;
      const props = fiber.pendingProps;
      const instance = canHydrateInstance(nextInstance, type, props);
      if (instance !== null) {
        fiber.stateNode = (instance: Instance);
        hydrationParentFiber = fiber;
        nextHydratableInstance = getFirstHydratableChild(instance);
        return true;
      }
      return false;
    }
    case HostText: {
      const text = fiber.pendingProps;
      const textInstance = canHydrateTextInstance(nextInstance, text);
      if (textInstance !== null) {
        fiber.stateNode = (textInstance: TextInstance);
        hydrationParentFiber = fiber;
        // Text Instances don't have children so there's nothing to hydrate.
        nextHydratableInstance = null;
        return true;
      }
      return false;
    }
    case SuspenseComponent: {
      const suspenseInstance: null | SuspenseInstance = canHydrateSuspenseInstance(
        nextInstance,
      );
      if (suspenseInstance !== null) {
        const suspenseState: SuspenseState = {
          dehydrated: suspenseInstance,
          treeContext: getSuspendedTreeContext(),
          retryLane: OffscreenLane,
        };
        fiber.memoizedState = suspenseState;
        // Store the dehydrated fragment as a child fiber.
        // This simplifies the code for getHostSibling and deleting nodes,
        // since it doesn't have to consider all Suspense boundaries and
        // check if they're dehydrated ones or not.
        const dehydratedFragment = createFiberFromDehydratedFragment(
          suspenseInstance,
        );
        dehydratedFragment.return = fiber;
        fiber.child = dehydratedFragment;
        hydrationParentFiber = fiber;
        // While a Suspense Instance does have children, we won't step into
        // it during the first pass. Instead, we'll reenter it later.
        nextHydratableInstance = null;
        return true;
      }
      return false;
    }
    default:
      return false;
  }
}

tryHydrate 函数是 React 水合(hydration)过程中的关键环节,用于尝试将当前的 Fiber 节点与给定的 DOM 实例进行匹配和水合。这个过程根据 Fiber 节点的类型(如宿主组件、文本节点或 Suspense 组件)采取不同的处理策略。以下是对这个函数的详细解释:

  1. 针对宿主组件的处理(HostComponent):

    • 如果当前 Fiber 节点是宿主组件(如 div、span 等 HTML 元素),函数将检查 nextInstance 是否可以与该 Fiber 节点对应的类型和属性进行水合。

    • canHydrateInstance(nextInstance, type, props) 函数用于判断 nextInstance 是否能够与指定类型和属性的组件进行水合。如果可以,它将返回对应的 DOM 实例;如果不可以,它将返回 null。

    • 如果成功匹配,fiber.stateNode 将被设置为这个 DOM 实例,表示该 Fiber 节点已经与 DOM 实例成功关联。然后,函数会尝试获取这个 DOM 实例的第一个可水合子节点作为下一步水合的目标。

  2. 针对宿主文本组件的处理(HostText):

    • 如果当前 Fiber 节点是文本节点,函数将检查 nextInstance 是否可以作为对应文本内容的文本节点进行水合。

    • canHydrateTextInstance(nextInstance, text) 函数用于判断 nextInstance 是否能够作为指定文本内容的文本节点进行水合。如果可以,它将返回文本 DOM 实例;如果不可以,它将返回 null。

    • 如果成功匹配,fiber.stateNode 将被设置为这个文本 DOM 实例。由于文本节点没有子节点,所以 nextHydratableInstance 将被设置为 null。

  3. 针对 Suspense 组件的处理(SuspenseComponent):

    php 复制代码
    - 如果当前 Fiber 节点是 Suspense 组件,函数将检查 nextInstance 是否可以作为 Suspense 实例进行水合。
    - canHydrateSuspenseInstance(nextInstance) 函数用于判断 nextInstance 是否能够作为 Suspense 实例进行水合。如果可以,它将返回 Suspense DOM 实例;如果不可以,它将返回 null。
    - 如果成功匹配,函数将创建一个代表脱水片段的 Fiber 节点,并将其设置为当前 Suspense Fiber 节点的子节点。这样做是为了简化后续处理,因为在水合过程中不会立即进入 Suspense 实例的内部,而是会在后续阶段重新进入。

对于每种类型的 Fiber 节点,如果成功进行水合,函数将返回 true,表示当前 Fiber 节点已成功与 DOM 实例关联;如果不能进行水合,函数将返回 false,表示当前 Fiber 节点需要采取其他措施(如创建新的 DOM 节点)。

总的来说,tryHydrate 函数是 React 在水合过程中尝试匹配和关联服务器渲染的 DOM 节点与客户端 Fiber 节点的关键环节,它根据 Fiber 节点的不同类型采取不同的水合策略,以确保客户端应用能够正确地接管服务器渲染的内容。

canHydrateInstance 函数是 React 在客户端水合过程中用于检查一个 DOM 实例是否可以作为特定 React 组件水合目标的关键函数。这一步骤确保了只有当 DOM 实例与 React 组件类型完全匹配时,才会进行水合操作,从而提高了水合过程的准确性和效率。

completeUnitOfWork

completeUnitOfWork 方法是在 beginWork 执行到第一个叶子节点的时候开始执行的,从子到父,构建其余节点的 fiber 树,主要是给 dom 关联 fiber 以及 props:

在 completeWork 函数中,我们先来看看 HostRoot 这个 Case,HostRoot 通常代表了 React 应用的根节点:

  1. 如果是首次渲染(hydration),则检查并处理与水合相关的状态。如果成功进行了水合,会标记 Update flag 以在提交阶段进行相应的处理。
  2. 如果未进行水合,但存在必要的条件(例如,是客户端渲染或者之前因错误切换到了客户端渲染),则标记 Snapshot flag 以在提交阶段处理。

HostComponent 代表了宿主环境(如浏览器环境中的 DOM 节点)的普通组件,如 div、span 等。

接下来我们来详细看看这个代码的逻辑:

  1. 检查是否进行了水合:const wasHydrated = popHydrationState(workInProgress); 这行代码尝试从当前的 workInProgress Fiber 节点中弹出水合状态。如果返回 true,则意味着当前节点对应的 DOM 实例是通过水合服务器端渲染的 HTML 得到的。

  2. 准备水合宿主实例:

    • 如果当前节点已经进行了水合,prepareToHydrateHostInstance 函数会被调用,以准备水合过程。这个函数会检查当前的 Fiber 节点和对应的 DOM 实例,确定是否需要更新 DOM 实例的属性或状态。
    • 如果这个函数返回 true,表示在水合过程中发现了需要更新的属性,那么会通过 markUpdate(workInProgress) 将当前的 Fiber 节点标记为需要进行更新。

如果当前节点不是通过水合得到的,那么将会创建一个新的 DOM 实例。createInstance 函数根据给定的类型(如 'div')、新的属性集、容器实例和宿主上下文来创建一个新的 DOM 元素。

appendAllChildren(instance, workInProgress, false, false); 这行代码将遍历 workInProgress Fiber 节点的子节点,并将这些子节点对应的 DOM 元素追加到新创建的 DOM 实例中。这一步骤确保了 DOM 树的结构正确地反映了 Fiber 树的结构。

workInProgress.stateNode = instance; 这行代码将新创建的 DOM 实例与当前的 Fiber 节点关联起来,通过设置 stateNode 属性。

finalizeInitialChildren 函数会在新创建的 DOM 实例上设置属性、绑定事件等。如果这个函数返回 true,表示有一些设置是在提交阶段(commit phase)完成的,因此需要将当前的 Fiber 节点标记为需要更新。

段代码处理了在客户端渲染过程中,针对 HostComponent 类型的 Fiber 节点,要么通过水合现有的服务器端渲染的节点,要么创建新的 DOM 节点,并确保 DOM 节点的属性和子节点正确设置。这是 React 在渲染或更新 DOM 树时的关键步骤之一。

popHydrationState

对于这个函数,我们直接上源码:

ts 复制代码
function popHydrationState(fiber: Fiber): boolean {
  if (!supportsHydration) {
    return false;
  }
  if (fiber !== hydrationParentFiber) {
    return false;
  }
  if (!isHydrating) {
    popToNextHostParent(fiber);
    isHydrating = true;
    return false;
  }

  if (
    fiber.tag !== HostRoot &&
    (fiber.tag !== HostComponent ||
      (shouldDeleteUnhydratedTailInstances(fiber.type) &&
        !shouldSetTextContent(fiber.type, fiber.memoizedProps)))
  ) {
    let nextInstance = nextHydratableInstance;
    if (nextInstance) {
      if (shouldClientRenderOnMismatch(fiber)) {
        warnIfUnhydratedTailNodes(fiber);
        throwOnHydrationMismatch(fiber);
      } else {
        while (nextInstance) {
          deleteHydratableInstance(fiber, nextInstance);
          nextInstance = getNextHydratableSibling(nextInstance);
        }
      }
    }
  }
  popToNextHostParent(fiber);
  if (fiber.tag === SuspenseComponent) {
    nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
  } else {
    nextHydratableInstance = hydrationParentFiber
      ? getNextHydratableSibling(fiber.stateNode)
      : null;
  }
  return true;
}

首先,函数检查当前环境是否支持水合(supportsHydration)。如果不支持,函数直接返回 false。

函数接着检查当前处理的 Fiber 节点(fiber)是否与当前的水合父节点(hydrationParentFiber)相同。如果不相同,表示当前 Fiber 节点处于更深层的插入树中(不在当前水合上下文中),函数返回 false。

如果当前不在水合状态(isHydrating 为 false),但处于一个水合上下文中,函数会尝试回溯到下一个宿主父节点,并将 isHydrating 设置为 true,然后返回 false。

对于非 HostRoot 和特定条件下的 HostComponent 类型的 Fiber 节点,函数会检查是否存在下一个可水合的实例(nextHydratableInstance)。如果存在并且当前 Fiber 节点应该在客户端渲染时(由 shouldClientRenderOnMismatch 判断),函数会发出警告并抛出水合不匹配的错误。如果不应该在客户端渲染,函数会删除所有未水合的尾部实例。

函数会调用 popToNextHostParent 来更新水合上下文,回溯到下一个合适的宿主父节点。接着,根据当前 Fiber 节点的类型,更新 nextHydratableInstance 为下一个可水合的兄弟节点或跳过当前的脱水 Suspense 实例。

popHydrationState 函数负责在 React 水合过程中管理和更新水合状态,包括处理未水合的实例、更新水合上下文以及准备下一步的水合操作。这是确保客户端应用能够正确接管服务器渲染内容的关键步骤之一。

以下图为示例:

当 React 在客户端接管由服务器渲染的 HTML 时,它会尝试"水合"这些 DOM 节点以便它们能够响应事件和适当地更新。这个过程需要比较服务端生成的 DOM 树和客户端生成的 React Fiber 树。下面是具体的流程:

  1. 在 beginWork 阶段,React 会尝试匹配当前 Fiber 节点(在这个例子中是 p#B)与现有的 DOM 节点(h1#B)。

  2. 如果发现 DOM 节点不满足水合的条件,React 会检查该 DOM 节点的兄弟节点(div#C),继续寻找匹配项。

  3. 如果在最多两轮对比后仍然没有找到可以水合的 DOM 节点,React 会停止水合过程,并将 isHydrating 标记设为 false,同时更新 hydrationParentFiber 为当前的 Fiber 节点(p#B)。这表示 p#B 及其子孙节点不会继续水合。

  4. div#B1 Fiber 节点完成其工作时,它会调用 completeUnitOfWork。在 popHydrationState 方法中,会首先检查当前 Fiber 节点是否是水合父节点,由于 hydrationParentFiber 等于 p#B,对 div#B1 来说这个条件是成立的,所以它不会继续执行水合相关的逻辑。

  5. p#B 的子节点都完成了工作,p#B 也可以调用 completeUnitOfWork 来完成工作。在 popHydrationState 函数内部,由于 p#B 是当前的水合父节点,且 isHydrating 为 false,所以会更新 hydrationParentFiber 为 p#B 的第一个类型为 HostComponent 的祖先元素(div#A),并将 isHydrating 设置为 true,这表示可以为 p#B 的兄弟节点继续水合过程。

如果在服务端渲染的 DOM 中存在 React 不需要的节点(例如,下图中的 div#D),那么 React 会在适当的阶段调用 deleteHydratableInstance 方法将这些多余的 DOM 节点删除。在这个例子中,div#D 可能会在 div#A Fiber 的 completeUnitOfWork 阶段被删除。

prepareToHydrateHostInstance

函数是 React 在客户端水合过程中用于准备宿主组件实例的一个环节。此函数主要负责将服务器端渲染的 DOM 和对应的 React Fiber 节点进行匹配和更新,以便它们能够响应事件和适当地更新。

在这里我们应该了解一下 diffHydratedProperties 这个函数,在水合过程中检查 DOM 元素与 React 元素之间的属性是否匹配,并为必要的属性更新生成更新有效载荷。

以下面的 demo 为例:

jsx 复制代码
// 服务端对应的dom
<div id="root"><div extra="server attr" id="server">客户端的文本</div></div>

// 客户端
render() {
  const { count } = this.state;
  return <div id="client">客户端的文本</div>;
}

在 diffHydratedProperties 的过程中发现,服务端返回的 id 和客户端的 id 不同,控制台提示 id 不匹配,但是客户端并不会纠正这个,可以看到浏览器的 id 依然是 server。

同时,服务端多返回了一个 extra 属性,因此需要控制台提示,但由于已经提示了 id 不同的错误,这个错误就不会提示。 最后,客户端的文本和服务端的 children 不同,即文本内容不同,也需要提示错误,同时,客户端会纠正这个文本,以客户端的为主。

到这里我们应该就把 React SSR 从服务端的 renderToString 到浏览器端的 hydrate 的全流程的原理讲完了。

流式渲染(Streaming HTML)

一般来说,流式渲染就是把 HTML 分块通过网络传输,然后客户端收到分块后逐步渲染,提升页面打开时的用户体验。通常是利用 HTTP/1.1 中的分块传输编码(Chunked transfer encoding)机制。

在我们前面的内容,讲解的是 renderToString,它是一个同步函数,它将 React 组件树渲染为一个静态的 HTML 字符串。它的缺点包括:

  1. renderToString 在服务器上执行时必须等待整个页面渲染完毕才能返回结果。在复杂的应用中,这可能导致较长的响应时间。

  2. 作为一个同步操作,renderToString 在生成完整的 HTML 字符串之前会阻塞事件循环。这可能影响服务器处理其他并发请求的能力。

  3. 客户端无法尽早开始下载页面的其他资源,例如 CSS 或 JavaScript 文件,因为它必须等待服务器发送完整的 HTML 响应。

React 18 提供了一种新的 SSR 渲染模式: Streaming SSR。通过 Streaming SSR,我们可以实现以下两个功能:

  1. Streaming HTML:服务端可以分段传输 HTML 到浏览器,而不是像 React 18 以前一样,需要等待服务端渲染完成整个页面后才返回给浏览器。这样,浏览器可以更快的启动 HTML 的渲染,提高 FP、FCP 等性能指标。
  2. Selective Hydration:在浏览器端 hydration 阶段,可以只对已经完成渲染的区域做 hydration,而不需要等待整个页面渲染完成、所有组件的 JS bundle 加载完成,才能开始 hydration。这样可以更早的对已经完成渲染的区域做事件绑定,从而让页面获得更好的可交互性。

基本使用实例

HTTP 支持以 stream 格式进行数据传输。当 HTTP 的 Response header 设置 Transfer-Encoding: chunked 时,服务器端就可以将 Response 分段返回。一个简单示例:

js 复制代码
const http = require("http");
const url = require("url");

const sleep = (ms) => {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
};

const server = http.createServer(async (req, res) => {
  const { pathname } = url.parse(req.url);
  if (pathname === "/") {
    res.statusCode = 200;
    res.setHeader("Content-Type", "text/html; charset=utf-8"); // 设置字符集为 UTF-8
    res.setHeader("Transfer-Encoding", "chunked");
    res.write("<html><body><div>第一个分段</div>");

    await sleep(3000);

    res.write("<div>第二个分段</div></body></html>");
    res.end();
    return;
  }

  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); // 同样设置字符集为 UTF-8
  res.end("okay");
});

server.listen(8080);

当访问 localhost:8080 时,第一个分段第二个分段 会分两次传输到浏览器端,第一个分段会先显示到页面上,3 秒延迟后,第二个分段再显示到页面上。

React 的就更复杂了。这里我们不讲了,之后再详细讲讲 React 的,包括原理一起讲了。

选择性注水 (Selective Hydration)

React 18 之前,SSR 实际上是不支持 code splitting 的,只能使用一些 workaround,常见的方式有:1. 对于需要 code splitting 的组件,不在服务端渲染,而是在浏览器端渲染;2. 提前将 code splitting 的 JS 写到 html script 标签中,在客户端等待所有的 JS 加载完成后再执行 hydration。

在传统的水合过程中,React 需要按顺序从根节点开始,逐个将整个应用的静态内容变成动态内容。这意味着,直到整个应用完成水合之前,页面上的大部分组件都不会响应用户交互。对于大型应用,这可能会导致显著的延迟。

Selective Hydration 则允许 React 优先水合那些与用户当前交互相关的部分,而暂缓其他部分的水合。这样,用户看到的和与之交互的内容可以尽快变得可响应,即使页面的其他部分仍在水合过程中。

有了 lazy 和 Suspense 的支持,另一个特性就是 React SSR 能够尽早对已经就绪的页面部分注水,而不会被其他部分阻塞。从另一个角度看,在 React 18 中注水本身也是 lazy 的。

这样就可以将不需要同步加载的组件选择性地用 lazy 和 Suspense 包起来(和客户端渲染时一样)。而 React 注水的粒度取决于 Suspense 包含的范围,每一层 Suspense 就是一次注水的"层级"(要么组件都完成注水要么都没完成)。

React 内部有一个任务调度系统,它可以根据任务的优先级来决定执行顺序。在 SSR 完成后,当用户开始与页面交互时,React 会将这些交互事件(如点击事件)加入到优先队列中。

比如,如果用户点击了一个按钮,但是这个按钮对应的组件还没有被注水,React 会将注水这个组件的任务放到一个高优先级的队列中。这样做的目的是尽快让这个组件变得可交互,而不是按照文档流的顺序逐一注水所有组件。

由于用户可能在组件完成注水之前进行交互,React 需要一种机制来确保这些早期的交互不会丢失。这就是"事件重放"。

当用户在某个尚未注水的组件上触发事件时,React 会将该事件记录下来。一旦该组件被注水,即变成动态的、可交互的,React 会重新触发这些记录的事件,就好像这些交互是在注水完成后发生的一样。

这个过程对用户来说是透明的,他们不会察觉到发生在背后的注水和事件重放。用户的体验是连贯的:他们点击按钮,稍后按钮就会响应,即使实际上 React 在背后做了优先级调整和事件处理。

流式渲染会对 SEO 有影响吗

因为流式渲染一开始只返回一部分,那它会 SEO 有影响吗?答案是问题不大。

Streaming SSR 通常对 SEO 是有益的,因为它允许搜索引擎更快地看到并索引页面的首屏内容。尽管页面的其余部分可能仍在流式传输中,但搜索引擎通常会等待整个页面加载完成,然后抓取整个页面的内容进行索引。

搜索引擎偏好快速加载的页面。Streaming SSR 通过更快地提供首屏内容,有助于提升页面的 perceived performance(感知性能),这通常是搜索引擎排序算法考虑的因素之一。

由于搜索引擎爬虫的时间有限,Streaming SSR 可以通过快速提供页面内容来提高抓取效率,确保重要内容被抓取和索引。

即使使用了 Streaming SSR,搜索引擎爬虫通常也会等待直到接收到完整的 HTML 页面。这意味着即便是部分内容先行发送,对 SEO 的影响也是有限的。

总结

通过本篇文章我们应该可以了解到 SSR 出现的原因以及优势。

通过学习 React 源码 React SSR 从服务端的 renderToString 到浏览器端的 hydrate 的全流程的原理讲完了。

对于流式渲染我们也对基础进行了讲解,由于文章篇幅有限,我们会在之后的文章内容中进行原理的讲解。

参考文章

相关推荐
学习ing小白1 小时前
JavaWeb - 5 - 前端工程化
前端·elementui·vue
真的很上进1 小时前
【Git必看系列】—— Git巨好用的神器之git stash篇
java·前端·javascript·数据结构·git·react.js
胖虎哥er1 小时前
Html&Css 基础总结(基础好了才是最能打的)三
前端·css·html
qq_278063712 小时前
css scrollbar-width: none 隐藏默认滚动条
开发语言·前端·javascript
.ccl2 小时前
web开发 之 HTML、CSS、JavaScript、以及JavaScript的高级框架Vue(学习版2)
前端·javascript·vue.js
小徐不会写代码2 小时前
vue 实现tab菜单切换
前端·javascript·vue.js
2301_765347542 小时前
Vue3 Day7-全局组件、指令以及pinia
前端·javascript·vue.js
喝旺仔la2 小时前
VSCode的使用
java·开发语言·javascript
ch_s_t2 小时前
新峰商城之分类三级联动实现
前端·html
辛-夷2 小时前
VUE面试题(单页应用及其首屏加载速度慢的问题)
前端·javascript·vue.js