[TOC]
# React組件的服務端渲染
剛剛起的express服務返回的只是一個普通的html字符串,但我們討論的是如何進行React的服務端渲染,那么怎么做呢? 首先寫一個簡單的React組件:
~~~
// containers/Home.js
import React from 'react';
const Home = () => {
return (
<div>
<div>This is sanyuan</div>
</div>
)
}
export default Home
~~~
<br>
現在的任務就是將它轉換為html代碼返回給瀏覽器。 總所周知,JSX中的標簽其實是基于虛擬DOM的,最終要通過一定的方法將其轉換為真實DOM。虛擬DOM也就是JS對象,可以看出整個服務端的渲染流程就是通過虛擬DOM的編譯來完成的,因此虛擬DOM巨大的表達力也可見一斑了。
<br>
而react-dom這個庫中剛好實現了編譯虛擬DOM的方法。做法如下:
~~~
// server/index.js
import express from 'express';
import { renderToString } from 'react-dom/server';
import Home from './containers/Home';
const app = express();
const content = renderToString(<Home />);
app.get('/', function (req, res) {
res.send(
`
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
</body>
</html>
`
);
})
app.listen(3001, () => {
console.log('listen:3001')
})
~~~
然而還有一個問題,node不能解析 `containers/Home.js` 中的組件內容,需要使用babel進行編譯。這里需要先配置webpack:
~~~
// webpack.base.js
module.exports = {
module: {
rules: [{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-react', ['@babel/preset-env', {
targets: {
browsers: ['last 2 versions']
}
}]]
}
}]
}
}
~~~
<br>
~~~
// webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const merge = require('webpack-merge')
const config = require('./webpack.base')
const serverConfig = {
target: 'node',
mode: 'development',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'build')
},
externals: [nodeExternals()],
module: {
rules: [{
test: /\.css?$/,
use: ['isomorphic-style-loader', {
loader: 'css-loader',
options: {
modules: true
}
}]
}]
}
}
module.exports = merge.merge(config, serverConfig)
~~~
package.json添加下面的腳本
~~~
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:build:server": "webpack --config webpack.server.js --watch",
"dev:start": "nodemon --watch build --exec node \"./build/bundle.js\""
},
~~~
<br>
啟動express服務,再瀏覽器上打開對應端口,頁面顯示出"this is sanyuan"。 到此,就初步實現了一個React組件是服務端渲染。 當然,這只是一個非常簡陋的SSR,事實上對于復雜的項目而言是無能為力的,在之后會一步步完善,打造出一個功能完整的React的SSR框架。
<br>
<br>
# 同構
## 引入同構
其實前面的SSR是不完整的,平時在開發的過程中難免會有一些事件綁定,比如加一個button:
~~~
// containers/Home.js
import React from 'react';
const Home = () => {
return (
<div>
<div>This is sanyuan</div>
<button onClick={() => {alert('666')}}>click</button>
</div>
)
}
export default Home
~~~
再試一下,你會驚奇的發現,事件綁定無效!那這是為什么呢?原因很簡單,react-dom/server下的renderToString并沒有做事件相關的處理,因此返回給瀏覽器的內容不會有事件綁定。
那怎么解決這個問題呢?
這就需要進行同構了。所謂同構,通俗的講,就是一套React代碼在服務器上運行一遍,到達瀏覽器又運行一遍。服務端渲染完成頁面結構,瀏覽器端渲染完成事件綁定。
那如何進行瀏覽器端的事件綁定呢?
唯一的方式就是讓瀏覽器去拉取JS文件執行,讓JS代碼來控制。于是服務端返回的代碼變成了這樣:

有沒有發現和之前的區別?區別就是多了一個script標簽。而它拉取的JS代碼就是來完成同構的。
添加新文件 client/index.js 作為 客戶端入口
~~~
import React from 'react';
import ReactDom from 'react-dom';
import { BrowserRouter } from 'react-router-dom'
import Routes from '../Routes'
const App = () => {
return (
<BrowserRouter>
{Routes}
</BrowserRouter>
)
}
ReactDom.hydrate(<App />, document.getElementById('root'))
~~~
<br>
然后用webpack將其編譯打包成index.js:
~~~
//webpack.client.js
const path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base');
const clientConfig = {
mode: 'development',
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'public')
},
}
module.exports = merge(config, clientConfig);
//webpack.base.js
module.exports = {
module: {
rules: [{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-react', ['@babel/preset-env', {
targets: {
browsers: ['last 2 versions']
}
}]]
}
}]
}
}
//package.json的script部分
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
"dev:build:server": "webpack --config webpack.server.js --watch",
"dev:build:client": "webpack --config webpack.client.js --watch"
},
~~~
在這里需要開啟express的靜態文件服務:
~~~
const app = express();
app.use(express.static('public'));
~~~
<br>
現在來初步總結一下同構代碼執行的流程:

<br>
<br>
## 同構中的路由問題
現在寫一個路由的配置文件:
~~~
// Routes.js
import React from 'react';
import {Route} from 'react-router-dom'
import Home from './containers/Home';
import Login from './containers/Login'
export default (
<div>
<Route path='/' exact component={Home}></Route>
<Route path='/login' exact component={Login}></Route>
</div>
)
~~~
在客戶端的控制代碼,也就是上面寫過的client/index.js中,要做相應的更改:
~~~
import React from 'react';
import ReactDom from 'react-dom';
import { BrowserRouter } from 'react-router-dom'
import Routes from '../Routes'
const App = () => {
return (
<BrowserRouter>
{Routes}
</BrowserRouter>
)
}
ReactDom.hydrate(<App />, document.getElementById('root'))
~~~
服務器端,`BrowserRouter`變成了`StaticRouter`。
React Router 無法根據 location 自動判斷當前所在頁面,而需要你把`req.url`傳給`StaticRouter`,后續的路由渲染邏輯雙端都是通用的。
<br>
這時候控制臺會報錯,

因為在Routes.js中,每個Route組件外面包裹著一層div,但服務端返回的代碼中并沒有這個div,所以報錯。如何去解決這個問題?需要將服務端的路由邏輯執行一遍。
~~~
// server/index.js
import express from 'express';
import {render} from './utils';
const app = express();
app.use(express.static('public'));
//注意這里要換成*來匹配
app.get('*', function (req, res) {
res.send(render(req));
});
app.listen(3001, () => {
console.log('listen:3001')
});
~~~
~~~
// server/utils.js
import Routes from '../Routes'
import { renderToString } from 'react-dom/server';
//重要是要用到StaticRouter
import { StaticRouter } from 'react-router-dom';
import React from 'react'
export const render = (req) => {
//構建服務端的路由
const content = renderToString(
<StaticRouter location={req.path} >
{Routes}
</StaticRouter>
);
return `
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`
}
~~~
現在路由的跳轉就沒有任何問題啦。 注意,這里僅僅是一級路由的跳轉,多級路由的渲染在之后的系列中會用react-router-config中renderRoutes來處理。
<br>
<br>
# 同構項目中引入Redux
這一節主要是講述Redux如何被引入到同構項目中以及其中需要注意的問題。
重新回顧一下redux的運作流程:

再回顧一下同構的概念,即在React代碼客戶端和服務器端各自運行一遍。
## 創建全局store
現在開始創建store。 在項目根目錄的store文件夾(總的store)下:
~~~
import {createStore, applyMiddleware, combineReducers} from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/Home/store';
//合并項目組件中store的reducer
const reducer = combineReducers({
home: homeReducer
})
//創建store,并引入中間件thunk進行異步操作的管理
const store = createStore(reducer, applyMiddleware(thunk));
//導出創建的store
export default store
~~~
## 組件內action和reducer的構建
Home文件夾下的工程文件結構如下:

在Home的store目錄下的各個文件代碼示例:
~~~
//constants.js
export const CHANGE_LIST = 'HOME/CHANGE_LIST';
~~~
~~~
//actions.js
import axios from 'axios';
import { CHANGE_LIST } from "./constants";
//普通action
const changeList = list => ({
type: CHANGE_LIST,
list
});
//異步操作的action(采用thunk中間件)
export const getHomeList = () => {
return (dispatch) => {
return axios.get('xxx')
.then((res) => {
const list = res.data.data;
console.log(list)
dispatch(changeList(list))
});
};
}
~~~
~~~
//reducer.js
import { CHANGE_LIST } from "./constants";
const defaultState = {
name: 'sanyuan',
list: []
}
export default (state = defaultState, action) => {
switch(action.type) {
default:
return state;
}
}
~~~
~~~
//index.js
import reducer from "./reducer";
//這么做是為了導出reducer讓全局的store來進行合并
//那么在全局的store下的index.js中只需引入Home/store而不需要Home/store/reducer.js
//因為腳手架會自動識別文件夾下的index文件
export {reducer}
~~~
## 組件連接全局store
下面是Home組件的編寫示例。
~~~
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions'
class Home extends Component {
render() {
const { list } = this.props
return list.map(item => <div key={item.id}>{item.title}</div>)
}
}
const mapStateToProps = state => ({
list: state.home.newsList,
})
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(getHomeList());
}
})
//連接store
export default connect(mapStateToProps, mapDispatchToProps)(Home);
復制代碼
~~~
對于store的連接操作,在同構項目中分兩個部分,一個是與客戶端store的連接,另一部分是與服務端store的連接。都是通過react-redux中的Provider來傳遞store的。
客戶端:
~~~
//src/client/index.js
import React from 'react';
import ReactDom from 'react-dom';
import {BrowserRouter, Route} from 'react-router-dom';
import { Provider } from 'react-redux';
import store from '../store'
import routes from '../routes.js'
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
{routes}
</BrowserRouter>
</Provider>
)
}
ReactDom.hydrate(<App />, document.getElementById('root'))
復制代碼
~~~
服務端:
~~~
//src/server/index.js的內容保持不變
//下面是src/server/utils.js
import Routes from '../Routes'
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import React from 'react'
export const render = (req) => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} >
{Routes}
</StaticRouter>
</Provider>
);
return `
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`
}
復制代碼
~~~
## 潛在的坑
其實上面這樣的store創建方式是存在問題的,什么原因呢?
上面的store是一個單例,當這個單例導出去后,所有的用戶用的是同一份store,這是不應該的。那么這么解這個問題呢?
在全局的store/index.js下修改如下:
~~~
//導出部分修改
export default () => {
return createStore(reducer, applyMiddleware(thunk))
}
復制代碼
~~~
這樣在客戶端和服務端的js文件引入時其實引入了一個函數,把這個函數執行就會拿到一個新的store,這樣就能保證每個用戶訪問時都是用的一份新的store。
<br>
<br>
# 異步數據的服務端渲染方案(數據注水與脫水)
## 問題引入
在平常客戶端的React開發中,我們一般在組件的componentDidMount生命周期函數進行異步數據的獲取。但是,在服務端渲染中卻出現了問題。
現在我在componentDidMount鉤子函數中進行Ajax請求:
~~~
import { getHomeList } from './store/actions'
//......
componentDidMount() {
this.props.getList();
}
//......
const mapDispatchToProps = dispatch => ({
getList() {
dispatch(getHomeList());
}
})
~~~
<br>
~~~
//actions.js
import { CHANGE_LIST } from "./constants";
import axios from 'axios'
const changeList = list => ({
type: CHANGE_LIST,
list
})
export const getHomeList = () => {
return dispatch => {
//另外起的本地的后端服務
return axiosInstance.get('localhost:4000/api/news.json')
.then((res) => {
const list = res.data.data;
dispatch(changeList(list))
})
}
}
//reducer.js
import { CHANGE_LIST } from "./constants";
const defaultState = {
name: 'sanyuan',
list: []
}
export default (state = defaultState, action) => {
switch(action.type) {
case CHANGE_LIST:
const newState = {
...state,
list: action.list
}
return newState
default:
return state;
}
}
~~~
好,現在啟動服務。

現在頁面能夠正常渲染,但是打開網頁源代碼。
<br>

源代碼里面并沒有這些列表數據啊!那這是為什么呢?
<br>
讓我們來分析一下客戶端和服務端的運行流程,當瀏覽器發送請求時,服務器接受到請求,這時候服務器和客戶端的store都是空的,緊接著客戶端執行componentDidMount生命周期中的函數,獲取到數據并渲染到頁面,然而服務器端始終不會執行componentDidMount,因此不會拿到數據,這也導致服務器端的store始終是空的。換而言之,關于異步數據的操作始終只是客戶端渲染。
<br>
現在的工作就是讓服務端將獲得數據的操作執行一遍,以達到真正的服務端渲染的效果。
<br>
## 改造路由
在完成這個方案之前需要改造一下原有的路由,也就是routes.js
~~~
import Home from './containers/Home';
import Login from './containers/Login';
export default [
{
path: "/",
component: Home,
exact: true,
loadData: Home.loadData,//服務端獲取異步數據的函數
key: 'home'
},
{
path: '/login',
component: Login,
exact: true,
key: 'login'
}
}];
~~~
此時客戶端和服務端中編寫的JSX代碼也發生了相應變化
~~~
//客戶端
//以下的routes變量均指routes.js導出的數組
<Provider store={store}>
<BrowserRouter>
<div>
{
routers.map(route => {
<Route {...route} />
})
}
</div>
</BrowserRouter>
</Provider>
~~~
<br>
~~~
//服務端
<Provider store={store}>
<StaticRouter>
<div>
{
routers.map(route => {
<Route {...route} />
})
}
</div>
</StaticRouter>
</Provider>
~~~
<br>
其中配置了一個loadData參數,這個參數代表了服務端獲取數據的函數。每次渲染一個組件獲取異步數據時,都會調用相應組件的這個函數。因此,在編寫這個函數具體的代碼之前,我們有必要想清楚如何來針對不同的路由來匹配不同的loadData函數。
在server/utils.js中加入以下邏輯
~~~
import { matchRoutes } from 'react-router-config';
//調用matchRoutes用來匹配當前路由(支持多級路由)
const matchedRoutes = matchRoutes(routes, req.path)
//promise對象數組
const promises = [];
matchedRoutes.forEach(item => {
//如果這個路由對應的組件有loadData方法
if (item.route.loadData) {
//那么就執行一次,并將store傳進去
//注意loadData函數調用后需要返回Promise對象
promises.push(item.route.loadData(store))
}
})
Promise.all(promises).then(() => {
//此時該有的數據都已經到store里面去了
//執行渲染的過程(res.send操作)
}
)
~~~
<br>
現在就可以安心的寫我們的loadData函數,其實前面的鋪墊工作做好后,這個函數是相當容易的。
~~~
import { getHomeList } from './store/actions'
Home.loadData = (store) => {
return store.dispatch(getHomeList())
}
~~~
<br>
~~~
//actions.js
export const getHomeList = () => {
return dispatch => {
return axios.get('xxxx')
.then((res) => {
const list = res.data.data;
dispatch(changeList(list))
})
}
}
~~~
根據這個思路,服務端渲染中異步數據的獲取功能就完成啦。
<br>
## 數據的注水和脫水
其實目前做了這里還是存在一些細節問題的。比如當我將生命周期鉤子里面的異步請求函數注釋,現在頁面中不會有任何的數據,但是打開網頁源代碼,卻發現:

<br>
數據已經掛載到了服務端返回的HTML代碼中。那這就說明服務端和客戶端的store不同步的問題。
其實也很好理解。當服務端拿到store并獲取數據后,客戶端的js代碼又執行一遍,在客戶端代碼執行的時候又創建了一個空的store,兩個store的數據不能同步。
那如何才能讓這兩個store的數據同步變化呢?
首先,在服務端獲取獲取之后,在返回的html代碼中加入這樣一個script標簽:
~~~
<script>
window.context = {
state: ${JSON.stringify(store.getState())}
}
</script>
~~~
這叫做數據的“注水”操作,即把服務端的store數據注入到window全局環境中。 接下來是“脫水”處理,換句話說也就是把window上綁定的數據給到客戶端的store,可以在客戶端store產生的源頭進行,即在全局的store/index.js中進行。
~~~
//store/index.js
import {createStore, applyMiddleware, combineReducers} from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/Home/store';
const reducer = combineReducers({
home: homeReducer
})
//服務端的store創建函數
export const getStore = () => {
return createStore(reducer, applyMiddleware(thunk));
}
//客戶端的store創建函數
export const getClientStore = () => {
const defaultState = window.context ? window.context.state : {};
return createStore(reducer, defaultState, applyMiddleware(thunk));
}
復制代碼
~~~
至此,數據的脫水和注水操作完成。但是還是有一些瑕疵,其實當服務端獲取數據之后,客戶端并不需要再發送Ajax請求了,而客戶端的React代碼仍然存在這樣的浪費性能的代碼。怎么辦呢?
還是在Home組件中,做如下的修改:
~~~
componentDidMount() {
//判斷當前的數據是否已經從服務端獲取
//要知道,如果是首次渲染的時候就渲染了這個組件,則不會重復發請求
//若首次渲染頁面的時候未將這個組件渲染出來,則一定要執行異步請求的代碼
//這兩種情況對于同一組件是都是有可能發生的
if (!this.props.list.length) {
this.props.getHomeList()
}
}
~~~
<br>
<br>
# node作中間層及請求代碼優化
## 為什么要引入node中間層?
其實任何技術都是與它的應用場景息息相關的。這里我們反復談的SSR,其實不到萬不得已我們是用不著它的,SSR所解決的最大的痛點在于SEO,但它同時帶來了更昂貴的成本。不僅因為服務端渲染需要更加復雜的處理邏輯,還因為同構的過程需要服務端和客戶端都執行一遍代碼,這雖然對于客戶端并沒有什么大礙,但對于服務端卻是巨大的壓力,因為數量龐大的訪問量,對于每一次訪問都要另外在服務器端執行一遍代碼進行計算和編譯,大大地消耗了服務器端的性能,成本隨之增加。如果訪問量足夠大的時候,以前不用SSR的時候一臺服務器能夠承受的壓力現在或許要增加到10臺才能抗住。痛點在于SEO,但如果實際上對SEO要求并不高的時候,那使用SSR就大可不必了。
<br>
那同樣地,為什么要引入node作為中間層呢?它是處在哪兩者的中間?又是解決了什么場景下的問題?
<br>
在不用中間層的前后端分離開發模式下,前端一般直接請求后端的接口。但真實場景下,后端所給的數據格式并不是前端想要的,但處于性能原因或者其他的因素接口格式不能更改,這時候需要在前端做一些額外的數據處理操作。前端來操作數據本身無可厚非,但是當數據量變得龐大起來,那么在客戶端就是產生巨大的性能損耗,甚至影響到用戶體驗。在這個時候,node中間層的概念便應運而生。
<br>
它最終解決的前后端協作的問題。
<br>
一般的中間層工作流是這樣的:前端每次發送請求都是去請求node層的接口,然后node對于相應的前端請求做轉發,用node去請求真正的后端接口獲取數據,獲取后再由node層做對應的數據計算等處理操作,然后返回給前端。這就相當于讓node層替前端接管了對數據的操作。

<br>
## SSR框架中引入中間層
在之前搭建的SSR框架中,服務端和客戶端請求利用的是同一套請求后端接口的代碼,但這是不科學的。
<br>
對客戶端而言,最好通過node中間層。而對于這個SSR項目而言,node開啟的服務器本來就是一個中間層的角色,因而對于服務器端執行數據請求而言,就可以直接請求真正的后端接口啦。
~~~
//actions.js
//參數server表示當前請求是否發生在node服務端
const getUrl = (server) => {
return server ? 'xxxx(后端接口地址)' : '/api/sanyuan.json(node接口)';
}
//這個server參數是Home組件里面傳過來的,
//在componentDidMount中調用這個action時傳入false,
//在loadData函數中調用時傳入true, 這里就不貼組件代碼了
export const getHomeList = (server) => {
return dispatch => {
return axios.get(getUrl(server))
.then((res) => {
const list = res.data.data;
dispatch(changeList(list))
})
}
}
~~~
在server/index.js應拿到前端的請求做轉發,這里是直接用proxy形式來做,也可以用node單獨向后端發送一次HTTP請求。
~~~
//增加如下代碼
import proxy from 'express-http-proxy';
//相當于攔截到了前端請求地址中的/api部分,然后換成另一個地址
app.use('/api', proxy('http://xxxxxx(服務端地址)', {
proxyReqPathResolver: function(req) {
return '/api'+req.url;
}
}));
~~~
<br>
## 請求代碼優化
其實請求的代碼還是有優化的余地的,仔細想想,上面的server參數其實是不用傳遞的。
現在我們利用axios的instance和thunk里面的withExtraArgument來做一些封裝。
~~~
//新建server/request.js
import axios from 'axios'
const instance = axios.create({
baseURL: 'http://xxxxxx(服務端地址)'
})
export default instance
//新建client/request.js
import axios from 'axios'
const instance = axios.create({
//即當前路徑的node服務
baseURL: '/'
})
export default instance
~~~
<br>
然后對全局下store的代碼做一個微調:
~~~
import {createStore, applyMiddleware, combineReducers} from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/Home/store';
import clientAxios from '../client/request';
import serverAxios from '../server/request';
const reducer = combineReducers({
home: homeReducer
})
export const getStore = () => {
//讓thunk中間件帶上serverAxios
return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios)));
}
export const getClientStore = () => {
const defaultState = window.context ? window.context.state : {};
//讓thunk中間件帶上clientAxios
return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)));
}
~~~
現在Home組件中請求數據的action無需傳參,actions.js中的請求代碼如下:
~~~
export const getHomeList = () => {
//返回函數中的默認第三個參數是withExtraArgument傳進來的axios實例
return (dispatch, getState, axiosInstance) => {
return axiosInstance.get('/api/sanyuan.json')
.then((res) => {
const list = res.data.data;
console.log(res)
dispatch(changeList(list))
})
}
}
~~~
<br>
<br>
# 多級路由渲染(renderRoutes)
現在將routes.js的內容改變如下:
~~~
import Home from './containers/Home';
import Login from './containers/Login';
import App from './App'
//這里出現了多級路由
export default [{
path: '/',
component: App,
routes: [
{
path: "/",
component: Home,
exact: true,
loadData: Home.loadData,
key: 'home',
},
{
path: '/login',
component: Login,
exact: true,
key: 'login',
}
]
}]
~~~
現在的需求是讓頁面公用一個Header組件,App組件編寫如下:
~~~
import React from 'react';
import Header from './components/Header';
const App = (props) => {
console.log(props.route)
return (
<div>
<Header></Header>
</div>
)
}
export default App;
~~~
對于多級路由的渲染,需要服務端和客戶端各執行一次。 因此編寫的JSX代碼都應有所實現:
~~~
//routes是指routes.js中返回的數組
//服務端:
<Provider store={store}>
<StaticRouter location={req.path} >
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
</Provider>
//客戶端:
<Provider store={getClientStore()}>
<BrowserRouter>
<div>
{renderRoutes(routes)}
</div>
</BrowserRouter>
</Provider>
~~~
這里都用到了renderRoutes方法,其實它的工作非常簡單,就是根據url渲染一層路由的組件(這里渲染的是App組件),然后將下一層的路由通過props傳給目前的App組件,依次循環。
<br>
那么,在App組件就能通過props.route.routes拿到下一層路由進行渲染:
~~~
import React from 'react';
import Header from './components/Header';
//增加renderRoutes方法
import { renderRoutes } from 'react-router-config';
const App = (props) => {
console.log(props.route)
return (
<div>
<Header></Header>
<!--拿到Login和Home組件的路由-->
{renderRoutes(props.route.routes)}
</div>
)
}
export default App;
~~~
<br>
<br>
# CSS的服務端渲染思路
## 客戶端項目中引入CSS
還是以Home組件為例
~~~
//Home/style.css
body {
background: gray;
}
~~~
<br>
現在,在Home組件代碼中引入:
~~~
import styles from './style.css';
~~~
<br>
要知道這樣的引入CSS代碼的方式在一般環境下是運行不起來的,需要在webpack中做相應的配置。 首先安裝相應的插件。
<br>
~~~
npm install style-loader css-loader --D
~~~
<br>
~~~
//webpack.client.js
const path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base');
const clientConfig = {
mode: 'development',
entry: './src/client/index.js',
module: {
rules: [{
test: /\.css?$/,
use: ['style-loader', {
loader: 'css-loader',
options: {
modules: true
}
}]
}]
},
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'public')
},
}
module.exports = merge(config, clientConfig);
~~~
<br>
好,現在在客戶端CSS已經產生了效果。

可是打開網頁源代碼:

<br>
咦?里面并沒有出現任何有關CSS樣式的代碼啊!那這是什么原因呢?很簡單,其實我們的服務端的CSS加載還沒有做。接下來我們來完成CSS代碼的服務端的處理。
<br>
## 服務端CSS的引入
首先,來安裝一個webpack的插件,
~~~
npm install -D isomorphic-style-loader
~~~
然后中做好相應的css配置:
> 注意, css-loader 中要加上 esModule: false,否則輸出`<style><style>` 標簽時CSS內容會顯示為 `[Object Module]`
>
~~~
// webpack.server.js
// ...
const serverConfig = {
// ...
module: {
rules: [{
test: /\.css?$/,
use: ['isomorphic-style-loader', {
loader: 'css-loader',
options: {
modules: true
}
}]
}]
},
}
~~~
~~~
// webpack.client.js
// ...
const clientConfig = {
// ...
module: {
rules: [{
test: /\.css$/,
use: ['isomorphic-style-loader', 'style-loader', {
loader: 'css-loader',
options: {
esModule: false,
}
}]
}]
}
}
~~~
<br>
然后,分別在客戶端入口和服務端入口添加配置:
~~~
// client/index.js
import StyleContext from 'isomorphic-style-loader/StyleContext'
const insertCss = (...styles) => {
const removeCss = styles.map(style => style._insertCss())
return () => removeCss.forEach(dispose => dispose())
}
const App = () => {
return (
<Provider store={getClientStore()}>
// 添加 StyleContext
<StyleContext.Provider value={{ insertCss }}>
<BrowserRouter>
<div>
{renderRoutes(Routes)}
</div>
</BrowserRouter>
</StyleContext.Provider>
</Provider>
)
}
~~~
<br>
~~~
// server/index.js
import?StyleContext?from'isomorphic-style-loader/StyleContext'
export const render = (store, routes, req, context) => {
const css = new Set();
const insertCss = (...styles) => styles.forEach(style => {
css.add(style._getCss())
})
//構建服務端的路由
const content = renderToString(
<Provider store={store}>
// 添加 StyleContext
<StyleContext.Provider value={{ insertCss }}>
<StaticRouter location={req.path} >
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
</StyleContext.Provider>
</Provider>
);
return `
<html>
<head>
<title>ssr</title>
// 添加 style 標簽
<style>${[...css].join('')}</style>
</head>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringify(store.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>
`
}
~~~
最后,修改 組件
~~~
// container/Home/index.js
import styles from './style.css';
import withStyles from 'isomorphic-style-loader/withStyles';
class Home extends Component {
render() {
const { list } = this.props
return list.map(item => <div key={item.id}>{item.title}</div>)
}
}
// 連接store
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(Home));
~~~
<br>
<br>
# 做好SEO的一些技巧,引入react-helmet
## 引入react-helmet
~~~
npm install react-helmet --save
~~~
組件代碼:(還是以Home組件為例)
~~~
import { Helmet } from 'react-helmet';
//...
render() {
return (
<React.Fragment>
<!--Helmet標簽中的內容會被放到客戶端的head部分-->
<Helmet>
<title>這是三元的技術博客,分享前端知識</title>
<meta name="description" content="這是三元的技術博客,分享前端知識"/>
</Helmet>
<div className="test">
{
this.getList()
}
</div>
</React.Fragment>
);
//...
~~~
這只是做了客戶端的部分,在服務端仍需要做相應的處理。
~~~
//server/utils.js
// ...
import { Helmet } from 'react-helmet';
export const render = (store, routes, req, context) => {
// ...
//拿到helmet對象,然后在html字符串中引入
const helmet = Helmet.renderStatic();
return `
<html>
<head>
<style>${cssStr}</style>
${helmet.title.toString()}
${helmet.meta.toString()}
</head>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringify(store.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>
`
};
~~~
現在來看看效果:

<br>
<br>
# 參考資料
[從頭開始,徹底理解服務端渲染原理(8千字匯總長文)](https://juejin.cn/post/6844903881390964744)
- 第一部分 HTML
- meta
- meta標簽
- HTML5
- 2.1 語義
- 2.2 通信
- 2.3 離線&存儲
- 2.4 多媒體
- 2.5 3D,圖像&效果
- 2.6 性能&集成
- 2.7 設備訪問
- SEO
- Canvas
- 壓縮圖片
- 制作圓角矩形
- 全局屬性
- 第二部分 CSS
- CSS原理
- 層疊上下文(stacking context)
- 外邊距合并
- 塊狀格式化上下文(BFC)
- 盒模型
- important
- 樣式繼承
- 層疊
- 屬性值處理流程
- 分辨率
- 視口
- CSS API
- grid(未完成)
- flex
- 選擇器
- 3D
- Matrix
- AT規則
- line-height 和 vertical-align
- CSS技術
- 居中
- 響應式布局
- 兼容性
- 移動端適配方案
- CSS應用
- CSS Modules(未完成)
- 分層
- 面向對象CSS(未完成)
- 布局
- 三列布局
- 單列等寬,其他多列自適應均勻
- 多列等高
- 圣杯布局
- 雙飛翼布局
- 瀑布流
- 1px問題
- 適配iPhoneX
- 橫屏適配
- 圖片模糊問題
- stylelint
- 第三部分 JavaScript
- JavaScript原理
- 內存空間
- 作用域
- 執行上下文棧
- 變量對象
- 作用域鏈
- this
- 類型轉換
- 閉包(未完成)
- 原型、面向對象
- class和extend
- 繼承
- new
- DOM
- Event Loop
- 垃圾回收機制
- 內存泄漏
- 數值存儲
- 連等賦值
- 基本類型
- 堆棧溢出
- JavaScriptAPI
- document.referrer
- Promise(未完成)
- Object.create
- 遍歷對象屬性
- 寬度、高度
- performance
- 位運算
- tostring( ) 與 valueOf( )方法
- JavaScript技術
- 錯誤
- 異常處理
- 存儲
- Cookie與Session
- ES6(未完成)
- Babel轉碼
- let和const命令
- 變量的解構賦值
- 字符串的擴展
- 正則的擴展
- 數值的擴展
- 數組的擴展
- 函數的擴展
- 對象的擴展
- Symbol
- Set 和 Map 數據結構
- proxy
- Reflect
- module
- AJAX
- ES5
- 嚴格模式
- JSON
- 數組方法
- 對象方法
- 函數方法
- 服務端推送(未完成)
- JavaScript應用
- 復雜判斷
- 3D 全景圖
- 重載
- 上傳(未完成)
- 上傳方式
- 文件格式
- 渲染大量數據
- 圖片裁剪
- 斐波那契數列
- 編碼
- 數組去重
- 淺拷貝、深拷貝
- instanceof
- 模擬 new
- 防抖
- 節流
- 數組扁平化
- sleep函數
- 模擬bind
- 柯里化
- 零碎知識點
- 第四部分 進階
- 計算機原理
- 數據結構(未完成)
- 算法(未完成)
- 排序算法
- 冒泡排序
- 選擇排序
- 插入排序
- 快速排序
- 搜索算法
- 動態規劃
- 二叉樹
- 瀏覽器
- 瀏覽器結構
- 瀏覽器工作原理
- HTML解析
- CSS解析
- 渲染樹構建
- 布局(Layout)
- 渲染
- 瀏覽器輸入 URL 后發生了什么
- 跨域
- 緩存機制
- reflow(回流)和repaint(重繪)
- 渲染層合并
- 編譯(未完成)
- Babel
- 設計模式(未完成)
- 函數式編程(未完成)
- 正則表達式(未完成)
- 性能
- 性能分析
- 性能指標
- 首屏加載
- 優化
- 瀏覽器層面
- HTTP層面
- 代碼層面
- 構建層面
- 移動端首屏優化
- 服務器層面
- bigpipe
- 構建工具
- Gulp
- webpack
- Webpack概念
- Webpack工具
- Webpack優化
- Webpack原理
- 實現loader
- 實現plugin
- tapable
- Webpack打包后代碼
- rollup.js
- parcel
- 模塊化
- ESM
- 安全
- XSS
- CSRF
- 點擊劫持
- 中間人攻擊
- 密碼存儲
- 測試(未完成)
- 單元測試
- E2E測試
- 框架測試
- 樣式回歸測試
- 異步測試
- 自動化測試
- PWA
- PWA官網
- web app manifest
- service worker
- app install banners
- 調試PWA
- PWA教程
- 框架
- MVVM原理
- Vue
- Vue 餓了么整理
- 樣式
- 技巧
- Vue音樂播放器
- Vue源碼
- Virtual Dom
- computed原理
- 數組綁定原理
- 雙向綁定
- nextTick
- keep-alive
- 導航守衛
- 組件通信
- React
- Diff 算法
- Fiber 原理
- batchUpdate
- React 生命周期
- Redux
- 動畫(未完成)
- 異常監控、收集(未完成)
- 數據采集
- Sentry
- 貝塞爾曲線
- 視頻
- 服務端渲染
- 服務端渲染的利與弊
- Vue SSR
- React SSR
- 客戶端
- 離線包
- 第五部分 網絡
- 五層協議
- TCP
- UDP
- HTTP
- 方法
- 首部
- 狀態碼
- 持久連接
- TLS
- content-type
- Redirect
- CSP
- 請求流程
- HTTP/2 及 HTTP/3
- CDN
- DNS
- HTTPDNS
- 第六部分 服務端
- Linux
- Linux命令
- 權限
- XAMPP
- Node.js
- 安裝
- Node模塊化
- 設置環境變量
- Node的event loop
- 進程
- 全局對象
- 異步IO與事件驅動
- 文件系統
- Node錯誤處理
- koa
- koa-compose
- koa-router
- Nginx
- Nginx配置文件
- 代理服務
- 負載均衡
- 獲取用戶IP
- 解決跨域
- 適配PC與移動環境
- 簡單的訪問限制
- 頁面內容修改
- 圖片處理
- 合并請求
- PM2
- MongoDB
- MySQL
- 常用MySql命令
- 自動化(未完成)
- docker
- 創建CLI
- 持續集成
- 持續交付
- 持續部署
- Jenkins
- 部署與發布
- 遠程登錄服務器
- 增強服務器安全等級
- 搭建 Nodejs 生產環境
- 配置 Nginx 實現反向代理
- 管理域名解析
- 配置 PM2 一鍵部署
- 發布上線
- 部署HTTPS
- Node 應用
- 爬蟲(未完成)
- 例子
- 反爬蟲
- 中間件
- body-parser
- connect-redis
- cookie-parser
- cors
- csurf
- express-session
- helmet
- ioredis
- log4js(未完成)
- uuid
- errorhandler
- nodeclub源碼
- app.js
- config.js
- 消息隊列
- RPC
- 性能優化
- 第七部分 總結
- Web服務器
- 目錄結構
- 依賴
- 功能
- 代碼片段
- 整理
- 知識清單、博客
- 項目、組件、庫
- Node代碼
- 面試必考
- 91算法
- 第八部分 工作代碼總結
- 樣式代碼
- 框架代碼
- 組件代碼
- 功能代碼
- 通用代碼