React SSR 介绍
什么是客户端渲染
CSR:Client Side Rendering
数据和 HTML 的拼接是在客户端(浏览器)使用 JavaScript 完成的,服务端只需要返回 JSON 数据即可。
使用 React 开发的项目是客户端渲染的单页应用(SPA)。
什么是服务器端渲染
SSR:Server Side Rendering
数据和 HTML 的拼接是在服务器端完成的,客户端向服务器端发送请求,服务器端返回拼接好的 HTML,客户端只需将其显示出来。
客户端渲染存在的问题
- 首屏等待时间长,用户体验差。
- 页面结构为空,不利于 SEO。
CSR 过程
- 用户首次访问页面,客户端向服务器发送请求,服务端返回 HTML,但是 HTML 文档只包含一些 CSS 资源链接和 JS 资源链接,等待期间页面没有展示的内容。
- 浏览器识别 HTML 文档,向服务器请求资源文件,请求过程中页面上没有展示内容。
- 加载完资源文件,就要执行对应的 JS 脚本,在执行 JS 脚本的时候又要向服务器获取当前页面所需要的数据,请求数据的过程中,页面上依然没有展示内容(或者渲染了一些静态内容,但是展示动态数据的地方还是空的)。
- 当数据请求完,在客户端又要使用 JavaScript 把请求的 JSON 数据和 HTML 拼接好,将拼接好的结果显示在页面中。到此页面才算加载完成。
在整个过程中,页面是没有内容或内容不完整的。即首屏等待时间长,用户体验差。
搜索引擎爬虫通常只会解析该请求返回的 HTML 内容,除非是针对性的爬虫,否则会忽略其它资源和请求数据的请求。
而用户请求客户端渲染的页面获取的 HTML 文档,页面结构是空的(浏览器查看页面源代码),所以爬虫抓取不到什么内容,也就不利于 SEO。
SSR 过程
- 用户首次访问页面,客户端向服务器端发送请求,服务器端将数据和 HTML 拼接好的结果返回给客户端。此时浏览器还是处于等待状态。
- 浏览器执行服务器端返回的 HTML 内容,它是包含了服务器端数据的,所以用户可以看到页面内容,只不过现在页面上展示的只有静态的 HTML 内容,没有动效果,浏览器还是要向服务端发送请求,获取资源文件。
- 当请求完 JS 文件,浏览器就会执行 JS 脚本,执行完成后页面就会有动态效果。
整个过程,用户早早的就能看到页面内容,只不过没有动态效果,要等待JS文件请求并执行完成后才会有动态效果。
用户可以更快看到页面内容,解决了首屏加载慢的问题。
而服务器页面请求返回的是完整的 HTML 内容,搜索引擎的爬虫工具能抓取相关内容,从而解决了 SEO 的问题。
同构
同构指的是代码复用。
单纯的 SSR 只是展示静态内容的传统技术,我们仍需要 SPA 交互体验。
最好的方案就是 SSR + SPA 相结合,即在实现服务端渲染的时候,还要实现客户端渲染,首次访问页面是服务端渲染,基于首次访问的后续的交互就是 SPA 的效果,这样就保留了两个技术的优点。
两种技术有大量可重用的代码,客户端路由、服务器端路由、客户端 Redux、服务器端 Redux 等,最大程度的复用这些代码,就是同构。
现在所说的服务端渲染基本上都是 SSR + SPA 的同构渲染,不是传统上的服务端渲染。
服务端渲染快速开始
项目结构
创建 react-ssr 文件夹,创建项目结构:
└─ src # 源代码文件夹
├─ client # 客户端代码
├─ server # 服务器端代码
└─ share # 同构代码
安装依赖
# 自动重新执行node命令的工具
nodemon
# babel
@babel/cli
@babel/core
@babel/preset-env
@babel/preset-react
babel-loader
# Node 服务器
express
# react
react
react-dom
# webpack
webpack
webpack-cli
创建 Node 服务器
// src/server/http.js
import express from 'express'
const app = express()
app.listen(3000, () => console.log('app is running on 3000 port'))
export default app
// src/server/index.js
import app from './http'
app.get('/', (req, res) => {
// 返回响应内容
})
React SSR 实现
实现步骤:
- 引入要渲染的 React 组件
- 通过
renderToString
方法将 React 组件转换为 HTML 字符串 - 将结果 HTML 字符串响应到客户端
renderToString
方法用于将 React 组件转换为 HTML 字符串,通过 react-dom/server 导入。
对于要渲染的 React 组件,属于客户端和服务端同构代码,所以放到 share
文件夹下。
// src\share\pages\Home.js
import React from 'react'
function Home() {
return <div>Home works</div>
}
export default Home
// src/server/index.js
import app from './http'
import React from 'react'
import { renderToString } from 'react-dom/server'
import Home from '../share/pages/Home'
app.get('/', (req, res) => {
const content = renderToString(<Home />)
res.send(`
<html>
<head>
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
</body>
</html>
`)
})
服务端程序 webpack 打包配置
Node 环境不支持 ESModule 模块系统,不支持 JSX 语法。
所以需要通过 webpack 调用 Babel 对服务端代码进行打包,然后运行打包后的代码启动项目。
// webpack.server.js
const path = require('path')
module.exports = {
mode: 'development',
target: 'node',
entry: './src/server/index.js',
output: {
path: path.join(__dirname, 'build'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}
]
}
}
配置打包命令:
// package.json
"scripts": {
// 服务器端打包命令(监听文件变化)
"dev:server-build": "webpack --config webpack.server.js --watch",
// 服务器端启动命令(监听打包目录文件变化)
"dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
},
执行打包命令:
npm run dev:server-build
新开命令行窗口运行打包结果:
npm run dev:server-run
访问http://localhost:3000/
,查看页面源代码包含了页面展示的内容。
现在就完成了一个简单的服务端渲染。
hydrate 客户端二次渲染
为什么要二次渲染
在 React 组件中为元素添加事件,经过服务端渲染后并没有被保留下来:
// src\share\pages\Home.js
import React from 'react'
function Home() {
return <div onClick={() => {console.log('click')}}>Home works</div>
}
export default Home
渲染结果:
<div id="aa" data-reactroot="">Home works</div>
这是因为 renderToString
渲染的 HTML 是纯静态 HTML,没有任何交互效果。
解决办法:在客户端二次“渲染”。
react-dom 提供的 hydrate
方法类似 render
方法,用于二次渲染。
它在渲染的时候会复用原本已经存在的 DOM 节点,减少重新生成节点以及删除原本 DOM 节点的开销,只进行事件处理绑定。
hydrate
和 render
的区别就是 hydrate
会复用已有节点,render
会重新渲染全部节点。
所以hydrate
主要用于二次渲染服务端渲染的节点,提高首次加载体验。
二次渲染组件
// src\client\index.js
import React from 'react'
import ReactDOM from 'react-dom'
import Home from '../share/pages/Home'
ReactDOM.hydrate(<Home />, document.getElementById('root'))
这个文件将作为静态资源文件被客户端请求。
但是当前代码无法在浏览器环境直接运行,需要 webpack 打包编译。
客户端 webpack 打包配置
webpack 打包配置:
- 打包目的:转换 JSX 语法,转换浏览器不识别的高级 JS 语法。
- 打包目标位置:public 文件夹(存放客户端请求的静态资源文件)。
// webpack.client.js
const path = require('path')
module.exports = {
mode: 'development',
entry: './src/client/index.js',
output: {
path: path.join(__dirname, 'public'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}
]
}
}
配置打包命令:
// package.json
"scripts": {
// 客户端打包
"dev:client-build": "webpack --config webpack.client.js --watch",
"dev:server-build": "webpack --config webpack.server.js --watch",
"dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
},
新开命令行窗口执行打包命令:
npm run dev:client-build
引入静态资源文件
客户端的打包的 JS 文件会被作为静态资源引入,打包文件存放在 public 文件夹,服务器端需要为这个目录配置静态资源访问功能,这样从根目录访问的静态资源文件会去 public 文件夹下寻找。
// src/server/http.js
import express from 'express'
const app = express()
// 配置静态资源访问
app.use(express.static('public'))
app.listen(3000, () => console.log('app is running on 3000 port'))
export default app
在服务端返回的 HTML 中添加引入静态资源文件的 script 标签:
// src/server/index.js
import app from './http'
import React from 'react'
import { renderToString } from 'react-dom/server'
import Home from '../share/pages/Home'
app.get('/', (req, res) => {
const content = renderToString(<Home />)
res.send(`
<html>
<head>
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script src="bundle.js"></script>
</body>
</html>
`)
})
现在点击事件就可以生效了。
优化
合并 webpack 配置
webpack.server.js
和 webpack.client.js
配置文件中存在一些相同的配置,可以将重复配置抽象到一个配置文件中。
安装 webpack 配置合并工具:
npm i webpack-merge
公共配置文件:
// webpack.base.js
module.exports = {
mode: 'development',
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}
]
}
}
修改配置文件:
// webpack.client.js
const path = require('path')
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const config = {
entry: './src/client/index.js',
output: {
path: path.join(__dirname, 'public'),
filename: 'bundle.js'
}
}
module.exports = merge(baseConfig, config)
// webpack.server.js
const path = require('path')
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const config = {
target: 'node',
entry: './src/server/index.js',
output: {
path: path.join(__dirname, 'build'),
filename: 'bundle.js'
}
}
module.exports = merge(baseConfig, config)
接着重新运行打包命令即可。
合并项目启动命令
现在启动项目需要运行三个启动命令。
通过 npm-run-all 工具可以合并多个命令的执行,解决多个命令启动的繁琐问题。
安装:
npm i npm-run-all
添加脚本:
"scripts": {
"dev:client-build": "webpack --config webpack.client.js --watch",
"dev:server-build": "webpack --config webpack.server.js --watch",
"dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\"",
// 并行执行 `dev:` 开头的命令
"dev": "npm-run-all --parallel dev:*"
},
打断所有命令,重新执行 npm run dev
服务器端打包文件体积优化
使用 webpack 打包服务端文件时,通常不希望打包 node_modules 下的依赖模块。
因为在服务端运行后端代码时应该已经安装了依赖,应用程序应该从 node_modules 目录下引入模块,而不是全部合并到打包文件增加打包文件大小。
解决:通过 webpack-node-externals 配置剔除打包文件中的依赖模块。
当前服务端打包文件大小为 1MB 左右。
配置优化工具:
// webpack.server.js
const path = require('path')
const { merge } = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base')
const config = {
target: 'node',
entry: './src/server/index.js',
output: {
path: path.join(__dirname, 'build'),
filename: 'bundle.js'
},
externals: [nodeExternals()]
}
module.exports = merge(baseConfig, config)
优化之后文件大小为 8KB 左右。
服务器端代码模块化拆分
将启动服务器代码和渲染代码进行模块化拆分。
优化代码组织方式,渲染 React 组件的代码也是独立功能,所以把它从服务器端入口文件中进行抽离,方便后期维护。
// src\server\renderer.js
import React from 'react'
import { renderToString } from 'react-dom/server'
import Home from '../share/pages/Home'
export default () => {
const content = renderToString(<Home />)
return `
<html>
<head>
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/bundle.js"></script>
</body>
</html>
`
}
// src/server/index.js
import app from './http'
import renderer from './renderer'
app.get('/', (req, res) => {
res.send(renderer())
})