React SSR 101

这篇文章是博主在公司内的分享,选题目的时候随口胡诌了一个,但是已经被排上期了 XD

本文是非常基础的教程,只包含 SSR 的基础特性和原理,不涉及如何创建 SSR 项目、webpack 配置等内容

SSR 是什么?

SSR 是服务端渲染Server Side Rendering的简称。最早的 Web 应用是没有「前端」的概念的,网页的 HTML 是由后端服务渲染出来的 (PHP, ASP.NET, Java Servlet, etc),Web 最早的形态就是 SSR。

但这样的页面每次操作都会完整刷新整个页面,对那时候的超小带宽和 2G 网络来说有点过于超前了。后来有了 iframe 局部刷新和 ajax 等技术,有了对页面更多的需求(排版、动效、兼容性等)产生了专门的前端岗位,从而诞生了 CSR 的基础。

在 jQuery 的时代,CSR 的一些短板还没有显露出来,毕竟页面上的框架布局还是后端渲染/提前编写好的,初始内容大概率也是后端渲染到模版里的,只有增量更新操作会发送请求到服务器再局部更新页面。随着项目逐渐增大,依赖管理开始变得复杂,编写 jQuery 操作大量 DOM 也开始力不从心了,前端开发框架也应运而生。

当我们用 npx create-react-app 创建一个前端应用的时候,默认创建的是一个 CSR (Client Side Rendering) 应用。这种情况下,React 框架在浏览器端运行,加载渲染页面需要的后端数据、把组件渲染到 DOM 上。框架和组件的代码只在浏览器上运行。这样有一个弊端,由于整个页面的内容都是框架渲染出来的,用户需要白屏到加载完 js,再执行完首次渲染后才能得到一个 loading 的页面,接着采取请求后端获取数据,需要等待的时间太久了。

在 SSR 模式下,框架可以在服务器上先把组件渲染为 HTML 字符串 ,作为页面响应返回到浏览器。然后在浏览器把组件再次渲染得到 VDOM,把 VDOM 的结构和浏览器解析 <服务端渲染的 HTML> 产生的真实 DOM 进行绑定让它能够响应用户操作,之后用户的操作就完全交由浏览器响应。也就是说,SSR 除了首次请求是服务端渲染的,后续的用户操作跟 CSR 没有区别,但大幅减少了等待的时间。

Quiz: SSR 只能在浏览器上渲染吗? 答案留到后面

为什么要 SSR

更好的用户体验:首屏加载速度对网速慢或者配置低的设备非常重要,服务端渲染得到的 HTML 已经包含了页面的框架,不用等到 JS 下载完成才开始渲染。同时服务端渲染时可以一并进行数据获取,而不需要再等待一个额外的 RTT 来请求服务端获取数据,这样用户体验在延迟较高的移动网络会有明显的提升

https://skk.moe 为例,我们可以看到在首次收到 HTML 响应之后就开始了渲染,在 JS 资源加载完成之前就完成了 FCP/LCP,这样的体验是非常优秀的

https://oss.sem.ms/img/skk-perf.png

更好的 SEO:爬虫能够得到完整页面而不是一个 loading 的 HTML,更容易被被搜索引擎收录

相同的技术栈:可以在服务端使用熟悉的技术栈(Node.js: 我来力)实现服务端渲染,不用学习模版引擎、PHP 或者其他语言~~(面向上级:维护成本更低)~~

0JS 支持:在某些极端场景下客户端可能禁用了 JS,SSR 可以在这种情况下也能提供基本的页面展示(例如某洋葱网络浏览器

当然,SSR 也并不是没有缺点:

心智负担更重:由于组件会被渲染两次,一次在 Node.js 环境中,一次在浏览器环境中,开发者必须同时考虑组件在两端运行的情况,小心地保证组件是同时兼容两种运行时的;此外还有路由处理、全局状态污染、渲染动态内容 (如 i18n) 等小坑,开发需要消耗更多的注意力

带宽/性能浪费:如果不能恰当地编写服务端逻辑,就更容易造成带宽/性能的浪费,导致 SSR 的实际效果可能还不如 CSR(并且还不容易看出来):不小心在组件中引入了 node 的标准库导致产物中引入了不必要的 polyfill (例如 buffer, crypto)1 打包体积增大;一份数据在服务端渲染成 HTML 同时注入到 window 中在客户端再渲染一次,以「不同的形式」重复传输了两次;或者是忘记设置缓存,导致每个请求都会从服务端重新渲染

如果首屏速度或者 SEO 对网站是极其重要的,或者只是单纯想把用户体验做到最优,那么 SSR 是瑕不掩瑜的,否则请再思考一下 SSR 能够带来什么。

SSR 和 ... 的关系?

Server Components

在 React 18 中还引入了一个新的 RFC Server Component,Server Component 和 SSR 的共同点就是都在服务端运行,除此之外两者并不冲突,可以同时使用。一个 Server Component 写出来可能长这样

export async function MyServerComponent() {
  let res = await fetch('https://example.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return <div>{res.json().message}</div>
}
export async function MyServerComponent() {
  let res = await fetch('https://example.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return <div>{res.json().message}</div>
}

Server Componet 组件只在服务端渲染一次,把渲染结果传递到浏览器中,在浏览器中不会有“水合”的过程,因此用起来就是“一个返回 React 组件的一次性函数”,并且

  1. 可以用体积巨大的依赖,以及安全地使用密钥
  2. 可以直接使用 fetch / 连接数据库获取数据,因为 Server Component 只会在服务端渲染一次
  3. 不可以响应用户操作,没有生命周期
  4. 不可以被 Client Component 直接引用

SSG

那么 静态站点生成Static Site Generation 呢?SSG 可以简单理解为预渲染的 SSR

如果网站的服务端渲染结果对于每个用户都是相同的,那么可以提前把所有 HTML 都渲染出来,全站进行缓存。这样的方式特别适合文档、博客等网站,对服务器压力非常小,甚至可以直接部署到 CDN 上完全不用操心运维。

无惧 React SSR

理解 SSR 渲染流程

现在回答文章的最开头那个 quiz:答案是可以的,ReactDOMServer 支持运行在浏览器上,因此可以在任何地方进行服务端渲染,当然也包括浏览器。

在 React SSR 中有两个概念:

  • 脱水 dehydration:组件渲染为 HTML 字符串——把功能完整的组件渲染到只剩 DOM 结构和数据,去除不能序列化的事件处理器等对象
  • 水合 hydration 2:渲染组件到 VDOM,绑定到真实 DOM 上然后进行运行生命周期钩子 (e.g. useEffect) 、挂载事件监听等操作,使组件可以响应用户操作——给干瘪的 HTML 重新注入业务逻辑

我制作了一个 demo 来演示 SSR 的流程,这里的「脱水」过程就完全在浏览器中进行,虽然一般只有在服务端脱水才有意义

(可以把左侧竖条往右拖动查看源码)

在服务端,组件被脱水成为一个 HTML 字符串(如 1. 所示),这个字符串在浏览器上被渲染成 DOM (即 2. )。此时点击按钮是不会有响应的,因为脱水后的 HTML 没有交互逻辑。在 3. 中使用 ReactDOM.hydrate(<Test />, ref) 重新水合后组件才可以响应用户交互,此时点击按钮,计数就可以正常增加了。

注意到组件 render 函数实际执行了两次,一次是在服务端渲染生成 HTML 字符串,另一次是在客户端 hydrate 绑定点击事件,也就是说组件需要同时在浏览器和 Node 环境中运行。如果组件依赖了只在浏览器或 Node 上运行的代码,

https://oss.sem.ms/img/1681039161303.png

ReactDOM.hydrate 会假设已有的 DOM 结构和组件 render 得到的 React Node Tree 是匹配的,这样 React 可以跳过创建 DOM 节点的过程,更快完成水合。如果 DOM 节点不匹配,React 会抛出一个错误,虽然 React 会降级到客户端渲染,但是这个问题应该要视为一个 bug 来修复。

现在你已经完全学会 SSR 了!

Tips

虽然原理很简单,但是由于服务端和客户端天然有不同的特性,许多地方要额外处理才能正常跑起来。这里有一些小建议:

  • 使用框架 如果不是要自己造轮子,建议使用 Next.js(React 唯一钦定 SSR 框架,你的下一个脚手架何必是 CRA), UmiJS 等框架开发 SSR 应用,可以减少很多踩坑几率。 框架可以帮你完成许多事情:路由、数据注入、构建工具链以及一些通用优化,你只需要专心写业务代码。但是小心敏感数据有没有泄漏到客户端
  • 小心模块副作用 喜欢往 module 顶层写一个 let 然后在对外暴露的函数里修改状态的注意了:Server 实例是多个客户端共享的,不会像浏览器一样每个标签都创建一个单独的环境。如果模块不纯,那么「每个服务端实例」和「每个浏览器标签」中都会运行一次产生不同的副作用。尽量不要在模块中这么做,不然怎么死的都不知道
  • 注意运行环境 除了业务组件需要同时在 Node 和浏览器中运行,项目所依赖的第三方库如果只能在某个运行时中运行,那么你可能就要考虑换一个不那么野鸡的库、使用 dynamic import 按需引入,或者极端情况下 fork 一份自己修改

  1. 虽然 CSR 也可能会有这个问题但是在 SSR 需要格外小心

  2. Vue 也叫激活

Loading New Comments...