接手过一个用脚手架起的活动页,运营那边反馈搜索引擎一条都收录不到。右键查看源代码,<body> 里只有一个空的 div#root,剩下全是 script 标签。爬虫抓到的就是这么个空壳,它当然什么都索引不了。
要解决这个问题,路子只有一条,让页面内容在服务器上就渲染好,直接吐一份带内容的 HTML 给浏览器。React 这套东西叫 SSR,更准确的叫法是同构,同一份组件代码在 Node 里跑一遍生成字符串,在浏览器里再跑一遍把事件接上。
这篇不借助任何现成框架,从 webpack 配置开始,一步一步把这套架子搭出来。看完你会知道每个环节为什么必须这么写,以及哪些地方是坑。至于用框架怎么少写这些代码,放在系列第二篇讲。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 客户端渲染、服务端渲染、同构,三者到底差在哪一步
- SSR 能成立的技术前提是什么,为什么直接操作 DOM 的代码会让它崩掉
- 一次 SSR 请求从进 Node 到浏览器接管,中间到底发生了什么
- 路由为什么必须写两套,
BrowserRouter和StaticRouter各管什么 - 客户端和服务端的 webpack 配置差在哪几个字段上
- 服务端的 Redux Store 为什么绝对不能写成单例
- 怎么保证数据全部取回来之后才生成 HTML
- styled-components 的样式在服务端怎么收集出来
- Node 作为中间层要不要接管数据请求,代理该怎么配
- 这套 2018 年的手写方案,放到今天还剩下多少价值
# 一、先把三个概念分清楚
很多人聊 SSR 的时候其实混着说了三件事,先各自拆开。
# 1.1 什么是客户端渲染
React 在客户端执行,消耗的是客户端性能。页面初始加载的那份 HTML 里没有任何展示内容,浏览器需要先下载并执行 JavaScript 文件里的 React 代码,靠 JS 渲染生成页面,同时把交互事件绑上去。
整条链路是这样走的。
浏览器发送请求–>服务器返回HTML–>浏览器发送bundle.js请求–>服务器返回bundle.js–>浏览器执行bundle.js中的react代码完成渲染

图里最关键的信息是请求的次数。从用户按下回车到看见内容,至少要跨两个 HTTP 请求周期,中间这段时间屏幕上是白的。网速好的时候你察觉不到,4G 弱网或者首屏 bundle 有一两兆的时候,白屏能持续好几秒。
# 1.2 什么是服务端渲染
React 在服务端执行,消耗的是服务端性能。我们说的服务端渲染,只是首次加载页面的时候走服务端,后面的路由跳转还是前端路由在做客户端渲染。
用户请求服务器,服务器上直接生成 HTML 内容返回给浏览器,页面的内容是 Server 端生成的。单纯的服务端渲染页面交互能力有限,想要复杂交互,还是得引入 JavaScript 文件来辅助。
链路缩短成了这样。
浏览器发送请求–>服务器运行React代码生成页面–>服务器返回页面
少了一整个请求周期,这就是首屏收益的来源。
# 1.3 什么是同构
一套 React 代码,在服务端执行一次,在客户端也执行一次。
服务端执行 renderToString 只是把界面结构变成字符串返回,事件是绑不上的,必须在客户端再执行一次 JS 才能把交互接管过来。完整过程是这样。
服务器运行 React 代码渲染出 HTML,发送 HTML 给浏览器,浏览器接收到内容先展示出来,接着加载 js 文件,JS 中的 React 代码在浏览器端重新执行,最后由它接管页面操作。
路由同构也是同一个意思,让路由在服务端、客户端各跑一遍。
这里有个坑很多人第一次都会踩。服务端渲染出来的 HTML 是能看见的,但在客户端 JS 加载完成之前,页面上所有的点击、输入都是没反应的。用户看到按钮却点不动,这段时间在体验上反而比白屏更让人困惑。所以首屏 JS 的体积对同构应用来说,重要程度一点都没降低。
# 1.4 SSR 到底值不值得上
一般情况下写 React,页面都是客户端执行 JS 逻辑动态挂 DOM 生成的,普通单页面应用采用的就是客户端渲染。大多数场景下这完全够用,那为什么还要折腾同构。
主要是两个原因。
CSR 项目的 TTFP(Time To First Page)时间比较长。回看 1.1 那张流程图,先加载 HTML 文件,再下载页面所需的 JavaScript 文件,然后由 JS 渲染生成页面,这个过程至少涉及两个 HTTP 请求周期,一定会有耗时。低网速下访问普通的 React 或 Vue 应用初始页面会白屏,原因就在这。
CSR 项目的 SEO 能力极弱,在搜索引擎里基本不可能有好的排名。目前大多数搜索引擎主要识别的内容还是 HTML,对 JavaScript 文件内容的识别都还比较弱。如果一个项目的流量入口来自搜索引擎,用 CSR 开发就非常不合适。
SSR 的产生就是为了解决这两个问题。让 React 代码在服务器端先执行一次,用户下载到的 HTML 已经包含了所有页面展示内容,页面展示只需要经历一个 HTTP 请求周期,TTFP 能得到一倍以上的缩减。同时 HTML 里已经有了完整内容,SEO 效果也会变好。之后 React 代码在客户端再次执行,为 HTML 里的内容补上数据和事件绑定,页面就具备了 React 的各种交互能力。
但这个理念实现起来并非易事。先看一眼在 React 中实现 SSR 的架构图。

图上多出来的每一个方块,都是你后面要维护的东西。用 SSR 会让原本简单的 React 项目变得非常复杂,可维护性降低,代码问题的追溯也变困难。
我的建议一直是这样,除非你的项目特别依赖搜索引擎流量,或者对首屏时间有硬性要求,否则不要上 SSR。
一个后台管理系统上 SSR,纯属给自己找事。
# 二、SSR 能成立,靠的是虚拟 DOM
为什么 React 这套东西能在服务器上跑起来,根子在虚拟 DOM。
SSR 的工程里,React 代码会在客户端和服务器端各执行一次。你可能会想,这没什么问题,都是 JavaScript 代码,既能在浏览器上运行,又能在 Node 环境下运行。但事实并非如此。如果你的 React 代码里存在直接操作 DOM 的代码,SSR 就实现不了,因为 Node 环境下根本没有 DOM 这个概念,这些代码在 Node 里会直接报错。
React 框架里引入了虚拟 DOM,它是真实 DOM 的一个 JavaScript 对象映射。React 做页面操作时实际上不是直接操作 DOM,而是操作虚拟 DOM,也就是操作普通的 JavaScript 对象,这就让 SSR 成为了可能。在服务器上操作 JavaScript 对象,判断环境是服务器环境,把虚拟 DOM 映射成字符串输出;在客户端也是操作 JavaScript 对象,判断环境是客户端环境,就直接把虚拟 DOM 映射成真实 DOM 完成页面挂载。
顺着这个结论往下推,有几条约束就是自然的了。组件里不能在渲染阶段读 window、document、localStorage,因为 Node 里这些全局对象不存在,一读就是 ReferenceError。要用只能放到只在客户端执行的时机里去用。老代码放 componentDidMount,函数组件放 useEffect,这两个位置服务端都不会执行。
这个我踩过,一个第三方图表库在模块顶层就去摸 window,服务端 require 进来的瞬间就崩,最后只能改成动态引入。
# 三、整体架构先过一遍
在动手写代码之前,先把一次请求的完整路径摆出来,后面每一步在做什么就有位置感了。
┌──────────────────────────────┐
浏览器请求 /list │ Node 服务 (Express) │
───────────────► │ │
│ 1. StaticRouter 匹配路由 │
│ react-router-config │
│ │ │
│ ▼ │
│ 2. 为本次请求 new 一个 Store │
│ │ │
│ ▼ │
│ 3. 收集匹配组件的 loadData │──── API 服务器
│ Promise.all 等全部完成 │◄─── 取数据
│ │ │
│ ▼ │
│ 4. renderToString 出 HTML │
│ + 收集 styled 样式表 │
│ │ │
│ ▼ │
│ 5. 拼模板,把 store 脱水成 │
│ window.context 注入页面 │
└────────────┬─────────────────┘
│ 带内容的 HTML
▼
┌──────────────────────────────┐
│ 浏览器 │
│ 6. 直接展示首屏(可见不可点) │
│ 7. 下载 client bundle │
│ 8. 用 window.context 注水 │
│ BrowserRouter + hydrate │
│ 9. 事件绑定完成,页面可交互 │
└──────────────────────────────┘
后面的 Step 1 到 Step 5,就是把这张图里的每个方块填上代码。第 5 步和第 8 步是一对,服务端把 Store 序列化写进页面叫脱水,客户端读回来重建 Store 叫注水,两边的初始状态必须完全一致,不然会出问题,这点后面细说。
# 四、Step 1 路由必须拆成两套
实现 React 的 SSR 架构,要让相同的 React 代码在客户端和服务器端各执行一次。注意这里说的相同的 React 代码,指的是我们写的各种组件代码。同构里只有组件代码是可以公用的,路由这种代码没有办法公用。
为什么呢。服务器端要通过请求路径找到路由组件,客户端要通过浏览器地址栏的网址找到路由组件,是完全不同的两套机制,这部分代码肯定无法公用。
# 4.1 客户端路由
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
<div>
<Route path='/' component={Home} />
</div>
</BrowserRouter>
</Provider>
)
}
ReactDom.render(<App />, document.querySelector('#root'))
客户端路由代码非常简单,大家一定很熟悉,BrowserRouter 会自动从浏览器地址中匹配对应的路由组件显示出来。
# 4.2 服务器端路由
const App = () => {
return (
<Provider store={store}>
<StaticRouter location={req.path} context={context}>
<div>
<Route path='/' component={Home} />
</div>
</StaticRouter>
</Provider>
)
}
return ReactDom.renderToString(<App />)
服务器端路由代码相对复杂一点,需要你把 location(当前请求路径)传递给 StaticRouter,这样它才能根据路径分析出当前需要的组件是谁。StaticRouter 是 React-Router 针对服务器端渲染专门提供的路由组件,它不监听地址栏,路径由你手动喂进去。
那个 context 对象也别忽略。服务端渲染过程中如果组件里触发了 Redirect,React-Router 会把跳转信息写进这个对象,你在 renderToString 之后检查 context.url,有值就说明该发 302 了,而不是把一个空页面返回出去。