React 服务端渲染 搭建同构应用
- 作者:Bougie
- 创建于:2019-12-16
- 更新于:2023-03-09
# 为什么要使用服务端渲染(SSR 的优点)
- 更快的首屏加载速度
- 更好的 SEO
# React 如何服务端渲染
# 服务端渲染(SSR)和客户端渲染(CSR)的区别
- 服务端没有 dom,无法直接将 react 组件 render 到一个 dom 节点
- 服务端无法绑定事件到 dom 元素
- 服务端没有页面路由
# 什么是同构
- 同构既是服务端渲染结果和客户端渲染结果保持一致
- 同构大致上分为路由同构和数据同构
- 同构完成后,客户端只需要使用
ReactDOM.hydrate
完成事件绑定即可
# 在服务端运行 React
react-dom/server
提供renderToString
,renderToStaticMarkup
,renderToNodeStream
,renderToStaticNodeStream
四种方法用于在服务端渲染renderToString
和renderToStaticMarkup
可在服务端和客户端运行,左右都是将一个 React 组件转化为静态 html。官方文档说两者的区别是renderToStaticMarkup
不会在 React 内部创建的额外 DOM 属性,例如data-reactroot
,但如果有交互的话,不要使用此方法。个人在实际使用 16.10 版本中感受到两者的区别是:renderToString
也不会生成额外的 DOM 属性,但是会生成额外的空注释;renderToStaticMarkup
则是非常干净、没有多余东西的 html,然而在客户端使用ReactDOM.hydrate
时会报错客户端渲染结果和服务端渲染结果不一致。官方推荐的是使用renderToStaticMarkup
来做静态站点生成器,真正的服务端渲染还是需使用renderToString
renderToNodeStream
和renderToStaticNodeStream
返回一个可读流。仅可在 Node 端使用
# 路由同构
路由同构需保证访问同一个地址时,客户端需要渲染的组件和服务端是一致的。
# 配置式(目录约定式)
- 此种方法的原理是客户端和服务端使用同一份路由配置。客户端根据此配置生成 Route 组件,服务端根据此配置查找 url 对应的组件
- next.js 使用的既是这种方式,优点是比较简单,缺点是每个页面必须都引入布局组件。有公共的 Provider 等等也必须每个页面重复引入
# 使用 StaticRouter
- 需要 React Router 版本 >= 4
- 可以方便的将一个 csr app 转化为 ssr app
import http from 'http'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { StaticRouter } from 'react-router'
http
.createServer((req, res) => {
// This context object contains the results of the render
const context = {}
const html = ReactDOMServer.renderToString(
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
)
// context.url will contain the URL to redirect to if a <Redirect> was used
if (context.url) {
res.writeHead(302, {
Location: context.url
})
res.end()
} else {
res.write(html)
res.end()
}
})
.listen(3000)
# 数据同构
数据同构指服务端渲染和客户端渲染的数据保持一致。若渲染结果不一致 ReactDOM
在开发环境下会抛出警告。
数据同构往往采用数据注水的方式来实现,即服务端将渲染的数据嵌入 html,客户端拿到该数据后再进行客户端渲染。
# 配置式
- next js 使用的既是此种方法,在路由配置的基础上给每个组件添加静态
getInitalProps
方法。getInitialProps
方法在服务端执行,将执行取得的数据注入根据路由配置查找到的组件之中即可。
# 使用 context
- 和 StaticRouter 结合使用,在组件中获取对应 context 的值进行渲染即可
- 客户端
ReactDOM.hydrate(
<BrowserRouter>
<context.Propvider context={window.__INITIAL_STATE}>
<App />
</context.Provider>
</BrowserRouter>,
document.getElementById('root')
)
- 服务端
const data = await getData()
ReactDOMServer.renderToString(
<StaticRouter location={req.url}>
<context.Propvider context={data}>
<App />
</context.Provider>
</StaticRouter>
)
# 打包和配置
在使用 Webpack 打包时,服务端和客户端使用的 dist 是不一样的。具体表现为:
- 客户端无法 require 一个 module,需要将所有依赖打包进 js bundle
- 服务端可以 require 第三方 module,服务端的包不需要把第三方依赖打包进来
- 开发环境下若服务端和客户端使用的 react 和 react-dom 不是同一个引用会报错
- 服务端无法处理 css
- 服务端的 js bundle 为一个 commonjs module
# 客户端配置
和 spa 的 webpack 配置无二,需要注意客户端和服务端的入口稍有不同。可以将入口分为 ClientEntry.jsx
和 ServerEntry.jsx
ClientEntry.jsx
import React from 'react'
import { BrowserRouter as Router } from 'react-router-dom'
import { hydrate } from 'react-dom'
import { Provider } from './Context'
import App from './App'
hydrate(
<Router>
<Provider value={window.__INITIAL_STATE__}>
<App />
</Provider>
</Router>,
document.getElementById('root')
)
ServerEntry.jsx
import React from 'react'
import { StaticRouter as Router } from 'react-router-dom'
import { Provider } from './Context'
import App from './App'
export default (url, data) => {
return (
<Router location={url}>
<Provider value={data}>
<App />
</Provider>
</Router>
)
}
# 服务端配置
打包为 node 模块,忽略 css 文件。使用 watch 每次更改文件时重新编译。用 externals 属性排除所有第三方包。
module.exports = {
target: 'node',
output: {
libraryTarget: 'commonjs2'
},
module: {
rules: [
{
test: /\.s?css$/,
use: ['ignore-loader']
}
]
},
watch: true,
externals: Object.keys(require('../package.json').dependencies)
}
# 构建开发环境
开发环境应有以下特性
- 页面的 html 是服务端渲染过的 html
- 页面嵌入的 css 和 js 为客户端生成
- 更改客户端代码时需要热更新
# 客户端处理
由于客户端的生成的资源需要给服务端使用(包括客户端的 html),因此可以将客户端的 publicPath 设置成 ip 端口的形式供服务端使用。即
module.exports = {
output: {
publicPath: 'http://localhost:2999/'
}
}
这样在开发环境下生成的 html 里面引入的 js 是加 origin 的。
同时 devServer 需要允许资源跨域
module.exports = {
devServer: {
headers: {
'Access-Control-Allow-Origin': '*'
}
}
}
# 服务端处理
服务端的 html 模板可以使用客户端开发环境生成的:
async function resolveTemplate() {
return axios.get('http://localhost:2999/').then((res) => res.data)
}
在 renderMethod 中替换 <div id='root'></div>
即可。
然后服务端访问 http://localhost:3000
即可访问经过 ssr 的客户端页面了。此时的 hmr 由于是引用自客户端的并且允许了跨域,所以依然生效。
# 构建生产环境
# 客户端
同普通 spa 一样构建即可。
# 服务端
此时的 template 应使用构建后的 template
async function resolveTemplate() {
return promisify(fs.readFile)('../dist/index.html', 'utf8')
}
# SPA 和 SSR 二合一
SPA 的好处是使用 Link
阻止了默认事件,切换页面时页面不会重新载入。然而在 SSR 项目中使用 Link
的话会导致页面跳转时不请求服务端,数据注水在客户端无法获取。
Next.js
对此的做法是封装了自己的 Link
,使用 Link
可以预加载页面,原理是在页面嵌入 <link rel="prefetch" href="/xxx.js">
来达到预加载的效果,这个 js 已经包含了要跳转页面的内容。跳转页面后页面实际的 html 没有改变,只是被预拉取的 js 修改了。这种方法常常被用于静态网站,例如本站使用的 vuepress 即采用了这种方法。然而使用这种办法没法做动态内容页,例如跟路由地址和数据库强相关的页面,因为这类页面文章内容都是用户发布的,不可能特地给每个页面都生成一个 js。
# 如何在跳转到跟路由地址和数据库强相关的页面时使用 Link ?
- 最简单的方法当然是使用 a 标签,这样会从服务端重新拉取页面,会获取到正确的页面注水数据
- 第二种方法是在组件中判断,如果页面没有注水数据,则拉取接口获取数据
- 使用第二种方法并不会影响 SEO,因为爬虫引擎每爬取一个链接,都会重新请求服务器