点个关注不迷路!公众号【码间舞】会持续更新技术干货、一些体悟,感谢大家支持,欢迎点赞、评论、留言。
概念解释
服务端渲染(Server-Side Rendering,SSR)是一种由服务器直接生成完整 HTML 页面并发送给浏览器的技术。它与传统的客户端渲染(Client-Side Rendering,CSR)不同(如纯 React SPA),后者是由浏览器下载空的 HTML 框架和 JavaScript 包,然后由 JavaScript 在客户端动态构建页面。
服务端渲染的核心特点与优势
在传统的SPA应用里,无论是React和Vue,都有一个面试官经常问到的缺点:【如何提高首屏加载速度】,是的SPA应用的首屏加载速度是他的一个缺陷之一。因为SPA的应用返回的是一个空文件,需要通过里面链接的Js模块来加载页面。按照顺序一个一个请求,如果你的应用分包特别多(通常是为了把打包拆成小包降低单包过大问题)那么请求就会特别多,最后还要加载图片、css、iconfont等静态资源。如果用户网络不佳,体验就极为拉胯!
这个时候就需要SSR出马了,它的优势:
-
首屏加载更快: 浏览器收到的是完整的 HTML,能立即展示内容,无需等待 JavaScript 下载、解析和执行完毕才开始渲染。用户感知速度更快。
-
更好的 SEO: 搜索引擎爬虫可以直接抓取到服务器返回的完整 HTML 内容,更容易理解和索引页面内容。这对于依赖搜索引擎流量的应用至关重要。
-
更佳的用户体验: 在弱网环境或低端设备上,用户能更快看到内容,减少白屏时间。
-
社交分享友好: 社交媒体爬虫(如 Facebook, Twitter)也能获取到完整的页面内容,生成正确的预览卡片。
SSR需要几个服务器?
通常只需要一个服务器(Node.js 服务器): 这是最常见的架构。你的 Node.js 应用既负责处理 API 请求(或者代理到后端 API),也负责执行服务端渲染 React 应用的逻辑。
可能的架构变体:
- 同构 JavaScript 应用: 一个 Node.js 服务器处理所有(前端路由、API 请求、渲染。比如NextJs)。
- 前端服务器 + 后端 API 服务器:
- 一个 Node.js 服务器 专门负责服务端渲染和提供静态资源(SSR Server)。
- 一个或多个独立的 API 服务器(可以用 Node.js、Java、Python、Go 等编写)提供业务数据接口。
- SSR Server 在渲染时会调用这些后端 API 来获取渲染所需的数据。
- 使用 Serverless 函数: 服务端渲染逻辑可以部署为云函数(如 AWS Lambda, Vercel Serverless Functions, Netlify Functions),按需执行。这时“服务器”的概念是动态的、无状态的。
- 边缘渲染: 将 SSR 逻辑部署到 CDN 的边缘节点(如 Cloudflare Workers, Vercel Edge Functions),靠近用户,进一步减少延迟。
实现 SSR 的核心逻辑只需要一个能运行 JavaScript (Node.js) 的环境。这个环境可以是一个传统的常驻服务器、一个容器、一个 Serverless 函数或一个边缘计算节点。是否需要额外的服务器取决于你的后端服务是如何设计的。最常见的是:一个 Node.js SSR 服务器 + 一个或多个独立的后端 API 服务器。
SSR渲染需要做哪些操作?
在 React 项目中实现 SSR 涉及较多步骤,通常称为构建“同构应用”(Isomorphic App)或“通用应用”(Universal App),指代码能在客户端和服务器端运行。以下是关键步骤和操作
设置 Node.js 服务器:
-
选择框架:Express、Koa、Fastify 或使用更高级的 SSR 框架(如 Next.js, Gatsby, Remix,它们极大简化了 SSR 配置)。
-
创建服务器实例,监听 HTTP 请求。
配置 Babel 和 Webpack:
-
需要配置 Webpack 分别打包客户端代码和服务器端代码(通常有两个配置文件:webpack.client.config.js 和 webpack.server.config.js)。
-
服务器端打包:目标环境是 node,需要处理 .jsx/.tsx,可能需要排除 Node 内置模块或某些仅限客户端的库(如依赖 window 的库),输出 CommonJS 模块。
-
客户端打包:与常规 React SPA 打包类似,目标环境是 web。
-
使用 Babel 确保服务器端也能理解 JSX 和 ES6+ 语法。
创建 React 应用的根组件: 这是你的主 App 组件,定义应用的路由和结构。需要确保它能在服务器端和客户端都能运行(避免直接使用 window, document 等浏览器特有对象)。
实现路由同构:
- 使用 react-router-dom。
- 服务器端路由: 使用 包裹你的应用。服务器需要根据请求的 URL 路径,将正确的路由信息传递给 。
- 客户端路由: 使用 或 ,与常规 SPA 相同。
- 服务器需要处理所有可能的前端路由(/*),将它们指向同一个入口点,由 React Router 在服务器端匹配并渲染对应的组件。
数据预取(Data Fetching):
-
核心挑战: 确保组件在服务器端渲染前就获取到所需的数据,并将这些数据“脱水”(dehydrate)到 HTML 中,以便客户端能“注水”(hydrate)复用,避免客户端重新加载相同数据导致闪烁。
-
常用方法:
-
在路由组件上定义静态方法: 如 getInitialProps (Next.js v9 前)、getServerSideProps (Next.js)、load (Remix)。服务器在渲染该路由前会调用这个方法来获取数据。
-
使用状态管理库: 如 Redux, MobX, React Query, SWR。服务器端需要创建 store 实例、发起数据请求、将获取到的完整状态序列化(JSON.stringify)并嵌入到 HTML 响应中(例如放在 <script> 标签里 window.PRELOADED_STATE = ...)。客户端在初始化时读取这个状态并注入到客户端的 store 中,保证初始状态一致。
-
-
服务器端执行: 在调用 ReactDOMServer.renderToString() 或 renderToPipeableStream() 之前,需要根据匹配的路由组件,执行其数据获取方法,并等待数据返回。
服务器端渲染:
-
使用 react-dom/server 提供的 API:
- ReactDOMServer.renderToString(element): 将 React 组件树渲染成 HTML 字符串。这是最常用的方法。
- ReactDOMServer.renderToPipeableStream(element): 更现代的 API,使用 Node.js 流(Stream)进行渲染,支持 Suspense 和渐进式渲染,有助于提高 TTFB(首字节时间)和大型应用性能。
-
将渲染得到的 HTML 字符串插入到一个 HTML 模板中。这个模板通常包含:
- <html>, <head>, <body> 结构。
- <div id="root">...</div> 容器,用于放置服务器渲染的 HTML。
- 嵌入客户端 JavaScript 包的 <script> 标签。
- 嵌入预取数据(序列化后的状态)。
- 必要的 <link>(CSS)和 <meta> 标签。
客户端注水(Hydration):
- 客户端打包的 JavaScript 启动后,需要调用 ReactDOM.hydrateRoot()(React 18+)或 ReactDOM.hydrate()(React 17 及之前)。
- hydrateRoot(document.getElementById('root'), <App />):React 会将服务器渲染的 HTML 与客户端的 React 组件树进行“关联”(对比)。它会复用已有的 DOM 节点,而不是完全重新创建,并将事件监听器附加上去。同时,它会使用嵌入在 HTML 中的预取数据初始化 store/state,确保客户端应用从服务器停止的地方无缝继续运行(成为交互式 SPA)。
- 关键点: 服务器端和客户端渲染的初始组件树(在 renderToString 和 hydrateRoot 时)必须输出完全相同的结构。否则会导致 hydrate 失败,出现错误或客户端重新渲染整个树,失去 SSR 的性能优势。
处理资源(CSS/Images/Fonts):
- 需要配置 Webpack 的 css-loader/style-loader 或 MiniCssExtractPlugin 来处理 CSS。服务器端渲染时,可能需要使用 isomorphic-style-loader 或类似方案将组件级 CSS 也内联到 HTML 中,避免 FOUC(无样式内容闪烁)。
- 确保图片、字体等静态资源路径正确,通常由 Webpack 处理(file-loader, url-loader)或配置公共路径。
处理 Head 标签(如 title, meta):
- 使用像 react-helmet 或 react-helmet-async 这样的库,允许组件(尤其是路由组件)动态设置 <head> 内的内容(标题、描述、关键词等)。
- 服务器端渲染后,需要调用 Helmet.renderStatic() 来获取这些 head 信息,并将它们插入到最终的 HTML 模板中,这对 SEO 至关重要。
处理代码分割(Code Splitting):
- 使用 React.lazy + Suspense 进行组件级代码分割。
- 服务器端渲染需要特殊处理以确保分割的块也能被正确加载。通常需要记录哪些块在服务器渲染过程中被用到(@loadable/components 或 react-loadable 等库可帮助实现此功能),并将这些块的 <script> 标签也包含在 HTML 响应中。
处理环境差异:
-
避免在组件顶层使用浏览器特有的 API(window, document, localStorage 等)。如果必须使用,将它们放在 useEffect 钩子或 componentDidMount 生命周期方法中(这些只在客户端执行)。
-
使用 typeof window === 'undefined' 来判断当前执行环境是服务器还是客户端。
构建与部署:
-
分别构建客户端包 (clientBundle.js) 和服务器包 (serverBundle.js)。
-
部署 Node.js 服务器(或 Serverless 函数)以及构建好的静态资源(客户端 JS/CSS/图片)到 CDN 或静态文件服务器。服务器程序需要能访问到构建好的 serverBundle.js。
问题1:什么是Hydration?
服务端渲染最终生成html到底什么样子的呢? 为什么已经生了html的结构了,到了客户端还需要与react组件树进行对比? 来看服务端生成的html代码(简化demo):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>产品列表 - SSR 示例</title>
<meta name="description" content="我们的热门产品列表">
<link rel="stylesheet" href="/static/main.css">
<script>
// 预加载数据(脱水数据)
window.__PRELOADED_STATE__ = {
products: [
{ id: 1, name: "无线耳机", price: 199 },
{ id: 2, name: "智能手表", price: 299 }
],
user: { name: "张三", isLoggedIn: true }
};
</script>
</head>
<body>
<!-- SSR 渲染的容器 -->
<div id="root">
<header>
<nav>
<a href="/">首页</a>
<a href="/products">产品</a>
<span>欢迎,张三</span>
</nav>
</header>
<main>
<h1>热门产品</h1>
<div class="product-list">
<div class="product">
<h2>无线耳机</h2>
<p>价格:¥199</p>
<button>加入购物车</button>
</div>
<div class="product">
<h2>智能手表</h2>
<p>价格:¥299</p>
<button>加入购物车</button>
</div>
</div>
</main>
<footer>© 2023 我的商店</footer>
</div>
<!-- 客户端 JavaScript -->
<script src="/static/client.bundle.js"></script>
</body>
</html>
关键特点:
- 完整的内容结构:包含所有产品信息的完整 HTML
- 预加载数据:window.PRELOADED_STATE 包含初始状态
- 无交互元素:按钮看起来存在,但还没有点击事件
- 客户端脚本:client.bundle.js 包含 React 应用代码
虽然服务端已经生成了完整的 HTML 结构,但客户端仍然需要执行 hydration(注水) 过程。hydrate的意思是水合物化,这里稍微有点抽象,用在这里意思就是:使项目具备SPA应用的响应式能力,活水化、灵动化的意思,说白了使项目具有可交互能力。
为什么客户端还需要进行"对比"(Hydration)?
-
添加交互功能
- 服务端生成的 HTML 是静态的,没有绑定任何事件处理器
- 例如:<button>加入购物车</button> 看起来是按钮,但点击没有任何反应
- Hydration 过程会为这些元素附加事件处理函数
-
连接虚拟 DOM
- 服务端只生成 HTML,没有组件实例
- Hydration 创建组件实例树并关联到现有 DOM
-
接管后续渲染
- 首次渲染后,所有后续更新由客户端 React 处理
- 例如:点击"加入购物车"后更新购物车数量
- 保持 SPA 的流畅体验
-
状态同步
- 确保客户端使用服务端预加载的初始状态
-
避免内容闪烁
- 直接使用 ReactDOM.render() 会替换整个 #root 内容
- Hydration 复用现有 DOM 节点,只添加交互能力
问题2 为什么是两个包?
在服务端渲染(SSR)中,需要分别打包服务端代码和客户端代码,这是因为两者在运行时环境和职责上有本质区别:
服务端 Bundle 的职责
- 渲染引擎:使用 ReactDOMServer 将 React 组件渲染为 HTML 字符串
- 数据获取:在渲染前获取所需数据(访问数据库/API)
- 路由处理:根据请求 URL 匹配正确的组件
- HTML 组装:将渲染结果嵌入 HTML 模板
- 状态脱水:将初始状态序列化到 HTML 中
客户端 Bundle 的职责
- Hydration:将静态 HTML "激活"为交互式应用
- 事件处理:添加点击、输入等交互功能
- 后续导航:处理客户端路由切换
- 动态更新:管理组件状态变化和 UI 更新
- 浏览器 API 集成:使用 localStorage、地理位置等浏览器特性
工作流对比:
无 Hydration 的情况(纯静态页面):
有 Hydration 的 SSR 流程:
服务端渲染生成的 HTML 只是应用的"静态躯壳",而客户端 Bundle 负责注入"交互灵魂"。两者协同工作才能提供完整的用户体验:快速的首屏加载 + 丰富的交互功能。
码字不易,望大家多关照,点点关注。感谢大家!