大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!

今天给大家带来的主题是渐进式水合,话不多说,直接开始!
性能指标开始进入渐进式水合正题之前,先了解下常见页面性能指标,这些指标会在后面章节陆续引入。
TTFB: 是一个衡量对资源的请求和响应的第一个字节开始和到达之间时间的指标。是 Redirect time 重定向时延、Service worker 启动时延(如果适用)、DNS 查询时延、建立连接和 TLS 所消耗时延,直到响应的第一个字节到达为止的时延等的总和。FMP:全称 First Meaningful Paint ,为首次有效绘制。表示页面的”主要内容“开始出现在屏幕上的时间点,它是测量用户加载体验的主要指标FCP:指标测量页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。对于该指标,"内容"指的是文本、图像(包括背景图像)、<svg>元素或非白色的<canvas>元素。FP (First Paint) 首次绘制:标记浏览器渲染任何在视觉上不同于导航前屏幕内容之内容的时间点。TTI:测量页面从开始加载到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间。渐进式水合服务器渲染的应用程序依赖服务器为当前页面生成 HTML。 一旦服务器完成生成 HTML 内容(其中还包含正确显示静态 UI 所需的 CSS 和 JSON 数据),就会将数据发送到客户端。 由于服务器生成了 HTML 标记,客户端可以快速解析它并将其显示在屏幕上,从而产生快速的 First Contentful Paint,即 FCP。
虽然服务端渲染提供了更快的 FCP,但是 TTI(Time To Interactive)却不总是最优的。 因为网站交互所需的 JavaScript 尚未加载, 渲染出的按钮虽然看起来是可以交互式的,但是实际上却并非如此。 只有在 JavaScript 被加载和解析后,React DOM 上的事件处理程序才会被正确添加,这个过程称为水合。

用户在屏幕上看到非交互式 UI 的时间也称为恐怖谷(Uncanny Valley):尽管用户可能认为可以与网站进行交互,但组件上还没有附加处理程序,快速点击而无法响应对用户体验来说却是噩梦。
从服务器接收到的 DOM 组件完全水合可能需要一段时间,这段时间需要完成加载、处理和执行 JavaScript 文件。 当然,可以渲染渐进式对 DOM 节点进行水合,而不是像传统 SSR 那样一次水合整个应用程序。 渐进式水合使得单独水合节点成为可能,从而实现只加载最少的、必要的 JavaScript 。

通过渐进式水合应用程序,可以延迟页面中不太重要部分的内容水合,从而减少为使页面具有交互性而必须请求的 JavaScript 数量,同时仅在用户需要时才激活节点。 渐进式水合还有助于避免最常见的 SSR Rehydration 陷阱,即服务器渲染的整个 DOM 树被破坏然后立即重建。

渐进式水合还允许开发者仅根据特定条件对组件进行水合,例如:当组件在视口中可见时。 在下面的示例中,当用户列表组件出现在视口中时将会启动渐进式水合:
// client.js内容import React from 'react';import { hydrate } from 'react-dom';import App from './components/App';hydrate(<App />, document.getElementById('root'));下面是 server.js内容:
// server.jsimport React from 'react';import { renderToNodeStream } from 'react-dom/server';import App from './components/App';export default async () => renderToNodeStream(<App />);下面是组件 APP.js 的内容:
import React from 'react';import { Hydrator as ClientHydrator, ServerHydrator } from './Hydrator';let load = () => import('./Stream');let Hydrator = ClientHydrator;if (typeof window === 'undefined') { Hydrator = ServerHydrator; load = () => require('./Stream');}export default function App() { return ( <div id="app"> <divName="intro"> <p> 这是服务器端渲染的 React 如何启用的示例 <strong>渐进式水合</strong> experiences. </p> <p> <strong>滚动.</strong> The flash of color you see is an 没有任何直接更改的情况下获取 JavaScript 的指标用户界面。 </p> </div> <Hydrator load={load} /> </div> );}渐进式补水方法Hydration 允许客户端 React 识别在服务器上渲染的 ReactDOM 组件并将事件附加到这些组件。 因此,它为 SSR 应用程序带来了连续性、无缝性等特性,一旦在客户端上可用,它就可以像 CSR 应用程序一样运行。
为了让页面上的所有组件通过水合作用变得可交互,这些组件的 React 代码应该包含在下载到客户端的包中。 然而,大多数静态网站在屏幕上只有部分交互元素,不需要立即激活所有组件。渐进式水合允许在页面加载时只对应用程序的部分内容进行水合来解决这个问题, 其他部分按需逐步水合。

水合不是立即初始化整个应用程序,而是从 DOM 树的根部开始,但是服务端渲染的应用程序的各个部分会在一段时间内被激活。 水合过程可能会因各种分支而停止,并在它们进入视口或基于某些其他触发器后恢复。 需要注意的是,每次执行水合作用所需的资源加载也使用代码拆分技术延迟,从而减少了使页面交互所需的 JavaScript 数量。
渐进式水合的思想是通过分块激活应用程序来提供出色的性能, 任何渐进式水合还应考虑它将如何影响整体用户体验。 不能让屏幕区域一个接一个地出现,但阻止已经加载的块上的任何活动或用户输入。 因此,渐进水合需要考虑以下几个因素:
允许对所有组件使用 SSR支持将代码拆分为单独的组件或块。支持以开发人员定义的顺序对这些块进行客户端水合作用。不会阻止用户对已经水合的块进行输入。允许对具有延迟水合作用的块使用某种加载指示器。一旦对所有人可用,React 并发模式将解决所有这些需求。 它允许 React 同时处理不同的任务,并根据给定的优先级在它们之间切换。 切换时,不需要提交部分渲染的树,因此一旦 React 切换回同一任务,渲染任务可以继续。
并发模式可用于实施渐进式水合作用。 在这种情况下,页面上每个块的水合作用成为 React 并发模式的任务。 如果需要执行用户输入等更高优先级的任务,React 将暂停水合任务并切换到接受用户输入。 lazy()、Suspense() 等功能允许开发者使用声明式加载状态,可用于在延迟加载块时显示加载指示器,而 SuspenseList() 可用于定义延迟加载组件的优先级。
<SuspenseList revealOrder="forwards"> <Suspense fallback={'Loading...'}> <ProfilePicture id={1} /> </Suspense> <Suspense fallback={'Loading...'}> <ProfilePicture id={2} /> </Suspense> <Suspense fallback={'Loading...'}> <ProfilePicture id={3} /> </Suspense></SuspenseList>React 并发模式还可以与另一个 React 特性结合使用,即服务器组件(Server Components)。React 服务器组件允许开发人员构建跨越服务器和客户端的应用程序,将客户端应用程序丰富的交互性与传统服务器渲染的改进性能相结合。
在 Next.js 13 中,开发者可以将服务器组件放在app/目录下,该目录默认启动服务器组件。
渐进式水合实践者虽然基于 React 并发模式的渐进水合实现仍在开发中,但还有许多其他支持部分水合实现的竞争者可用。
next-super-performance一个使用 preact 实现部分水合作用的库(Partial hydration for Next.js with Preact X),目前通过以下两个包实现所有功能:
pool-attendant-preact: 一个使用 preact x 实现部分水合的库next-super-performance: 一个使用 pool-attendant-preact 来提高客户端性能的 Next.js 插件在部分水合之上,这个库还会聚焦:加载策略,包括关键 CSS、关键 JS、延迟加载、预加载资源等等,并在未来为整个应用程序性能提供支持。
这个库有一个 Next.js 的部分水合 POC,这就是它的工作原理。可以创建一个 next.config.js 并像如下示例使用插件:
const withSuperPerformance = require('next-super-performance');module.exports = withSuperPerformance();以上代码执行时候,会发生两件事情:
React 将被 Preact 取代,因为它只有 3KBNext.js 的主要客户端 JavaScript 文件将被丢弃并替换为控件中的 JavaScript 文件这意味着必须在应用程序的根文件夹中创建一个 client.js,它将作为发送到客户端的 JavaScript 的入口。 这样做是为了能完全控制希望用户下载的内容,同时确定加载策略。
pool-attendant-preact 导出了 3 个 API:
withHydration: 一个 HOC,可用于标记要水合的组件hydrate: 一个函数来水合客户端中标记的组件HydrationData: 一个将序列化 props 写入客户端的组件,例如 NEXT_DATA假设有一个带有标题、主页和预告片的 Next 应用程序。如下图:希望高亮区域的内容动态化,而其他区域内容保持静态化。

首先创建 next.config.js 并使用插件:
const withSuperPerformance = require('next-super-performance');module.exports = withSuperPerformance();修改 package.json 以使 Next 正确使用 Preact(对 preact 进行别名设置,然后在不修改的情况下启动原始的 next 脚本):
"scripts": { "dev": "next:performance dev", "start": "next:performance start", "build": "next:performance build" }创建文件 pages/index.js:
import Header from '../components/header';import Main from '../components/main';import { HydrationData } from 'next-super-performance';export default function Home() { return ( <section> <Header /> <Main /> <HydrationData /> </section> );}这里的重要部分是<HydrationData />,它将插入如下内容:
<script type="application/hydration-data"> {"1":{"name":"Teaser","props":{"column":2}},"2":{"name":"Teaser","props":{"column":3}}}</script这些是将被水合的组件的名称和 props, 要告诉应用某个特定组件应该被水合,请使用 withHydration。main.js 内容如下:
import Teaser from './teaser';import { withHydration } from 'next-super-performance';const HydratedTeaser = withHydration(Teaser);// 应该要被水合的组件HydratedTeaserexport default function Body() { return ( <main> <Teaser column={1} /> <HydratedTeaser column={2} /> <HydratedTeaser column={3} /> // 2个要被水合的组件 <Teaser column={1} /> <Teaser column={2} /> <Teaser column={3} /> <Teaser column={1} /> <Teaser column={2} /> <Teaser column={3} /> </main> );}首先创建了一个将在客户端中水合的组件,在页面上使用了 2 次不同的 props。withHydration 会在组件前添加一个标记,以便它可以在服务器上渲染并在客户端的 HTML 中找到。 所以 <HydratedTeaser column={2} /> 会变成:
<Fragment> <script type="application/hydration-marker" /> <Teaser column={2} /></Fragment>最后也是最关键的部分是 client.js,它是将发送给用户的代码,也是将在其中水合组件的地方。对于单个组件(Teaser),它可以很简单。
import { hydrate } from 'next-super-performance';import Teaser from './components/teaser';hydrate([Teaser]);以上示例中,我们从 next-super-performance 导入和导出 Hydration、hydrate 和 HydrationData。hydrate 将找到使用 withHydration 标记的组件,并使用 <HydrationData /> 中的数据将它们与作为数组传递给它们的组件进行水合。
这将要求导入要在客户端中使用的组件,并将它们传递给 hydrate 函数。 因为 client.js 是所有客户端代码的入口点,这也意味着能准确地查看和控制发送给用户的代码。 除了 Preact 之外,不会发送任何其他内容。
如果组件有自己的依赖项,那么这些依赖项也将被发送,因为 client.js 是入口,每个依赖项都将通过 webpack 解决。
渐进式水合利弊Progressive hydration 提供服务器端渲染和客户端水合,同时也最小化了水合成本,下面是渐进式水合的显著优势。
促进代码拆分:代码拆分是渐进水合作用的一个组成部分,因为需要为延迟加载的各个组件创建代码块。允许按需加载页面不常用的部分:页面的某些组件可能大部分是静态的,不在视口之外和/或不经常需要。 此类组件是延迟加载的理想候选者。 这些组件的水合代码不需要在页面加载时发送,可以根据触发因素进行水合。减少包的大小:代码拆分会减小包大小,加载时执行的代码更少有助于缩短 FCP 和 TTI 之间的时间。但是,渐进式水合可能不适合动态应用程序,在动态应用程序中,屏幕上的每个元素都可供用户使用,并且需要在加载时进行交互。 如果开发人员不知道用户可能首先点击的位置,也就无法确定应该首先渲染哪些组件。
本文总结本文主要和大家介绍渐进式水合原理。因为篇幅有限,文章并没有过多展开,如果有兴趣,可以在我的主页继续阅读,同时文末的参考资料提供了优秀文档以供学习。最后,欢迎大家点赞、评论、转发、收藏!
参考资料https://www.patterns.dev/posts/progressive-hydration
https://web.dev/i18n/zh/tti/
https://github.com/LukasBombach/next-super-performance
https://17.reactjs.org/docs/concurrent-mode-reference.html
https://nextjs.org/docs/advanced-features/react-18/server-components
https://vercel.com/templates/next.js/app-directory
https://www.builder.io/blog/why-progressive-hydration-is-harder-than-you-think