最近刚学了一些react-ssr的内容,刚好用这个来码了自己的个人简历网站。现在临近收尾工作,写一篇笔记来记录复盘一下。
如果希望可以无痛理解,可能需要提前理解react相关技术栈,如react-redux、react-router等
众所周知,一般的SPA会因为客户端需要先运行一遍渲染出DOM,因此一般会出现首屏加载白屏的现象,另外也会存在SEO较差的问题。SSR的出现就是为了解决这两个问题。(emm..我用这个码了一个个人简历网站,也是为了提高SEO...哈哈哈)
什么是同构
同一套react代码在服务端、客户端都运行一遍。当客户访问网页,react代码先在服务端运行一遍,返回一个html的字符串模板到客户端。客户端接收到html & JavaScript脚本。JavaScript脚本的react代码接管服务端返回的html模板。
//app.js
import React from 'react'
export default ()=>(
<div>this is app</div>
)
// client-side
import ReactDOM from 'react-dom'
import App from './app.js'
ReactDOM.hydrate(App,document.getElementById('root'))
//server-side
import express from 'express'
import { renderToString } from 'react-dom/server'
import App from './app.js'
const app = express()
const html =(content)=>(`
...
<body><div id="root"> ${content} </div></body>
...
`)
app.get('*',(req,res)=>{
const content = renderToString(App)
res.send(html(content))
})
一个简单的客户端/服务端同构就实现了。react-dom中有个API renderToString
可以将react生成的虚拟DOM转化为字符串。
简单知识点:
- server-side使用
renderToString
生成字符串模板后,返回给客户端。 - client-side使用
hydrate
接管server端返回的html模板,继续运行react代码。
hydrate
相比于普通的render
,免去了创建dom节点的工作,但仍然需要完成dom diff,和dom patch的工作,为相应的DOM节点绑定事件,运行react生命周期中componentDidMount
后的工作。
这里需要注意的是:当客户端渲染的结果与服务端渲染的结果不一致的时候,将会出现error xxx does not match xxx
这时候就要回顾一下自己的代码哪里出错了。
但在实际项目中,react 同构我们需要考虑的问题还有很多:
- 服务端和客户端运行环境不一样,服务端运行在node环境下,客户端运行在浏览器下,因此服务端没有window这个全局对象。
- 服务端遵循CommonJS标准,客户端支持ES6模块。
- 客户端/服务端的数据如何统一,如果有些数据需要异步(请求别的服务端)来获取,如何控制?
- html模板渲染出来了,但没有提前准备CSS资源,用户体验也不好。
- 应用的路由如何控制,在服务端/客户端如何设置
带着这些问题,我们来搭建一个简单的react-SSR环境
react-SSR环境搭建
在开始之前,我先说一下这次项目我所使用的技术栈以及目录结构:
- 前端: React 16.7 + Redux + immutable + thunk + React Router V4 + styled-component
- 后端: Node + exporess
- 第三方库: axios、leancloud、dayjs
- 构建: webpack4
//目录结构
.
├─ build/ # webpack配置目录
├─ dist/ # server-side 编译后的代码
├─ public/ # client-side 编译后的代码(静态资源)
├─ src/ # 源码
│ ├─── App/ # React代码
│ │ ├─── components/ # 页面中的组件
│ │ ├─── pages/ # 页面组件
│ │ ├─── store/ # 状态管理 redux
│ │ ├─── styled/ # 所有styled-component
│ │ └─── index.jsx # App
│ ├─── config/ # APP的一些配置文件,如Router、leancloud配置等
│ ├─── client/ # 客户端渲染代码
│ └─── server/ # 服务端代码
│ ├─── proxy.js # 路由代理
│ ├─── render.js # 负责生成html模板
│ └─── index.jsx # node-server 入口
├─── .gitignore
└─── package.json
1. 搭建ssr环境第一步
使用webpack分别给客户端和服务端写一份打包配置
# webpack.client.js
const path = require('path')
const Merge = require('webpack-merge')
const baseConfig = require('./webpack.base')
const config = {
entry: path.resolve(__dirname,'../src/client'),
output:{
filename:'index.js',
path: path.resolve(__dirname,'../public')
}
}
module.exports = Merge(config,baseConfig)
# 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: path.resolve(__dirname, '../src/server'),
output: {
filename: 'server.js',
path: path.resolve(__dirname, '../dist'),
libraryTarget: 'commonjs2'
},
externals:[nodeExternals()]
}
modules.exports = Merge(config,baseConfig)
# webpack.base.js
const path = require('path')
module.exports = {
mode: process.env.NODE_ENV ||'production',
resolve: {
extensions: [ '.js', '.jsx' ]
},
module:{
rules:[{
test: /\.(js|jsx)?$/,
loader:'babel-loader',
exclude:[
path.resolve(__dirname, '../node_modules')
],
options:{
presets:['react','stage-0',['env',{
target:{
browser:['last 2 versions']
}
}]
]
}
}
}
# npm script
"build:server":"webpack --config ./build/webpack.server.js --watch"
"build:client":"webpack --config ./build/webpack.client.js --watch"
"dev:start": "node ./dist/server.js"
第二步:配置css文件
这次我使用的是styled-component
- 在webpack上配置一下styled-components的plugin
# webpack.base.js
// babel-loader options
plugins:['babel-plugin-styled-components']
- 服务端收集相应css选择器&css样式
//server-side
import { ServerStyleSheet } from 'styled-components'
const sheet = new ServerStyleSheet()
const content = renderToString(sheet.collectStyles(App)) //App---root component
const style = sheet.getStyleTags() //获取css样式,插入在html模板中
# html
`
<html lang="zh-Hans">
<head>
${ style }
</head>
<body>
<div id="root">${content}</div>
<script src="./index.js"></script>
</body>
</html>
`
第三步:App的数据管理
- 涉及到数据管理,我们需要理解两个概念:脱水、注水
- 脱水:服务端、客户端生成的store应该是两个不同的对象。不然在开发过程中,两端会共用一个store。
- 注水:服务端运行react代码得到的数据应该带到客户端,并且赋值到客户端的store
- 数据的异步获取我们需要用到中间件 redux-thunk
# store
import {createStore,compose,applyMiddleware } from 'redux'
import reducer from './reducer'
import thunk from 'redux-thunk'
import {fromJS} from 'immutable'
let enhances,defaultState
if(typeof window === 'object'){ //client-side
const componentEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
enhances = componentEnhancers(applyMiddleware(thunk))
defaultState = fromJS(window.INITIAL_STATE) // 注水第2步(第1步看最下面)
} else { //server-side
enhances = compose(applyMiddleware(thunk))
}
// 脱水
const getStore = defaultState ?
createStore(reducer,defaultState,enhances) :
createStore(reducer,enhances)
export default getStore
# server-side
import getStore from './store'
const store = getStore()
const initState = store.getState()
# html
//注水第1步,将服务端的store数据传到window全局对象的__INITIAL_STATE__中
`<script>window.__INITIAL_STATE__ = ${JSON.stringify(initState)}</script> `
-
异步数据的获取
关于客户端有没有必要获取异步数据,答案是肯定的。
在实际项目中,有的页面需要异步数据加载(page A),有的不需要(page B)。
当我们从page A 进入网站时,客户端当然不需要重复获取数据(因为服务端已经帮我们拿到了数据并渲染到页面了,注意,这里需要我们判断数据是否存在再异步获取 );但是当我们从page B进入网站,再跳转到page A,此时客户端应该需要获取异步数据并渲染到页面中。 -
客户端获取数据
在一般的ssr架构中,node应该充当一个中间层的角色。底层的后端服务器如c#、java等用来操作数据,因为它们的性能相对于node来说更高。
因此,为了保持这样的架构设计,客户端获取数据的接口应该设计在node服务器上,再由node代理到底层服务器
由于我们后端使用express,我们可以用express-http-proxy
直接将url映射到底层服务器:import proxy from 'express-http-proxy' const app = express() app.use(' /api ',proxy('你的底层server url', { proxyReqPathResolver(req) { return '你的底层server url'+ req.url } })) /** 比如你的node server url为 localhost:3000,底层服务器为 localhost:8888 当客户端访问 localhost:3000/api/xxx 时,node服务器会映射到localhost:8888/xxx **/
-
服务端获取异步数据
关于服务端获取异步数据我们需要关注两点:
-
在上面我们已经将客户端获取数据的接口放在服务端上,但是服务端获取数据并不需要代理,它直接在底层服务器上获取就可以了。
-
两端所使用的store并不共用,我们如何确保获取的数据是相应的一端?
第1点里,两者所请求的接口不一致,我们可以使用axios
的一个APIaxios.create()
事先定义好请求接口,然后在createstore时,用thunk的APIthunk.withExtraArgument()
将它们当作额外参数传到各自的store中# axiosInstance.js import axios from 'axios' const baseClientURL='xxx', baseServerURL='yyy' export const clientInstance = axios.create({ baseURL: baseClientURL }) export const serverInstance = axios.create({ baseURL: baseServerURL }) # store.js import {clientInstance , serverInstance } from './axiosInstance' //client-side enhances = componentEnhancers(applyMiddleware(thunk.withExtraArgument(clientInstance ))) // server-side enhances = componentEnhancers(applyMiddleware(thunk.withExtraArgument(serverInstance ))) # action.js // thunk中间件的 函式 action const url={ // ... other url homelist:'/homelist' } export const getHomeList =()=>((dispatch,getState,axiosInstance)=>{ return axiosInstance.get(url.homelist).then( response=>{ const action = { type:GETHOMELIST, list:response.data.list } dispatch(action) }) })
这样的设计就能保证在服务端或客户端获取异步数据的时候,它们所请求的接口不会出错。
但是如何保证当dispatch一个action时,它的store是对应的那个呢?
答案是:我们利用react-router让服务端在路由渲染成功之前,先异步获取数据。详细怎么做在第四步有详细说明。
-
第四步:路由设置
关于react-router,跟平常的react App差不多,但是在服务端里,由于没有DOM,因此我们不能用<BrowserRouter>
来包裹routes
,react-router-dom 提供了<staticRouter>
用于node端。
# Router config
import App from 'app.jsx'
import IndexPage from 'indexPage.jsx'
import LoginPage from 'loginPage.jsx'
export default [{
path: '/',
component: App,
routes:[{
path: '/',
exact: true,
component:IndexPage,
loadData: IndexPage.loadData
},{
path: '/login',
component: LoginPage
}]
}]
# server-side
import Router from './Router.js'
import { renderRouter } from 'react-router-config'
import { staticRouter } from 'react-router-dom '
app.get('x',(req,res)=>{
const App= (
<StaticRouter location ={req.path} context={context}>
{ renderRoutes(Router) }
</StaticRouter>)
const content = renderTostring(App)
})
# client-side
const App =(
<BrowserRouter>
{ renderRoutes(Router) }
</BrowserRouter>
)
ReactDOM.hydrate(App,document.getElementById('root'))
- 利用loadData实现服务端异步获取数据
以上是一个简单的router配置。仔细看过代码应该可以发现,有一个route上有个属性loadData
这个loadData
就是我在第三步中提到的服务端获取异步数据所用到的函数。
思路: 我们可以利用 matchRoutes匹配到当前路由下所有routes,并提取每个routes的loadData,并且在服务端先执行成功在返回html。
# server-side
import { matchRoutes } from 'react-router-config'
import getStore from './store'
// ...
const store = getStore()
const currentRoutes = matchRoutes(Router, req.path)
currentRoutes .map( matchItem=>{
if(!!matchItem.route.loadData){
const newPromise = new Promise((resolve, reject)=>{
matchItem.route.loadData(store).then(resolve).catch(reject) //这里将服务端的store传给App
})
promises.push(newPromise)
}
})
Promise.all(promises).then(()=>{
const html = render()
res.send(html)
}).catch((err)=>{
res.status(500).end('sorry request error')
})
# app component
IndexPage.loadData =({dispatch})=>{
return dispatch(action.getHomeList())
}
# action.js
// thunk中间件的 函式 action
const url={
// ... other url
homelist:'/homelist'
}
export const getHomeList =()=>((dispatch,getState,axiosInstance)=>{
return axiosInstance.get(url.homelist).then( response=>{
const action = {
type:GETHOMELIST,
list:response.data.list
}
dispatch(action)
})
})
- 利用
staticRouter
的context
实现404页面 & 301重定向
- 实现404页面
首先在路由配置Router.js
中设置路由:当访问路径不满足指定路径时,跳转到NotFound
组件
export default [{
path: '/',
component: App,
routes:[{
path: '/',
exact: true,
component:IndexPage,
loadData: IndexPage.loadData
},{
path: '/login',
component: LoginPage
}]
},{
component:NotFound
}]
在NotFound
组件里,对context进行修改:
# NotFound component
componentwillMount(){
this.props.staticContext && (this.props.staticContext.NotFound = true)
}
在服务端对context进行判断
context.NotFound ? res.status(404).send(html) :res.send(html)
- 实现301重定向
当组件里有Redirect组件的时候,客户端进行重定向,同时也会向服务端的stacticContext发送一段信息:
{
action:' REPLACE ',
location: xxx,//
url: //重定向的路由
}
可以借助这个特性实现重定向。
总结
以上是关于react-ssr的一些概念。我这个项目的架构相对来说还是比较简单,对于第一次接触react-ssr的人,可能会比较好理解,有兴趣可以clone下来学习一下。
对于这个项目架构的设计其实对我来说并不完美。如果说打分勉强算 5、6分吧。它还有很多需要优化的地方:比如没有eslint的检查配置,比如开发体验还不是最优的,每次更新代码不能自动刷新页面更新...等等
anyway, learn by doing. 大家渣油啊。
网友评论