React水合技术:优化SSR和CSR的完美结合
讨论水合(Hydration)之前,先说下 CSR 和 SSR:
- CSR(Client-side Rendering):在CSR中,整个应用程序的构建和渲染都发生在客户端浏览器中。当用户访问一个CSR应用时,浏览器会下载应用的JavaScript代码,然后在用户的设备上执行该代码来渲染页面。这种方式的好处是可以在客户端实现动态交互,但也有性能挑战,因为首次加载时需要下载大量的JavaScript代码,导致页面加载时间较长。
- SSR(Server-side Rendering):在SSR中,服务器在接收到客户端请求时,会在服务器上预先渲染HTML内容,并将其发送到客户端浏览器。这意味着用户会更快地看到内容,因为不必等待大量JavaScript代码下载和执行。但与CSR相比,SSR可能在复杂的应用中导致服务器负载增加,并且对实现某些交互功能有一定限制(并不总是提供更快的可交互时间)。
什么是水合
水合(Hydration)在前端开发中是指将服务端生成的静态HTML转换为动态可交互网页的过程。
过程结合了服务端渲染(SSR)和客户端渲染(CSR)的优点:服务端渲染提供了更快的首屏加载时间和更好的SEO,而客户端渲染提供了更丰富的交互体验。
传统客户端渲染问题
客户端渲染 (CSR) - 初始加载时空白、SEO不友好
function CSRApp() {return (<div id="root">{/* 需要等待JS加载执行后才能看到内容 */}</div>);
}ReactDOM.render(<CSRApp />, document.getElementById('root'));
水合优势
立即展示服务端渲染的HTML
// 服务器端预渲染的 HTML
<div id="root"><h1 data-reactroot="">Hello, World!</h1><p>这是服务器渲染的内容</p>
</div>// 客户端水合 - 立即显示内容,然后添加交互性
hydrateRoot(document.getElementById('root'), <CSRApp />);
水合关键过程:
- 绑定事件处理器:使得服务器端渲染的HTML元素变得可交互。例如,按钮的点击事件、表单的提交事件等。
- 重建应用状态:恢复或初始化客户端的JavaScript应用状态,使得客户端代码和服务器端渲染的一致。
- 同步DOM:确保在客户端的虚拟DOM与服务器端生成的实际DOM一致。
水合的方式有很多种,下面展开介绍几种常用的「下述示例代码逻辑性没问题,但非完整」:
特性 | 完全水合 | 渐进式水合 | 选择性水合 | 流式水合 |
---|---|---|---|---|
React版本 | 所有版本 | React 16+ | React 18+ | React 18+ |
实现复杂度 | 简单 | 中等 | 中等 | 复杂 |
性能影响 | 可能阻塞 | 良好 | 最优 | 优秀 |
用户体验 | 一般 | 良好 | 优秀 | 优秀 |
SEO友好 | ✅ | ✅ | ✅ | ✅ |
学习成本 | 低 | 中 | 中 | 高 |
完全水合
应用一次性完成水合过程
React 18 + Node
server.js // 同构渲染
App.jsx // 共享组件
client.js // 激活脚本
App.jsx
import React, { useState } from 'react';export default function App() {const [n, setN] = useState(0);return (<button onClick={() => setN(n + 1)}>已点击 {n} 次</button>);
}
server.js
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from './App.jsx';const app = express();
app.use(express.static('.')); // 把 client.js 暴露出去app.get('/', (req, res) => {// 1. 把组件渲染成“死”的 HTML 字符串const html = ReactDOMServer.renderToString(<App />);// 2. 把这段 HTML 插进页面模板,同时引入 client.jsres.send(`<!doctype html><html><body><div id="root">${html}</div><script type="module" src="/client.js"></script></body></html>`);
});app.listen(3000, () => console.log('http://localhost:3000'));
client.js
import { hydrateRoot } from 'react-dom/client';
import App from './App.jsx';// 将 React 连接到由 React 在服务端环境中渲染的现有 HTML 中
hydrateRoot(document.getElementById('root'), <App />);
验证“不重建 DOM”:点击按钮,React 的计数器 + 原生监听都会触发,说明服务端生成的 DOM 节点被完整复用,而非重新创建。
document.querySelector('button').addEventListener('click', () => console.log('原生Click事件'))
✅ 实现简单,开箱即用;SEO 友好,完整的内容预渲染
❌ 性能瓶颈明显;可交互时间(TTI)延迟("下载 → 解析 → hydrate"串行执行,遇到重组件整页都会卡顿)
适用场景: 小型应用、原型开发、对性能要求不高的项目
渐进式水合
可自定义触发条件:如可见性、时间等(优先水合关键组件,非关键组件延迟水合)。
基于 Intersection Observer
实现
import { useEffect, useRef, useState } from 'react';function useProgressiveHydration(options = {}) {const { threshold = 0.1, rootMargin = '50px' } = options;const [shouldHydrate, setShouldHydrate] = useState(false);const ref = useRef(null);useEffect(() => {const element = ref.current;if (!element || shouldHydrate) return;const observer = new IntersectionObserver(([entry]) => {if (entry.isIntersecting) {setShouldHydrate(true);observer.disconnect();}},{ threshold, rootMargin });observer.observe(element);return () => observer.disconnect();}, [shouldHydrate, threshold, rootMargin]);return [ref, shouldHydrate];
}// 使用示例
function LazyHydratedComments() {const [ref, shouldHydrate] = useProgressiveHydration({ threshold: 0.3 });return (<div ref={ref} style={{ minHeight: '200px' }}>{shouldHydrate ? (<CommentsSection />) : (<div className="comments-placeholder"><p>评论加载中...</p></div>)}</div>);
}
✅ 按需分配资源,避免主线程阻塞
❌ 实现复杂度较高,需要手动管理水合时机
适用场景: 内容型网站、博客、新闻站点
选择性水合 - React 18 核心特性
选择性水合是指在水合过程中,根据用户交互:优先水合被交互的部分,而中断或延迟其他部分的水合。
如果页面正在水合,但用户点击了某个按钮,React 会优先水合这个按钮相关的组件,以便快速响应交互。
选择性水合依赖于React 18的 hydrateRoot
和 Suspense
。-- 将应用的不同部分用 Suspense
包裹,这样 React 就可以独立地水合每个 Suspense
边界。当用户与一个尚未水合的 Suspense
边界内的组件交互时,React 会优先水合这个边界。
import { Suspense, useState } from 'react';function App() {const [activeSection, setActiveSection] = useState('home');return (<div>{/* React 自动监控这些点击事件,调整水合优先级 */}<nav><button onClick={() => setActiveSection('home')}>首页</button><button onClick={() => setActiveSection('profile')}>个人资料</button><button onClick={() => setActiveSection('settings')}>设置</button></nav><main>{activeSection === 'home' && (<Suspense fallback={<div>加载中...</div>}>{/* 当用户点击"首页"时,这个组件优先水合 */}<HomeContent /></Suspense>)}{activeSection === 'profile' && (<Suspense fallback={<div>加载中...</div>}>{/* 当用户点击"个人资料"时,这个组件优先水合 */}<ProfileContent /></Suspense>)}</main></div>);
}
✅ 避免水合阻塞主线程,改善用户体验
❌ 需要React 18及以上版本,将应用划分为多个Suspense边界,这可能需要调整组件结构
适用场景: 大型交互式应用、电商网站
流式水合
流式水合指的是服务器端使用流式传输(Streaming)将渲染的HTML分块发送到客户端,客户端在接收到这些分块后逐步进行水合。
浏览器可以更早地开始渲染页面,而不必等待整个HTML文档生成完毕。特别是对于慢网络或服务器生成部分内容较慢的情况,可以显著提升首屏显示时间。
// 服务器端 - 使用流式渲染
import { renderToPipeableStream } from 'react-dom/server';app.get('/product/:id', async (req, res) => {const { id } = req.params;const { pipe } = renderToPipeableStream(<ProductPage productId={id} />,{bootstrapScripts: ['/client.js'],onShellReady() {// 1. 先发送页面外壳res.setHeader('Content-type', 'text/html');pipe(res);},onAllReady() {console.log('所有内容渲染完成');}});
});// 客户端组件
function ProductPage({ productId }) {return (<div className="product-page">{/* 立即渲染的部分 */}<Header /><Breadcrumb />{/* 流式渲染的产品信息 */}<Suspense fallback={<div className="product-skeleton"><div className="image-placeholder"></div><div className="info-placeholder"></div></div>}><ProductDetails productId={productId} /></Suspense>{/* 流式渲染的推荐商品 */}<Suspense fallback={<RecommendationSkeleton />}><ProductRecommendations productId={productId} /></Suspense>{/* 流式渲染的用户评价 */}<Suspense fallback={<ReviewsSkeleton />}><ProductReviews productId={productId} /></Suspense></div>);
}// 异步数据获取组件
async function ProductDetails({ productId }) {// 模拟数据获取const product = await fetchProduct(productId);const inventory = await fetchInventory(productId);return (<div className="product-details"><ProductImages images={product.images} /><ProductInfo product={product} /><InventoryStatus inventory={inventory} /><AddToCart product={product} /></div>);
}async function ProductRecommendations({ productId }) {const recommendations = await fetchRecommendations(productId);return <RecommendationGrid products={recommendations} />;
}async function ProductReviews({ productId }) {const reviews = await fetchReviews(productId);return <ReviewsList reviews={reviews} />;
}
✅ 最快的首屏显示时间
❌ 服务器配置复杂,错误处理复杂
适用场景: 对首屏加载速度要求极高的应用、慢网络环境、大型内容网站
一旦组件流式传输到客户端,就可以对其进行水合,因为我们不再需要等待所有 JavaScript 加载才能开始水合,并且可以在所有组件都完成水合之前开始与应用程序交互。