爲了更深刻地瞭解服務端渲染,因此動手搭了一個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> ) }