前端渲染方式之SSR渲染
前端渲染方式之SSR渲染
月晕SSR 介绍
什么是 SSR
指 HTML 在服务端渲染完成,发送到浏览器,由浏览器去绑定事件状态和交互,形成现代前端的完整页面,给浏览器 http 请求直接提供有内容的 html 结果页面
比起 传统 CSR(Client Slider Rendering) 首先要请求 html,请求 js,再次请求数据最终形成 html 页面,SSR 让其页面直出的效果体验很好
在现代的 SSR 一般指的是同构 SSR 的解决方案比如 Nextjs 或者 Nuxtjs 等的,下面我们的讨论以 React 框架中的 server 为代表
为了完成直接渲染的 React 组件转成 HTML 在 node 端,我们要使用到 React 中提供的renderToString
API, 如下
1 | import { useState } from 'react' |
当服务端收到请求执行renderHtml
方法后,返回如下<h1>Explosion!!</h1><button>点我自增 <!-- --><!-- --> times</button>
会打上标记
此时客户端接受到 HTML 就即刻开始渲染首屏,但是渲染之后,会存在一个问题是按钮绑定的onClick
方法并没有点击响应
因为 SSR 要先进行水合
水合(Hydrate)
在服务端返回的 HTML 类似于一种骨架,用户可以快速的预览,但不具备任何的交互能力,所以需要浏览器去下载 js 把 HTML 转化成有交互性的,一般水合的过程如下
- 解析和执行 JavaScript: 当浏览器加载并解析 HTML 页面时,遇到页面中的
<script>
标签,这些标签通常引用打包好的 JavaScript 文件,浏览器会下载并执行这些 JavaScript 文件。这个过程使得 React 组件的逻辑在客户端启动 - React 组件激活: 在 JavaScript 执行过程中,React 会在客户端重新初始化它的组件。此时,React 通过虚拟 DOM 将当前的页面结构与服务器渲染的 HTML 进行对比,如果保持一致,则直接将事件监听器与其他的交互逻辑绑定在已有的 DOM 上,即水合的核心
- 页面可交互:
实践例子
项目背景介绍
比如在 MIX CSR 的场景下,一个丰富的电商平台或者是个人博客之类的,下面假设为一个电商的移动端,如果页面存在复杂的组件,耗时会很长,解析并执行 bundle js 的时间最长,阻塞了首屏渲染
渲染流程分析
一个商品类页面的加载可以拆解成服务端加载耗时和客户端加载耗时
- server 是一个聚合的接口,去获取页面组件结构和组件数据的职责,整体耗时取决于最慢的服务
用 SSR 改造之后,在组件对应服务器执行完成后就开始渲染,而不是等待
优化原理
在 React18 中为 SSR 提供了两个重要的支持:RSC(React Server Component) 和 流式渲染
流式渲染
服务端将 HTML 分段传输给浏览器,不需要等待服务器生成整个页面后在返回给浏览器,这样可以更快的启动 HTML 渲染可以有效的提高 FCP、FMP 等性能指标
流式渲染早在 React16 就已经被支持,但是只能是自上而下的 DOM 渲染,不能对组件渲染,不发控制优先级和细腻度
在 React18 中提出了 Suspense + Data fetching 的组合,可以更加细腻度的决定渲染
如下
1 | import { fetchData } from '@/api' |
过程:
fetchData()
可以在服务端执行- 服务端请求完成前,返回
fallback
的组件,流式推送给客户端 - 完成后,Suspense 会携带数据,其 html 片段被推送给客户端替换之前的 fallback
流式渲染可以使组件独立在服务端发起请求并在取得结果后立刻渲染在页面上,而不受其他组件干扰
RSC
RSC 是 react 服务端渲染组件,在整个 SSR 的渲染流程中,仅在服务端运行一次,生成一段类 HTML 脚本返回给客户端,针对这个组件做了renderToString
,所以没有浏览器的方法也没有状态等的
不需要水合、在客户端也不需要加载或执行任何的 JS 资源
1 | import db from 'store' |
- 在服务端执行,拥有 nodejs 环境
- 只执行一次,副作用直接在 render function 中去执行,可以使用 async
RSC 的 AST 最后会和其他普通的 React 组件共同渲染出整个页面
实际应用
在一些复杂场景下,可以将页面与组件的数据请求工作包装成 RSC,并可以使用更快的 RPC 去 fetch 数据
这样做的好处有
- 减少页面 JS 资源体积,优化水合速度,将耗时的渲染工作放在服务端完成,降低水合过程中对低端设备的依赖
- 请求数据变得更快
React中的渲染发展
从最初的客户端渲染(CSR),发展到服务端渲染(SSR),紧接着是静态站点生成的SSG,随后到增量的静态再生(ISR),目前又比较流行的部分预渲染(PPR)
单一渲染模式
CSR(Client Side Rendering)
最早的现代web端渲染就是CSR,指的是在浏览器端使用JavaScript来渲染页面内容
服务发生无内容的HTML文件 –> 浏览器下载并执行JavaScript文件 –> 动态创建DOM将元素插入到页面中 –> 额外数据发起API请求,更新DOM完成真正的渲染
但是在最原始的阶段(前端后不分离的时期),其实是一种原始的SSR
- 静态HTML阶段:最初的Web页面就是纯静态的HTML文件,所有内容都是预先编写好的。没有JavaScript,这是最初始互联网超链接的形态
- 动态HTML阶段:这个阶段前端有了一些基本的动画交互,并且数据也不仅仅是静态的超链接,可能是服务器端返回的一些数据,大部分是使用PHP、JSP等技术手段,在服务端生成HTML内容。其实这才是最早的服务器端渲染(Server-Side Rendering)
- AJAX(Async JavaScript and XML):不刷新页面的情况下更新部分内容(最要的过渡阶段)
- 客户端渲染(CSR):随着JavaScript框架(如AngularJS、React、Vue等)的出现和普及,CSR成为主流的前端渲染模式
CSR的优势在于
优点:SPA更好的用户体验、减少服务器的负载(不需要提供渲染工作)、前后端分离
缺点:初始化loading时间长、SEO效果差、无法兼容低性能设备
SSR(Server-Slide Rendering)
现代SSR工作流程
用户请求页面服务器会执行JavaScript代码,生成完整的HTML内容 –> 服务器将这个预渲染的HTML发送给浏览器,用户可以立即看到页面内容,而不是空白页面 –> 同时,服务器还会发送必要的JavaScript代码 –> 浏览器加载并且在客户端水合,接管后续的交互和动态渲染
1 | // server.js |
与原始的SSR区别在于
- hydration(水合过程):现在SSR的明显特点就是存在hydration过程,客户端接受服务端渲染后,会水合这些静态内容,变成可交互的动态应用
- 组件化架构:和传统的服务端返回HTML不同的是,无论页面是不是在服务端渲染,都是采用同样的基于组件的开发范式,服务端渲染的是组件树,而不是模板
- 复杂状态管理:现代SSR框架通常提供了复杂的状态管理解决方案,可以在服务器端预填充状态,然后在客户端无缝接管。
缺点:服务器负载,开发更加复杂(保证同一套代码在客户端和服务端运行),状态同步问题多
SSG(Static Site Generation)
有没有一种既不需要服务器负载维护,又可以有良好SEO的手段呢?
SSG - 在构建时生成完成的静态HTML页面技术
在构建过程中,SSG工具会遍历所有的路由和页面 –> 对于每个页面会获取必要的数据 –> 生成完成的HTML页面 –> 保存部署到CDN上
这种方式虽然好,但是不合适频繁更新的动态内容、构建时间可能比较长、无法处理用户的特定动态内容(文档,博客常用)
混合渲染
对于一个大型的复杂站点来说,单一渲染往往不是最佳的解决方案,我们根据不同的诉求有不同的渲染
- 静态内容:适合SSG
- 动态且与内容强相关的:SSR
- 高度交互个性化的内容:CSR
以React为例,看看是怎么一步一步发展到混合渲染的模式
React发展
React Fiber架构
在Fiber架构之前 React的渲染流程其实是一个递归的过程,组件需要更新的时候,React会从该组件开始,递归地遍历整个组件树,处理每个树的节点
在递归过程中会产生很多问题,比如很严重的性能问题,占用主线程过多的时间,导致事件循环阻塞,没法中断
在React16引入了新的Fiber结构,支持更好的增量渲染、优先级的调度、错误处理、并发模式、批处理等
Fiber 允许 React 执行增量渲染:能够将渲染工作分割成小的单元,然后分批执行。
主要分成 Reconcilation 阶段:可以被中断,调用生命周期,计算差异diff等,Commit阶段:不可被打断,执行实际DOM更新
React Supense
Suspense是React 16.6引入的特性,它允许组件在渲染之前”等待“某些操作。最早的实现是配合lazy一起使用的,主要是为了解决代码分割(code splitting)的问题
1 | import React, { Suspense } from 'react'; |
在上面的过程中
- LazyComponent是通过React.lazy()动态导入的。
- Suspense包裹了LazyComponent,提供了一个fallback UI(在这里是一个”Loading…”的div)。
- 当LazyComponent正在加载时,会显示fallback UI。
- 一旦LazyComponent加载完成,fallback UI会被替换为实际的组件内容。
- 实际上LazyComponent代码并不会打包进主包,因为已经是异步加载了的。
原理其实就是
- 抛出Promise:当一个组件需要等待某些数据时,它会抛出一个 Promise。这个 Promise 代表了正在进行的异步操作。
- 捕获Promise:Suspense 组件会捕获这个 Promise,并渲染指定的 fallback 内容。
- 恢复渲染:当 Promise resolve 后,React 会重新尝试渲染,这时数据已经可用,组件可以正常渲染。
即:
- 遇到 Supspense 边界时,React会创建一个特殊的Fiber节点
- 子树抛出Promise,React将这个Promise附加到Supspense Fiber节点上
- React渲染 fallback的内容,在回调在Promise resolve 的时候重新渲染
和Boundary 其实原理一样,只要代码异步,就可以实现延迟加载
在React 18版本中,Suspense 的功能得到了进一步的扩展和增强,允许在服务器端使用 Suspense 进行组件的异步渲染。这意味着,我们不仅仅可以利用Suspense做到异步组件,还可以利用Suspense获取异步数据
Streaming Rendering(流式渲染)
React 16.0实际上就已经支持Streaming SSR了,引入了ReactDOMServer.renderToNodeStream()
方法。这个方法其实就是将 React 元素树转换为一个 Node.js Readable Stream。这个流包含了渲染后的 HTML 字符串。
1 | function renderToNodeStream(element) { |
使用nodejs的可读流,渲染过程中被分解成多个小任务,逐步将渲染的内容推送到流中
React 18也特地增强了流式API:renderToPipeableStream():成熟的streaming SSR以及Suspense的彻底支持。
1 | import React, { Suspense } from 'react'; |
React Server Component (RSC)
RSC可以真正让SSR不在以HTML为单位,而是根据Component维度进行服务端渲染,RSC只在服务端运行,数据获取和渲染都在服务端,不用打包到客户端,大幅度减小客户端代码的体积
怎么做到以组件为粒度进行的服务端渲染呢?
服务器渲染RSC树,将结果序列化为一种特殊格式(React Server Component Payload)。这个payload是二进制的,当然我们可以简单理解为以下数据结构
1 | { |
客户端在拿到这个payload时候,动态的hydrate到当前的渲染树当中,从而完成客户端的动态更新。
一个RSC看起来是这样的:
1 | // Page.server.js |
这个组件在服务端执行完,实际上已经是拿到了post数据,客户端直接hydrate即可。
注意RSC是无状态的,不能使用 hooks 或保持内部状态。也不能直接添加事件处理程序。
RSC最大的价值还是在于能开启CSR和SSR的混合渲染,借助Suspense,做到CSR和SSR的局部渲染方式:
1 | import { Suspense } from 'react'; |
Use Client ?
当我们使用RSC时,它是不允许浏览器的运行API的(比如click事件、hook、state等),如果要用这些特性,需要在文件首行标记“use client”,这样在构建的过程中,就会bundle到js里面交给浏览器去执行。
但实际上,一个令人惊讶的事实是:标记为use client的组件依然会在服务端上运行
在RSC出现之前,SSR的心智模型如下
在服务端,实际上是调用了类似renderToString的方法,将Component转化为HTML,并且通过构建的方式,将组件的代码打包到main.js中,供浏览器加载,执行JS逻辑。实际上,一个组件有两个输出:
- 输出HTML给浏览器
- 输出main.js(通过构建) 被浏览器加载执行
RSC出现后,实际上并没有修改这个心智模型,只是在上面又加了一层
多个RSC实际上会形成一个Server Tree,区分于非RSC组件,这个Server Tree没有任何JS需要在客户端执行(当然微观来看还是有一个updateDOM的过程,可以忽略),然后RSC的子组件可以是一个客户端组件,这个时候它可以将Props传给客户端组件(当然这个props是需要可序列化的)
老的SSR心智,就是标记为”use client”的这些组件,它在Nextjs这样服务器优先的框架上,依旧是服务端渲染的,它组成了一颗Client Tree进行hydrate,它的代码会被打包到main.js,由浏览器加载执行
“use client”会标记一个组件为”客户端组件”,这个客户端组件不是”物理意义“上的客户端组件,它不代表是采用CSR渲染,它代表的是这个组件会有一些客户端行为,需要在浏览器执行
由于RSC的各种优势,新的React范式推荐自顶向下应该尽可能采用RSC,只有在叶子结点,必要时才用客户端组件(标记为”use client”)。
另外,一个组件一旦被标记为client,那么它的子组件也会自动变成client
Use sever ??
并不是声明是服务端组件,而是用来标记Server Action行为的
- “use client”标记客户端组件,但不代表它不会在服务器执行。
- “use server”不是用来标记服务端组件,RSC不用任何标注,它是用来标注Server Action的。
NextJs的渲染策略
NextJS中的ISR策略
- 构建时:
- Web服务器从数据源或API获取初始数据。
- 使用这些数据生成静态页面。
- 将生成的静态页面部署到CDN或边缘服务器。
- 首次请求(页面未过期):
- 用户向CDN请求页面。
- CDN检查页面是否过期。
- 如果未过期,CDN直接返回静态页面给用户。
- 页面过期后的请求:
- 用户再次请求页面。
- CDN检查并发现页面已过期。
- CDN仍然返回旧版本的静态页面给用户,确保快速响应。
- 同时,CDN触发Web服务器重新生成页面。
- Web服务器从数据源获取最新数据。
- Web服务器使用最新数据重新生成页面。
- 更新后的页面被发送回CDN。
- 更新后的请求:
- 下一个用户请求页面。
- CDN检查页面,发现是最新的。
- CDN返回更新后的静态页面给用户。
NextJS中的PPR策略
PPR是Partial Prerendering,如果把ISR比作旧时期的SSR,那可以把PPR比作新时代的RSC。
怎么理解呢?
我们可以看到,ISR策略虽然看上去是混合了SSG+SSR的模式,但是实际上它的粒度是整个页面为维度的,意味着整个HTML都会在revalidate时效到期之后重新生成替换,意味着其实页面的动态性还是比较弱,如果这个页面包含一些用户相关的组件,那么整个页面就无法使用ISR的模式,全都降级成SSR(这里指的是有RSC+Streaming的SSR)了。
PPR相当于是支持局部渲染静态组件和动态组件,它的简单原理如下:
- 在构建时,生成页面的静态部分(shell)。
- 为动态内容预留位置(holes)。
- 当用户请求页面时:
- 立即发送静态shell。
- 同时在服务器端开始准备动态内容。
- 动态内容准备好后,通过流式传输填充到客户端的holes中