[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算法
- 第八部分 工作代码总结
- 样式代码
- 框架代码
- 组件代码
- 功能代码
- 通用代码