React SSR的流水账
source link: https://zhuanlan.zhihu.com/p/419104337
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
React SSR的流水账
这篇文章出自于慕课课程的Step by Step实际亲手操作,记录下来,以便后期回顾
SSR与CSR
取决于DOM结构是从服务端生成还是客户端生成,简单测试可以通过查看源代码能不能看到DOM结构,或者通过禁用JavaScript后能不能正常看到页面
SSR的优势
- 减少首页渲染白屏时间
- SEO友好
迭代1
新建工程,初始化
mkdir react-ssr && cd react-ssr
npm init -y
npm install @babel/cli @babel/core @babel/preset-env babel-loader express react react-dom webpack webapck-cli webpack-noe-externals
新建目录src/server
,新建文件index.js
const express = require('express')
const app = express()
app.get('/', function(req, res) {
res.send(
`<html>
<head>
<title>React SSR</title>
</head>
<body>
<h1>Hello React SSR</h1>
</body>
</html>`
)
})
const server = app.listen(3000)
新建.babelrc
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
新建webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals')
module.exports = {
mode: 'development',
target: 'node', // 必须指定
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
externals: [nodeExternals()],
/*
没有这个插件就会报
WARNING in ./node_modules/express/lib/view.js 81:13-25
Critical dependency: the request of a dependency is an expression
@ ./node_modules/express/lib/application.js 22:11-28
@ ./node_modules/express/lib/express.js 18:12-36
@ ./node_modules/express/index.js 11:0-41
@ ./src/server/index.js 1:14-32
*/
module: {
rules: [{
test: /\.js?$/,
loader: 'babel-loader',
exclude: /node_modules/,
}]
}
}
修改package.json
加上命令
"scripts": {
"start": "node ./dist/bundle.js",
"build": "webpack --config webpack.server.js"
}
此时执行npm build
能看到打包出来的结果文件,执行npm start
能启动一个服务器,打开http://localhost:3000
能看到网页结果
迭代2
webpack
初步配置完成之后,可以使用ES6
的模块机制,在src/index.js
中把express
的引入改为
import express from 'express'
然后打包重启,一切正常
新建一个组件文件src/containers/Home/index.js
import React from 'react'
const Home = () => {
return <div>Home</div>
}
export default Home
在src/index.js
里修改
import express from 'express'
import { renderToString } from 'react-dom/server'
import Home from '../containers/Home/index'
import React from 'react'
const app = express()
app.get('/', function(req, res) {
res.send(
`<html>
<head>
<title>React SSR</title>
</head>
<body>
<h1>Hello React SSR</h1>
${renderToString(<Home />)}
</body>
</html>`
)
})
const server = app.listen(3000)
此时重新构建、启动,能看到
查看源代码能看到
<html>
<head>
<title>React SSR</title>
</head>
<body>
<h1>Hello React SSR</h1>
<div data-reactroot="">Home</div>
</body>
</html>
迭代3
监听自动打包
"scripts": {
"start": "node ./dist/bundle.js",
"build": "webpack --config webpack.server.js --watch"
},
下面实现服务器的自动重新
npm install -g nodemon
"scripts": {
"start": "nodemon --watch dist --exec node \"./dist/bundle.js\"",
"build": "webpack --config webpack.server.js --watch"
},
npm install -g npm-run-all
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:start": "nodemon --watch dist --exec node \"./dist/bundle.js\"",
"dev:build": "webpack --config webpack.server.js --watch"
},
此时只要执行一条命令npm run dev
就可以了
只有服务器渲染DOM是不够的,对于事件等机制还是需要代码在客户端再次执行一遍,也就是同一套代码在服务器和浏览器同时执行,这就称之为同构
到此时,如果在Home
组件里加上事件是不会响应的
const Home = () => {
return <div onClick={() => { alert('Home') }}>Home</div>
}
在index.js
里修改一下HTML模板,加上包裹的带id
的元素,为的是方便客户端渲染时使用,并且加上引入index.js
的<script>
,同时再加上静态文件的路由
app.use(express.static('public'))
app.get('/', function(req, res) {
res.send(
`<html>
<head>
<title>React SSR</title>
</head>
<body>
<h1>Hello React SSR</h1>
<div id="root">
${renderToString(<Home />)}
</div>
<script src="index.js"></script>
</body>
</html>`
)
})
新建src/client/index.js
,这是客户端渲染的入口
import React from 'react'
import { render } from 'react-dom'
import Home from '../containers/Home/index'
render(<Home />, document.getElementById('root'))
新建webpack.client.js
const path = require('path')
module.exports = {
mode: 'development',
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'public')
},
module: {
rules: [{
test: /\.js?$/,
loader: 'babel-loader',
exclude: /node_modules/,
}]
}
}
在package.json
里修改脚本
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:start": "nodemon --watch dist --exec node \"./dist/bundle.js\"",
"dev:build:server": "webpack --config webpack.server.js --watch",
"dev:build:client": "webpack --config webpack.client.js --watch"
}
现在启动npm run dev
,刷新页面,点击,有事件响应了
控制台里报了个警告
Warning: render(): Target node has markup rendered by React, but there are unrelated nodes as well. This is most commonly caused by white-space inserted around server-rendered markup.
同构的代码应该使用hydrate
来渲染,修改src/client/index.js
import React from 'react'
import { hydrate } from 'react-dom'
import Home from '../containers/Home/index'
hydrate(<Home />, document.getElementById('root'))
现在又换了一个新的警告
Did not expect server HTML to contain the text node "
" in <div>.
此时要在模板里把服务端渲染的内容周围去掉空格和换行
<div id="root">${renderToString(<Home />)}</div>
现在就没有警告了
用到webpack-merge
npm install webpack-merge
新建webpakc.base.js
,抽出公共配置
module.exports = {
module: {
rules: [{
test: /\.js?$/,
loader: 'babel-loader',
exclude: /node_modules/,
}]
}
}
修改webpack.client.js
const path = require('path')
const merge = require('webpack-merge').default
const base = require('./webpack.base')
const client = {
mode: 'development',
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'public')
},
}
module.exports = merge(base, client)
修改webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const merge = require('webpack-merge').default
const base = require('./webpack.base')
const server = {
mode: 'development',
target: 'node',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
externals: [nodeExternals()],
/*
WARNING in ./node_modules/express/lib/view.js 81:13-25
Critical dependency: the request of a dependency is an expression
@ ./node_modules/express/lib/application.js 22:11-28
@ ./node_modules/express/lib/express.js 18:12-36
@ ./node_modules/express/index.js 11:0-41
@ ./src/index.js 1:14-32
*/
}
module.exports = merge(base, server)
路由上最大的区别在于服务端渲染要使用StaticRouter
改造1
npm install react-router-dom
新建src/routes.js
文件
import Home from './containers/Home'
import React from 'react'
import { Route } from 'react-router-dom'
const Routes = () => {
return (
<div>
<Route path="/" exact component={Home}></Route>
</div>
)
}
export default Routes
修改客户端入口src/client/index.js
import React from 'react'
import { hydrate } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routes from '../routes'
const App = () => {
return (
<BrowserRouter>
<Routes />
</BrowserRouter>
)
}
hydrate(<App />, document.getElementById('root'))
修改服务端入口src/server/index.js
import express from 'express'
import { renderToString } from 'react-dom/server'
import Routes from '../routes'
import { StaticRouter } from 'react-router-dom'
import React from 'react'
const app = express()
app.use(express.static('public'))
app.get('/', function (req, res) {
const content = renderToString((
/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */
<StaticRouter location={req.path} context={{}}>
<Routes />
</StaticRouter>
))
console.log(content)
res.send(
`<html>
<head>
<title>React SSR</title>
</head>
<body>
<h1>Hello React SSR</h1>
<div id="root">${content}</div>
<script src="index.js"></script>
</body>
</html>`
)
})
const server = app.listen(3000)
此时运行,和原来没有差别,当前也只存在一个路由
改造2
这一步增加一个新的路由/login
新建src/container/Login/index.js
import React from 'react'
export default () => (
<div>Login</div>
)
修改src/routes.js
const Routes = () => {
return (
<div>
<Route path="/" exact component={Home}></Route>
<Route path="/login" exact component={Login}></Route>
</div>
)
}
最后在src/server/index.js
里修改服务端的路由
app.get('*', () => {
//...
})
访问localhost:3000/login
改造3
这一步是将<Link>
加入进来做路由跳转
新建src/containers/Header/index.js
import React from 'react'
import { Link } from 'react-router-dom'
export default () => (
<div>
<Link to="/">Home</Link>
<br />
<Link to="/Login">Login</Link>
</div>
)
修改src/routers.js
const Routes = () => {
return (
<div>
<Header />
<Route path="/" exact component={Home}></Route>
<Route path="/login" exact component={Login}></Route>
</div>
)
}
此时就能路由跳转了,并且服务端渲染只发生在初始加载页面的时候,后续的路由跳转就是客户端的事情
Redux
改造1
npm install redux react-redux redux-thunk
新建src/store.js
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
const reducer = (state = { name: 'zhangsan' }, action) => {
return state
}
const store = createStore(reducer, applyMiddleware(thunk))
export default store
修改src/client/index.js
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
<Routes />
</BrowserRouter>
</Provider>
)
}
修改src/server.index.js
const content = renderToString((
<Provider store={store}>
{/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */}
<StaticRouter location={req.path} context={{}}>
<Routes />
</StaticRouter>
</Provider>
))
修改src/container/Home/index.js
消费store
const Home = () => {
const name = useSelector(state => state.name)
return (
<div onClick={() => { alert('Home') }}>
Home
{name}
</div>)
}
改造2
改造1里有一个坑就是导出的store
是同一个实例,这样对于不同用户访问都是访问到了同一个实例
修改scr/store.js
,使用方法每次生成一个新的实例,就跟Vue
的data
配置一样
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
const reducer = (state = { name: 'zhangsan' }, action) => {
return state
}
const getStore = () => {
return createStore(reducer, applyMiddleware(thunk))
}
export default getStore
修改src/client/index.js
const App = () => {
return (
<Provider store={getStore()}>
<BrowserRouter>
<Routes />
</BrowserRouter>
</Provider>
)
}
修改src/server/index.js
const content = renderToString((
<Provider store={getStore()}>
{/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */}
<StaticRouter location={req.path} context={{}}>
<Routes />
</StaticRouter>
</Provider>
))
改造3
这一步的目标是用上reducer
,因为仅做试验,所以很多都是硬编码,这一步其实也跟SSR关系不大
新建public/data.json
[
{
"name": "zhangsan"
},
{
"name": "lise"
},
{
"name": "wangwu"
}
]
npm install axios
修改src/store.js
里的reducer
const reducer = (state = { list: [] }, action) => {
switch (action.type) {
case 'UPDATE':
return { list: action.payload }
}
return state
}
修改src/container/Home/index.js
const Home = () => {
const list = useSelector(state => state.list)
const dispatch = useDispatch()
const sendRquest = () => {
dispatch(dispatch => {
axios.get('/data.json').then(res => {
dispatch({
payload: res.data,
type: 'UPDATE'
})
})
})
}
return (
<div>
Home
<button onClick={sendRquest}>Send Request</button>
{
list.map(item => (
<div key={item.name}>{item.name}</div>
))
}
</div>)
}
这里没有解耦出专门的Action
,直接写在了组件里
改造4
这一步解决的是在服务端获取数据的问题,由于componentDidMount
方法只会在客户端执行,所以即便初始状态数据的获取代码写在componentDidMount
里也是没有用的,这个改造主要参考react-router
的Guide里Server Rendering一章
修改src/containers/Home/index.js
增加一个数据获取的方法
Home.loadData = () => {
// TODO
}
修改src/routes.js
,把导出从组件改为了描述数据
const routes = [
{
path: '/',
component: Home,
exact: true,
loadData: Home.loadData
},
{
path: '/login',
component: Login,
exact: true
}
]
export default routes
修改src/client/index.js
const App = () => {
return (
<Provider store={getStore()}>
<BrowserRouter>
<Header />
{
routes.map(route => (
<Route key={route.path} {...route} />
))
}
</BrowserRouter>
</Provider>
)
}
修改src/server/index.js
app.get('*', function (req, res) {
const store = getStore()
const matchRoutes = []
routes.some(route => {
const match = matchPath(req.path, route)
if (match) {
matchRoutes.push(route)
}
})
const content = renderToString((
<Provider store={store}>
{/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */}
<StaticRouter location={req.path} context={{}}>
<Header />
{
routes.map(route => (
<Route key={route.path} {...route} />
))
}
</StaticRouter>
</Provider>
))
res.send(
`<html>
<head>
<title>React SSR</title>
</head>
<body>
<h1>Hello React SSR</h1>
<div id="root">${content}</div>
<script src="index.js"></script>
</body>
</html>`
)
})
到这一步把基础的架子改造完成
改造5
上一步的改造只支持单层的路由匹配,如果想要匹配多层嵌套路由(每一层可能都要去加载数据),就要使用react-router-config
npm install react-router-config
修改src/routes.js
,增加子路由做测试
import Home from './containers/Home'
import Login from './containers/Login'
const ABC = () => (<div>abc</div>)
const XYZ = () => (<div>xyz</div>)
const routes = [
{
path: '/',
component: Home,
// exact: true,
loadData: Home.loadData,
routes: [ // 千万不要写成routers,否则查到怀疑人生
{
path: '/abc',
component: ABC,
exact: true
},
{
path: '/xyz',
component: XYZ,
exact: true
},
]
},
{
path: '/login',
component: Login,
exact: true
}
]
export default routes
修改src/server/index.js
app.get('*', function (req, res) {
const store = getStore()
const matchedRoutes = matchRoutes(routes, req.path)
console.log(matchedRoutes)
// ...
}
此时访问/xyz
可以看到同时匹配到了/
和/xyz
改造6
修改src/container/Home/index.js
Home.loadData = store => {
return store.dispatch(dispatch => {
// 因为要从服务端发起请求,这里不能简写/data.json
return axios.get('http://localhost:3000/data.json').then(res => { // 返回promise
dispatch({
payload: res.data,
type: 'UPDATE'
})
})
})
}
这里在实际项目中对于请求URL还是要小心一些,可能还涉及代理转发等各种复杂的情况,稳妥一点就判断一下当前是在客户端还是服务端
修改src/server/index.js
app.get('*', function (req, res) {
const store = getStore()
const matchedRoutes = matchRoutes(routes, req.path)
const promises = []
matchedRoutes.forEach(item => {
if (item.route.loadData) {
promises.push(item.route.loadData(store))
}
})
Promise.all(promises).then(() => {
console.log(store.getState())
const content = renderToString((
<Provider store={store}>
{/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */}
<StaticRouter location={req.path} context={{}}>
<Header />
{
routes.map(route => (
<Route key={route.path} {...route} />
))
}
</StaticRouter>
</Provider>
))
res.send(
`<html>
<head>
<title>React SSR</title>
</head>
<body>
<h1>Hello React SSR</h1>
<div id="root">${content}</div>
<script src="index.js"></script>
</body>
</html>`
)
})
})
此时刷新网页,会看到数据一闪而过,显示数据的区块为空白,这是因为一开始服务端渲染出了数据
<html>
<head>
<title>React SSR</title>
</head>
<body>
<h1>Hello React SSR</h1>
<div id="root"><div><a href="/">Home</a><br/><a href="/Login">Login</a></div><div>Home<button>Send Request</button><div>zhangsan</div><div>lise</div><div>wangwu</div></div></div>
<script src="index.js"></script>
</body>
</html>
但随后客户端再次渲染一遍,把数据给刷没了,可以修改Home
组件,使得逻辑一致
useEffect(() => {
dispatch(dispatch => {
axios.get('/data.json').then(res => {
dispatch({
payload: res.data,
type: 'UPDATE'
})
})
})
}, [])
此时还存在两个问题
第一个问题是有一个警告
第二个问题是存在一个闪烁过程,这两个问题其实是同一个原因:
一开始服务端获得数据,返回的DOM里是有数据的,随后客户端渲染开始,但一开始默认的store里是空的,客户端按空数组来渲染,此时就变成了客户端渲染出的DOM结果和服务端返回的DOM结果不一致,就报了警告(注意如果使用render
而非hydate
的话,render
并不会在开发模式检查比较DOM的一致性,也就不会报警告),最后在渲染Home组件的时候,发起请求,渲染出数据来
改造7
修改src/store.js
export const getStore = () => {
return createStore(reducer, applyMiddleware(thunk))
}
export const getClientStore = () => {
const defaultState = window.__context__.state
return createStore(reducer, defaultState, applyMiddleware(thunk))
}
对应的import
都要改变,这里不再赘述
在src/client/index.js
里就要用getClientStore
了
<Provider store={getClientStore()}>
</Provider>
至于这个window.__context__.state
是从服务端来
`<html>
<head>
<title>React SSR</title>
</head>
<body>
<h1>Hello React SSR</h1>
<div id="root">${content}</div>
<script>
window.__context__ = {
state: ${JSON.stringify(store.getState())}
}
</script>
<script src="index.js"></script>
</body>
</html>`
修改过后,不再有闪烁,警告也消失了
这个过程就称之为数据的注水(注入到window.__context__
里)和脱水(从window.__context__
里拿出来使用)
客户端在
useEffect
或者componentDidMount
里请求数据的代码不能少,因为服务端只在第一个请求的页面进行服务端渲染,加入请求的是/dummy
,此时window.__context__
里就是空,路由跳转到Home
组件的话必须要在客户端发起请求才行
多级嵌套路由改造
之前的嵌套路由没有真正实现渲染,所以还是要借助于react-router-config
的renderRouters
方法来实现,本质上相当于回到集中式声明路由的写法中去
修改src/routes.js
使之变为嵌套路由声明
const routes = [
{
path: '/',
component: App,
routes: [
{
path: '/',
component: Home,
exact: true,
loadData: Home.loadData,
},
{
path: '/login',
component: Login,
exact: true,
}
]
}
]
修改src/client/index.js
,客户端渲染第一层路由
hydrate(
<Provider store={getClientStore()}>
<BrowserRouter>
{renderRoutes(routes)}
</BrowserRouter>
</Provider>, document.getElementById('root'))
修改src/server/index.js
,服务端渲染第一层路由
const content = renderToString((
<Provider store={store}>
{/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */}
<StaticRouter location={req.path} context={{}}>
{renderRoutes(routes)}
</StaticRouter>
</Provider>
))
新建src/App.js
,这里用到了props.route.routes
注意拼写,有一点不对就要调得怀疑人生
const App = (props) => {
return (
<div>
<Header />
{renderRoutes(props.route.routes)}
</div>
)
}
export default App
这里props.route.routes
对应的就是
[
{
path: '/',
component: Home,
exact: true,
loadData: Home.loadData,
},
{
path: '/login',
component: Login,
exact: true,
}
]
也就是去渲染第二层的路由
由于style-loader
会找寻window
并把样式挂载在DOM中,而服务端渲染的时候不存在window
对象,所以处理样式的时候要分开
其实判断当前在服务端还是客户端渲染的一个最简便方法就是
window
存不存在
npm install isomorphic-style-loader css-loader style-loader
具体用法参考文档
react-helmet
使用react-helmet
可以生成、修改DOM的<title>
和<meta>
等跟SEO相关的元素,要注意的是真的要做SEO的话react-helmet
是要在服务端运行的,具体做法很简单参考官方文档就行了
预渲染
判断是否爬虫来访问,是爬虫的话返回在预渲染服务器的预渲染结果
可以通过prerender
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK