手把手带你实现 Vite+React 的简易 SSR 改造【含部分原理讲解】

1、前言

最近工作中接触到了SSR(服务端渲染),想必大家肯定对这个技术名词肯定不陌生。SSR也不是什么新兴技术,远古时代的JSP便是一种天然的服务端渲染。但随着AJAX技术的成熟以及各种前端框架(如VueReact)的兴起,前后端分离的开发模式逐渐成为常态,前端只负责页面UI及逻辑的开发,而服务端只负责提供数据接口,这种开发方式下的页面渲染也叫客户端渲染(Client Side Render,简称CSR)。现代前端工具也提供了诸多SSR方案,如ReactNextVueNuxt以及ViteWebpackSSR模式。

字面意思上就有看出,这两种渲染方式的差别就在于页面渲染的时机:

  • 服务端渲染是页面在服务端的时候就渲染完成了;
  • 而客户端渲染是页面在客户端(浏览器或者WebView之类的)进行渲染。

其它关于这两种渲染方式的介绍以及优缺点不再此处赘述,可以自行搜索或者询问伟大的AI或者阅读这篇博客【一文搞懂:什么是SSR、SSG、CSR?前端渲染技术全解析】。

下面手把手带你实现 Vite+React 的 SSR 服务端渲染,代码已上传至仓库【react-ssr-demo】,SSR改造前的代码(即按照我的这篇博客【我的 Vite + React + TS 前端工程化配置实践】设置的一个工程模板)在分支【CSR_VERSION】。

2、SSR改造实现

读者可以拿我上面提到的SSR改造前的代码【CSR_VERSION】跟着一起进行改造,改造的方法来源于官方文档

2-1 CSR的逻辑

将SSR改造前的代码【CSR_VERSION】运行起来可以看到CSR下服务端确实只会返回空html页面: 然后在客户端重新加载JS脚本并执行之后才看到完整的页面,这部分耗时是SSR相比于CSR优化的部分:

2-2 SSR改造的一些逻辑讲解

改造作出的代码变更可看这次提交:【feat: SSR代码上传】,主要增加或修改了下面这五个文件:

核心做了以下工作:

  • 修改package.json,增加运行服务端渲染脚本server.js的命令。
  • 增加server.js,服务端渲染的核心逻辑:1、监听端口;2、区分生产环境还是开发环境,读取对应环境下的文件并执行;3、将执行结果和空index.html进行文档拼接(即服务端渲染);4、将拼接后的html文件返回。
  • 修改index.html文件,增加<!--app-head--><!--app-html-->插槽方便文档拼接,同时将加载main.tsx文件改为加载entry-client.tsx文件用于文档水合。
  • 增加entry-server.tsx文件,用于服务端渲染,核心是预取数据后利用renderToString API将组件渲染为字符串,组件的具体内容便就此生成了。
  • 增加entry-client.tsx文件,用于水合使得页面具有交互能力,核心是利用hydrateRoot API使得页面根据已有的预取数据重新执行CSRJavaScript代码来进行页面注水。

可以看到,服务端渲染时请求返回的文档是一个只有样式和节点的文档,俗称"脱水页面",没有JS逻辑导致页面没有交互能力:

客户端JS加载完成后,会运行react,并且执行同构方法ReactDOM.hydrate,而不是平时用的 ReactDOM.render

react-dom提供的hydrate方法类似render方法,用于二次渲染。

它在渲染的时候会复用原本已经存在的DOM节点,减少重新生成节点以及删除原本DOM节点的开销,只进行事件处理绑定。

hydraterender的区别就是hydrate会复用已有节点,render会重新渲染全部节点。

所以hydrate主要用于二次渲染服务端渲染的节点,提高首次加载体验。

注水完成之后的页面才是可交互的。

2-3 从开发环境与生产环境的不同表现谈到Vite中CSS模块化的处理

2-3-1 表现

改造后的工程运行开发环境(默认)下的效果执行以下命令即可:

bash 复制代码
pnpm install
pnpm start

若想运行生产环境的效果,则手动将环境判断取反后再执行以下命令即可:

bash 复制代码
pnpm run build
pnpm start

笔者刚开始改造时在开发环境下运行发现页面会闪一下,而生产环境下的就不会:

然后经过问题定位发现开发环境下服务端渲染返回的文档根本没有样式文件的加载!!!只有水合之后才进行样式文件的加载:

而生产环境中运行是立即引入了样式了:

2-3-2 原因

这是为什么呢???答案就在下面!!!继续往下看吧。

Vite内置了对CSS的支持,并提供了高效的加载、模块化和热更新机制。处理CSS文件的过程:

  • 读取CSS文件
    ViteJavaScript模块中检测到对CSS文件的导入(例如import './index.css'),它会使用Node.jsfs模块读取该CSS文件的内容。
  • 处理CSS内容Vite在处理CSS时,根据开发环境和生产环境采取不同的策略:
    • 开发模式 (动态创建<style>标签):
      为了实现快速的热更新,Vite会将CSS文件转换为JavaScript模块动态注入到浏览器中。这部分JS脚本的作用是创建一个<style>标签。 将CSS内容插入到该 <style>标签中,然后再将<style>标签动态插入到HTML<head>中。主要目的是为了支持模块化和热更新,这是开发体验的设计,通过将CSS封装在JavaScript模块中,可以更好地控制样式的应用和更新。 这就是我们上面的开发模式下会产生页面闪烁的原因!!!
    • 生产模式优化 (提取到单独的.css文件):
      在生产模式下,Vite会将所有CSS文件提取到单独的 .css文件中,并通过<link>标签在HTML文件中引入。这种方式有以下优点:1、更好的缓存: 浏览器可以单独缓存CSS文件,提高页面加载速度。2、减少JavaScript包体积:将CSSJavaScript包中分离出来,减小 JavaScript包的体积。3、更好的性能:浏览器可以并行加载CSS文件,提高页面渲染速度。

2-3-3 解决

我的解决方式是手动增加开发环境下的样式文件处理,下面是我的entry-server.tsx文件

tsx 复制代码
import { StrictMode } from 'react';
import { renderToString } from 'react-dom/server';

import Home from '@/pages/home';

// 从 /src/**/*.less 中导入所有的 CSS 资源,并生成 <link> 标签
// 因为vite开发环境中样式的实现是通过创建一个 <style> 标签,将 CSS 内容插入到该 <style> 标签中, 然后再将 <style> 标签动态插入到 HTML 的 <head> 中。(即通过执行 JS 代码实现)
// 因此在服务端渲染时,需要手动将 CSS 链接插入到 HTML 的 <head> 中,使得开发环境中的样式生效
const cssAssets = import.meta.glob('/src/**/*.less', { eager: true, as: 'url' });
const cssLinks = Object.values(cssAssets)
	.map(url => `<link rel="stylesheet" href="${url}">`)
	.join('');

// 将 Home 组件渲染为 HTML 字符串
export function render() {
	const html = renderToString(
		<StrictMode>
			<Home />
		</StrictMode>
	);
	return {
		html, // 将 HTML 字符串插入到模板的 <!--app-html-->
		head: cssLinks // 将 CSS 链接插入到模板的 <!--app-head-->,使得开发环境中的样式生效
	};
}

可以看到,我增加了手动读取样式文件之后,此时在水合之后便已有样式文件,页面不再闪烁:

注意,如果采用CSS-in-JS这种比较特殊的样式方案,则上面这种处理可能无效,下面是我问伟大的DeepSeek这种样式方案下的处理方法,但不知道有没有用,待读者去验证探索了:

3、实际生产中SSR需要注意的点

上面我的SSR改造工程只是一个很简单的原理讲解Demo,实际生产中需要注意以下诸多问题:

  • CSR降级 。在某些比较极端的情况下,我们需要降级到 CSR,也就是客户端渲染。一般而言包括如下的降级场景:1、服务器端预取数据失败,需要降级到客户端获取数据;2、服务器出现异常,需要返回兜底的CSR模板,完全降级为CSR。3、本地开发调试,有时需要跳过SSR,仅进行CSR。笔者在日常生产中,手动或者被动降级后的处理都是在useEffect中(回到客户端了)判断有无数据,没有便需要手动再重新请求一次。SSR开关控制入口推荐在页面拼上类似SSR=true的参数实现。具体的降级处理待读者去探索,此处不再展开赘述。
  • <ClientOnly>功能 。类似于Nuxt.jsclient-only组件用于仅在客户端渲染的组件。这对于那些依赖于浏览器 API 或仅在客户端环境中可用的功能(如WindowDocument对象)的组件非常有用。
    • 注意服务端渲染时不能调用客户端才能用的API。笔者日常工作中接触到的前端页面基本都是H5页面,这种情况比较多,比如某个组件需要根据设备类型渲染不同的样式拿到视窗尺寸才进行不同的渲染等。
    • 部分不在首屏的组件没必要走SSR渲染,比如一些弹窗组件、浮层半浮层组件、抽屉组件等,因为这部分组件可能是相对于当前document.body进行定位,即使服务端渲染模拟了Document对象,也不能挂载DOM
    • 如果有业务逻辑非常依赖一些本来在客户端才能调用的API返回的结果(原本CSR下的的JSBridge暴露的能力),比如位置信息等,如果没有可能导致服务端渲染和客户端渲染不一致,产生服务端渲染前的预取数据的接口不通或者页面水合不一致闪烁问题。此时可能需要在服务端渲染环境再手动模拟实现一次。
    • 在进行同构渲染的时候,请务必保证客户端渲染出来的内容和服务端渲染的内容完全相同。如果客户端和服务端渲染出来的内容不一致,React会尝试对不一致的地方进行修复,而这些修复是非常耗时的。如果差异过大甚至会重新渲染整个应用(类似于ReactDOM.render)。
  • 流式SSR+FCC优化
    • 流式SSRStreaming Server-Side Rendering)是一种将服务端渲染和流式传输结合起来的技术。与传统的SSR不同,流式SSR可以在服务端渲染的同时,逐步将渲染结果传输到客户端,实现页面的渐进式展示。在流式SSR中,服务端会根据客户端的请求,逐步生成页面内容,并将它们作为流式数据流式传输到客户端。客户端可以在接收到一部分数据后,就开始逐步显示页面,而不需要等待整个页面渲染完成。这种方式可以有效提高页面的加载速度和用户体验。
    • FCC指的是(first chunk cache),一般用于移动端H5页面的流式SSR方案。FCC需要Native客户端配合,将首chunk缓存到本地,二开时享受极致的FCP(首chunk也可能是骨架屏,此时是避免二开时启动Webview容器时的白屏),用户体验较好。FCC比较适用于动态请求比较少的流式SSR,会有分段上屏的效果。
    • 当然对于移动端H5页面的首屏性能优化,Webview容器相关的优化也是很重要的一点了,比如Webview容器预热复用甚至借助Webview容器进行snapshot快照优化(snapshot比较适用于动态请求较多,性能较好的机器,先有一个snapshot占位,后渲染真实内容),当然这是另外的技术话题了,此处不再展开赘述。
  • SSR调试工具链的完善 。有一说一,笔者在日常工作中的SSR实践中发现SSR调试起来确实稍微困难一点,相关工具链的完善确实很重要,比如SSR下的抓包、性能监控、降级情况获取、流式SSR的各个chunk到达时间获取等工具。
相关推荐
不能只会打代码39 分钟前
六十天前端强化训练之第十七天React Hooks 入门:useState 深度解析
前端·javascript·react.js
PagiHi1 小时前
iWebOffice2015 中间件如何在Chrome107及之后的高版本中加载
前端·javascript·chrome·中间件·edge·js
曾富贵1 小时前
【eslint 插件】导入语句排序
前端·eslint
NaZiMeKiY1 小时前
HTML5前端第八章节
前端·html·html5
远之喵2 小时前
js基础知识-考点
前端
如此风景2 小时前
TS装饰器
前端
鱼樱前端2 小时前
React完整学习指南:从入门到精通(从根class->本hooks)16-19完整对比
前端·react.js
紫琪软件工作室2 小时前
ElementUI 级联选择器el-cascader启用选择任意一级选项,选中后关闭下拉框
前端·elementui·vue
思想永无止境2 小时前
vue elementUI组件国际化
前端·vue.js·elementui
_Lok2 小时前
Element Plus性能优化实战:从卡顿到流畅的进阶指南
前端