为了更深入地了解服务端渲染,所以动手搭了一个react-ssr
的服务端渲染项目,因为项目中很少用到,这篇文章主要是对实现过程中的一些总结笔记,更详细的介绍推荐看 从零开始,揭秘React服务端渲染核心技术
服务端和客户端的渲染区别
- 客户端渲染react:
ReactDOM.render(component,el)
- 服务端渲染react:
ReactDom.renderToString(component)
服务端并没有dom元素,需要使用renderToString
方法将组件转成html字符串返回。
不同的编写规范
客户端编写使用es6 Module
规范,服务端使用使用的commonjs
规范
解决问题
使用webpack
对服务端代码进行打包,和打包客户端代码不同的是,服务端打包需要添加target:"node"
配置项和webpack-node-externals这个库:
与客户端打包不同,这里服务端打包webpack
有两个点要注意:
- 添加
target:"node"
配置项,不将node自带的诸如path、fs这类的包打进去 - 新增webpack-node-externals,忽视
node_modules
文件夹
var nodeExternals = require('webpack-node-externals');
...
module.exports = {
...
target: 'node', // in order to ignore built-in modules like path, fs, etc.
externals: [nodeExternals()], // in order to ignore all modules in node_modules folder
...
};
同构
renderToString
方法返回的只是html字符串,js逻辑并没有生效,所以react
组件在服务端完成html渲染后,也需要打包客户端需要的js交互代码:
import express from 'express';
import React from 'react';
import {renderToString} from 'react-dom/server';
import App from './src/app';
const app = express();
// 静态文件夹,webpack打包后的js文件放置public下
app.use(express.static("public"))
app.get('/',function(req,res){
// 生成html字符串
const content = renderToString(<App/>);
res.send(`
<!doctype html>
<html>
<title>ssr</title>
<body>
<div id="root">${content}</div>
// 绑定生成后的js文件
<script src="/client.js"></script>
</body>
</html>
`);
});
app.listen(3000);
可以理解成,react代码在服务端生成html结构,在客户端执行js交互代码
同样在服务端也要编写一份同样App组件代码:
import React from 'react';
import {render} from 'react-dom';
import App from './app';
render(<App/>,document.getElementById("root"));
不过在服务端已经绑定好了元素在root
节点,在客户端继续执行render
方法,会清空已经渲染好的子节点,又重新生成子节点,控制台也会抛出警告:
Warning: render(): Calling ReactDOM.render() to hydrate server-rendered markup will stop working in React v17. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML.
这里推荐用ReactDOM.hydrate()
取代ReactDOM.render()
在服务端的渲染,两者的区别是:
ReactDOM.render()会将挂载dom节点的所有子节点全部清空掉,再重新生成子节点。而ReactDOM.hydrate()则会复用挂载dom节点的子节点,并将其与react的virtualDom关联上。
路由
客户端渲染路由一般使用react-router
的BrowserRouter
或者HashRouter
,两者分别会使用浏览器的window.location
对象和window.history
对象处理路由,但是在服务端并没有window
对象,这里react-router
在服务端提供了StaticRouter
。
- 服务端渲染使用
StaticRouter
,提供location
和context
参数
import {StaticRouter,Route} from 'react-router';
...
module.exports = (req,res)=>{
const context = {} // 服务端才会有context,子组件通过props.staticContext获取
const content = renderToString(
<StaticRouter context={context} location={req.path}>
<Route to="/" component={Home}></Route>
</StaticRouter>
);
}
- 客户端渲染使用
BrowserRouter
:
import {BrowserRouter,Route} from 'react-router';
...
ReactDom.hydrate(
<BrowserRouter>
<Route to="/" component={Home}></Route>
</BrowserRouter>
document.getElementById("root")
)
前后端路由同构
前后端的路由基本相同,适合应该写成一份代码进行维护,这里使用react-router-config将路由配置化。
- routes/index.js
import Home from "../containers/Home";
import App from "../containers/App";
import Profile from "../containers/Profile";
import NotFound from "../containers/NotFound";
export default [
{
path: "/",
key: "/",
component: App,
routes: [
{
path: "/",
key: "/home",
exact: true,
component: Home,
},
{
path: "/profile",
key: "/profile",
component: Profile,
},
{
component: NotFound,
},
],
},
]
- 客户端 client.js
import routes from "../routes"
import { BrowserRouter } from "react-router-dom"
import { renderRoutes } from "react-router-config"
ReactDom.hydrate(
<BrowserRouter>{renderRoutes(routes)}</BrowserRouter>
document.getElementById("root")
)
- 服务端 server.js
const content = renderToString((
<StaticRouter context={context} location={req.path}>
{renderRoutes(routes)}
</StaticRouter>
))
重定向302和404
- 在使用
<Redirect>
重定向时,由于服务端渲染返回给客户端的状态码始终是200
- 未匹配到路由,进入
NotFound
组件,给客户端返回的也是成功状态码200
这两个问题需要在服务端拦截处理,返回正确的状态码给客户端。
记得前面给服务端路由传入的context
参数:
<StaticRouter context={context} location={req.path}>
当路由重定向时,会给props.staticContext
加入{action:"REPLACE"}
的信息,以此判断是否重定向:
// render
const content = renderToString(<App />)
// return 重定向到context的url地址
if (context.action === "REPLACE") return res.redirect(302, context.url)
进入NotFound
组件,判断是否有props.staticContext
对象,有代表在服务端渲染,新增属性给服务端判断:
export default function (props) {
if (props.staticContext) {
// 新增 notFound 属性
props.staticContext.notFound = true;
}
return <div>NotFound</div>
}
进入到
const content = renderToString(<App />);
// 存在 notFound 属性,设置状态码
if (context.notFound) res.status(404)
redux与数据注入
首先,服务端渲染的数据从数据服务器获取,客户端获取数据通过服务端中间层再去获取数据层数据。
客户端 ---> 代理服务 ---> 数据接口服务
服务端 ---> 数据接口服务
加入接口代理
客户端通过服务端调用接口数据,需要设置代理,这里用的express
框架,所用使用了express-http-proxy
:
const proxy = require("express-http-proxy");
app.use(
"/api",
// 数据接口地址
proxy("http://localhost:3001", {
proxyReqPathResolver: function (req) {
return `/api${req.url}`;
},
})
);
两种请求方式
由于请求方式不同,所以服务端和客户端需要各自维护一套请求方法。
- 服务端
request.js
:
import axios from "axios";
export default (req)=>{
// 服务层请求获取接口数据不会有跨域问题
return axios.create({
baseURL: "http://localhost:3001/",
// 需要带上 cookie
headers: {
cookie: req.get("cookie") || "",
},
})
}
- 客户端
request.js
:
import axios from "axios";
export default axios.create({
baseURL:"/"
})
创建 store
接着创建store文件夹,我这边的基本目录结构如下:
/-store
/- actions
/- reduces
- action-types.js
- index.js
为了让接口调用更加方便,这里引入了redux-thunk
中间件,并利用withExtraArgument
属性绑定了服务端和客户端请求:
import reducers from "./reducers";
import {createStore,applyMiddleware} from 'redux'
import clientRequest from "../client/request";
import serverRequest from "../server/request";
import thunk from "redux-thunk";
// 服务端store,需要加入http的request参数,获取cookie
export function getServerStore(req) {
return createStore(
reducers,
applyMiddleware(thunk.withExtraArgument(serverRequest(req)))
)
}
export function getClientStore(){
return createStore(
reducers,
initState,
applyMiddleware(thunk.withExtraArgument(clientRequest))
);
}
服务端渲染:
import { Provider } from "react-redux"
import { getServerStore } from "../store"
<Provider store={getServerStore(req)}>
<StaticRouter context={context} location={req.path}>
{renderRoutes(routes)}
</StaticRouter>
</Provider>
客户端渲染:
import { Provider } from "react-redux"
import { getClientStore } from "../store"
ReactDom.hydrate(
<Provider store={getClientStore()}>
<BrowserRouter>{renderRoutes(routes)}</BrowserRouter>
</Provider>,
document.getElementById("root")
)
通过中间件redux-thunk
可以在action
里面调用接口:
import * as TYPES from "../action-types";
export default {
getHomeList(){
// withExtraArgument方法让第三个参数变成axios的请求方法
return (dispatch,getState,request)=>{
return request.get("/api/users").then((result) => {
let list = result.data;
dispatch({
type: TYPES.SET_HOME_LIST,
payload: list,
});
});
}
}
}
数据注入
如果数据通过store调用接口获取,那么服务端渲染前需要先初始化接口数据,等待接口调用完成,数据填充进store.state
才去渲染dom。
给需要调用接口的组件新增静态方法loadData
,在服务端渲染页面前,判断渲染的组件否有loadData
静态方法,有则先执行,等待数据填充。
例如首页调用/api/users
获取用户列表:
class Home extends Component {
static loadData = (store) => {
return store.dispatch(action.getHomeList());
}
}
服务端渲染入口修改如下:
import { matchRoutes, renderRoutes } from "react-router-config"
...
async function render(req, res) {
const context = {}
const store = getServerStore(req)
const promiseAll = []
// matchRoutes判断当前匹配到的路由数组
matchRoutes(routes, req.path).forEach(({ route: { component = {} } }) => {
// 如果有 loadData 方法,加载
if (component.loadData) {
// 保证返回promise都是true,防止页面出现卡死
let promise = new Promise((resolve) => {
return component.loadData(store).then(resolve, resolve)
})
promiseAll.push(promise)
}
})
// 等待数据加载完成
await Promise.all(promiseAll)
const content = renderToString(
<Provider store={store}>
<StaticRouter context={context} location={req.path}>
{renderRoutes(routes)}
</StaticRouter>
</Provider>
);
...
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>react-ssr</title>
</head>
<script>
// 将数据绑定到window
window.context={state:${JSON.stringify(store.getState())}}
</script>
<body>
<div id="root">${content}</div>
<script src="./client.js"></script>
</body>
</html>
`)
等待Promise.all
加载完成后,所有需要加载的数据都通过loadData
填充进store.state
里面,
最后,在渲染页面将store.state
的数据获取并绑定到window上。
因为数据已经加载过一遍了,所以在客户端渲染时,把已经初始化好的数据赋值到store.state
里面:
export function getClientStore(){
let initState = window.context.state;
return createStore(
reducers,
initState,
applyMiddleware(thunk.withExtraArgument(clientRequest))
);
}
加入css
处理样式可以使用style-loader
和css-loader
,但是style-loader
最终是通过生成style标签插入到document里面的,服务端渲染并没有document,所以也需要分开维护两套webpack.config。
服务端渲染css使用isomorphic-style-loader,webpack配置如下:
module: {
rules: [
{
test: /\.css$/,
use: [
"isomorphic-style-loader",
{
loader: "css-loader",
options: {
modules: true,
},
},
],
},
],
}
客户端配置还是正常配置:
module: {
rules: [
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
modules: true,
},
},
],
},
],
}
这里 css-loader 推荐用@2的版本,最新版本在服务端isomorphic-style-loader取不到样式值
这里有个问题,因为样式css是js生成style
标签动态插入到页面,所以服务端渲染好给到客户端的页面,期初是没有样式的,如果js脚本加载慢的话,用户还是能看到没有样式前的页面。
提取css
在服务端渲染前,提取css样式,isomorphic-style-loader也提供了很好的处理方式,这里通过写个高阶函数处理,在加载样式的页面,先提取css代码保存到context里面:
服务端渲染页面,定义context.csses
数组保存样式:
const context = { csses:[] }
创建高阶函数 withStyles.js
:
import React from 'react'
export default function withStyles(RenderComp,styles){
return function(props){
if(props.staticContext){
// 获取css样式保存进csses
props.staticContext.csses.push(styles._getCss())
}
return <RenderComp {...props}></RenderComp>
}
}
使用:
import React, { Component } from "react";
import { renderRoutes } from "react-router-config";
import action from "../store/actions/session"
import style from "../style/style.css";
import withStyle from "../withStyles";
class App extends Component {
static loadData = (store) => {
return store.dispatch(action.getUserMsg())
}
render() {
return (
<div className={style.mt}>{renderRoutes(this.props.route.routes)}</div>
)
}
}
// 包裹组件
export default withStyle(App,style)
渲染前提取css样式:
const cssStr = context.csses.join("\n")
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>react-ssr</title>
<style>${cssStr}</style>
</head>
</html>
`)
seo优化
seo优化策略里面,一定会往head里面加入title
标签以及两个meta
标签(keywords
、description
),
通过react-helmet可以在每个渲染组件头部定义不同的title和meta,非常方便,使用如下:
import { Helmet } from "react-helmet"
...
const helmet=Helmet.renderStatic();
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
${helmet.title.toString()}
${helmet.meta.toString()}
<title>react-ssr</title>
<style>${cssStr}</style>
</head>
</html>
`)
在需要插入title或者meta的组件中引入Helmet
:
import { Helmet } from "react-helmet"
function Home(props){
return render() {
return (
<Fragment>
<Helmet>
<title>首页标题</title>
<meta name="keywords" content="首页关键词" />
<meta name="description" content="首页描述"></meta>
</Helmet>
<div>home</div>
</Fragment>
)
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。