为了更深刻地了解服务端渲染,因此动手搭了一个react-ssr
的服务端渲染项目,由于项目中不多用到,这篇文章主要是对实现过程当中的一些总结笔记,更详细的介绍推荐看 从零开始,揭秘React服务端渲染核心技术css
ReactDOM.render(component,el)
ReactDom.renderToString(component)
服务端并无dom元素,须要使用renderToString
方法将组件转成html字符串返回。html
客户端编写使用es6 Module
规范,服务端使用使用的commonjs
规范node
使用webpack
对服务端代码进行打包,和打包客户端代码不一样的是,服务端打包须要添加target:"node"
配置项和webpack-node-externals这个库:react
与客户端打包不一样,这里服务端打包webpack
有两个点要注意:webpack
target:"node"
配置项,不将node自带的诸如path、fs这类的包打进去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交互代码:ios
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组件代码:git
import React from 'react'; import {render} from 'react-dom'; import App from './app'; render(<App/>,document.getElementById("root"));
不过在服务端已经绑定好了元素在root
节点,在客户端继续执行render
方法,会清空已经渲染好的子节点,又从新生成子节点,控制台也会抛出警告:es6
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()
在服务端的渲染,二者的区别是:github
ReactDOM.render()会将挂载dom节点的全部子节点所有清空掉,再从新生成子节点。而ReactDOM.hydrate()则会复用挂载dom节点的子节点,并将其与react的virtualDom关联上。
客户端渲染路由通常使用react-router
的BrowserRouter
或者HashRouter
,二者分别会使用浏览器的window.location
对象和window.history
对象处理路由,可是在服务端并无window
对象,这里react-router
在服务端提供了StaticRouter
。web
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将路由配置化。
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, }, ], }, ]
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") )
const content = renderToString(( <StaticRouter context={context} location={req.path}> {renderRoutes(routes)} </StaticRouter> ))
<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)
首先,服务端渲染的数据从数据服务器获取,客户端获取数据经过服务端中间层再去获取数据层数据。
客户端 ---> 代理服务 ---> 数据接口服务
服务端 ---> 数据接口服务
客户端经过服务端调用接口数据,须要设置代理,这里用的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 /- 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)) ); }
处理样式可使用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样式,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优化策略里面,必定会往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> ) }