教你如何在React及Redux项目中进行服务端渲染
服务端渲染(SSR: Server Side Rendering)在React项目中有着广泛的应用场景
基于React虚拟DOM的特性,在浏览器端和服务端我们可以实现同构(可以使用同一份代码来实现多端的功能)
服务端渲染的优点主要由三点
1. 利于SEO
2. 提高首屏渲染速度
3. 同构直出,使用同一份(JS)代码实现,便于开发和维护
一起看看如何在实际的项目中实现服务端渲染
项目地址 ,欢迎围观!
有纯粹的 React,也有 Redux 作为状态管理
使用 webpack 监听编译文件,nodemon 监听服务器文件变动
使用 redux-saga 处理异步action,使用 express 处理页面渲染
本项目包含四个页面,四种组合,满满的干货,文字可能说不清楚,就去看代码吧!
React
React + SSR
React + Redux
React + Redux + SSR
一、React
实现一个最基本的React组件,就能搞掂第一个页面了
复制代码
/**
* 消息列表
*/
class Message extends Component {
constructor(props) {
super(props);
this.state = {
msgs: []
};
}
componentDidMount() {
setTimeout(() => {
this.setState({
msgs: [{
id: '1',
content: '我是消息我是消息我是消息',
time: '2018-11-23 12:33:44',
userName: '王羲之'
}, {
id: '2',
content: '我是消息我是消息我是消息2',
time: '2018-11-23 12:33:45',
userName: '王博之'
}, {
id: '3',
content: '我是消息我是消息我是消息3',
time: '2018-11-23 12:33:44',
userName: '王安石'
}, {
id: '4',
content: '我是消息我是消息我是消息45',
time: '2018-11-23 12:33:45',
userName: '王明'
}]
});
}, 1000);
}
// 消息已阅
msgRead(id, e) {
let msgs = this.state.msgs;
let itemIndex = msgs.findIndex(item => item.id === id);
if (itemIndex !== -1) {
msgs.splice(itemIndex, 1);
this.setState({
msgs
});
}
}
render() {
return (
消息列表
{
this.state.msgs.map(item => {
return (
{item.userName} - {item.time}
{item.content}
×
)
})
}
)
}
}
render(
, document.getElementById('content'));
复制代码
是不是很简单,代码比较简单就不说了
来看看页面效果
可以看到页面白屏时间比较长
这里有两个白屏
1. 加载完JS后才初始化标题
2. 进行异步请求数据,再将消息列表渲染
看起来是停顿地比较久的,那么使用服务端渲染有什么效果呢?
二. React + SSR
在讲如何实现之前,先看看最终效果
可以看到页面是直出的,没有停顿
在React 15中,实现服务端渲染主要靠的是 ReactDOMServer 的 renderToString 和 renderToStaticMarkup方法。
复制代码
let ReactDOMServer = require('react-dom/server');
ReactDOMServer.renderToString(
)
ReactDOMServer.renderToStaticMarkup(
)
复制代码
将组件直接在服务端处理为字符串,我们根据传入的初始状态值,在服务端进行组件的初始化
然后在Node环境中返回,比如在Express框架中,返回渲染一个模板文件
复制代码
res.render('messageClient/message.html', {
appHtml: appHtml,
preloadState: JSON.stringify(preloadState).replace(/
<|- appHtml |>
复制代码
express框架返回之后即为在浏览器中看到的初始页面
需要注意的是这里的ejs模板进行了自定义分隔符,因为webpack在进行编译时,HtmlWebpackPlugin 插件中自带的ejs处理器可能会和这个模板中的ejs变量冲突
在express中自定义即可
复制代码
// 自定义ejs模板
app.engine('html', ejs.__express);
app.set('view engine', 'html');
ejs.delimiter = '|';
复制代码
接下来,在浏览器环境的组件中(以下这个文件为公共文件,浏览器端和服务器端共用),我们要按照 PRELOAD_STATE 这个初始状态来初始化组件
复制代码
class Message extends Component {
constructor(props) {
super(props);
this.state = {
msg: []
};
// 根据服务器返回的初始状态来初始化
if (typeof PRELOAD_STATE !== 'undefined') {
this.state.msgs = PRELOAD_STATE;
// 清除
PRELOAD_STATE = null;
document.getElementById('preload-state').remove();
}
// 此文件为公共文件,服务端调用此组件时会传入初始的状态preloadState
else {
this.state.msgs = this.props.preloadState;
}
console.log(this.state);
}
componentDidMount() {
// 此处无需再发请求,由服务器处理
}
...
复制代码
核心就是这些了,这就完了么?
哪有那么快,还得知道如何编译文件(JSX并不是原生支持的),服务端如何处理,浏览器端如何处理
接下来看看项目的文件结构
把注意力集中到红框中
直接由webpack.config.js同时编译浏览器端和服务端的JS模块
复制代码
module.exports = [
clientConfig,
serverConfig
];
复制代码
浏览器端的配置使用 src 下的 client目录,编译到 dist 目录中
服务端的配置使用 src 下的 server 目录,编译到 distSSR 目录中。在服务端的配置中就不需要进行css文件提取等无关的处理的,关注编译代码初始化组件状态即可
另外,服务端的配置的ibraryTarget记得使用 'commonjs2',才能为Node环境所识别
复制代码
// 文件输出配置
output: {
// 输出所在目录
path: path.resolve(__dirname, '../public/static/distSSR/js/'),
filename: '[name].js',
library: 'node',
libraryTarget: 'commonjs2'
},
复制代码
client和server只是入口,它们的公共部分在 common 目录中
在client中,直接渲染导入的组件
复制代码
import React, {Component} from 'react';
import {render, hydrate, findDOMNode} from 'react-dom';
import Message from '../common/message';
hydrate(
, document.getElementById('content'));
复制代码
这里有个 render和hydrate的区别
在进行了服务端渲染之后,浏览器端使用render的话会按照状态重新初始化一遍组件,可能会有抖动的情况;使用 hydrate则只进行组件事件的初始化,组件不会从头初始化状态
建议使用hydrate方法,在React17中 使用了服务端渲染之后,render将不再支持
在 server中,导出这个组件给 express框架调用
复制代码
import Message from '../common/message';
let ReactDOMServer = require('react-dom/server');
/**
* 提供给Node环境调用,传入初始状态
* @param {[type]} preloadState [description]
* @return {[type]} [description]
*/
export function init(preloadState) {
return ReactDOMServer.renderToString(
);
};
复制代码
需要注意的是,这里不能直接使用 module.exports = ... 因为webpack不支持ES6的 import 和这个混用
在 common中,处理一些浏览器端和服务器端的差异,再导出
这里的差异主要是变量的使用问题,在Node中没有window document navigator 等对象,直接使用会报错。且Node中的严格模式直接访问未定义的变量也会报错
所以需要用typeof 进行变量检测,项目中引用的第三方插件组件有使用到了这些浏览器环境对象的,要注意做好兼容,最简便的方法是在 componentDidMount 中再引入这些插件组件
另外,webpack的style-loader也依赖了这些对象,在服务器配置文件中需要将其移除
复制代码
{
test: /\.css$/,
loaders: [
// 'style-loader',
'happypack/loader?id=css'
]
}
复制代码
在Express的服务器框架中,messageSSR 路由 渲染页面之前做一些异步操作获取数据
复制代码
// 编译后的文件路径
let distPath = '../../public/static/distSSR/js';
module.exports = function(req, res, next) {
// 如果需要id
let id = 'req.params.id';
console.log(id);
getDefaultData(id);
async function getDefaultData(id) {
let appHtml = '';
let preloadState = await getData(id);
console.log('preloadState', preloadState);
try {
// 获取组件的值(字符串)
appHtml = require(`${distPath}/message`).init(preloadState);
} catch(e) {
console.log(e);
console.trace();
}
res.render('messageClient/message.html', {
appHtml: appHtml,
preloadState: JSON.stringify(preloadState).replace(/ {
res.write("
My Page");
res.write("
");
const stream = renderToNodeStream();
stream.pipe(res, { end: false });
stream.on('end', () => {
res.write("
");
res.end();
});
});
复制代码
这便是在React中进行服务端渲染的流程了,说得有点泛泛,还是自己去看 项目代码 吧
三、React + Redux
React的中的数据是单向流动的,即父组件状态改变之后,可以通过props将属性传递给子组件,但子组件并不能直接修改父级的组件。
一般需要通过调用父组件传来的回调函数来间接地修改父级状态,或者使用 Context ,使用 事件发布订阅机制等。
引入了Redux进行状态管理之后,就方便一些了。不过会增加代码复杂度,另外要注意的是,React 16的新的Context特性貌似给Redux带来了不少冲击
在React项目中使用Redux,当某个处理有比较多逻辑时,遵循胖action瘦reducer,比较通用的建议时将主要逻辑放在action中,在reducer中只进行更新state的等简单的操作
一般还需要中间件来处理异步的动作(action),比较常见的有四种 redux-thunk redux-saga redux-promise redux-observable ,它们的对比
这里选用了 redux-saga,它比较优雅,管理异步也很有优势
来看看项目结构
我们将 home组件拆分出几个子组件便于维护,也便于和Redux进行关联
home.js 为入口文件
使用 Provider 包装组件,传入store状态渲染组件
复制代码
import React, {Component} from 'react';
import {render, findDOMNode} from 'react-dom';
import {Provider} from 'react-redux';
// 组件入口
import Home from './homeComponent/Home.jsx';
import store from './store';
/**
* 组装Redux应用
*/
class App extends Component {
render() {
return (
)
}
}
render(
, document.getElementById('content'));
复制代码
store/index.js 中为状态创建的过程
这里为了方便,就把服务端渲染的部分也放在一起了,实际上它们的区别不是很大,仅仅是 defaultState初始状态的不同而已
复制代码
import {createStore, applyMiddleware, compose} from 'redux';
import createSagaMiddleware from 'redux-saga';
// import {thunk} from 'redux-thunk';
import reducers from './reducers';
import wordListSaga from './workListSaga';
import state from './state';
const sagaMiddleware = createSagaMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
let defaultState = state;
// 用于SSR
// 根据服务器返回的初始状态来初始化
if (typeof PRELOAD_STATE !== 'undefined') {
defaultState = Object.assign({}, defaultState, PRELOAD_STATE);
// 清除
PRELOAD_STATE = null;
document.getElementById('preload-state').remove();
}
let store = createStore(
reducers,
defaultState,
composeEnhancers(
applyMiddleware(sagaMiddleware)
));
sagaMiddleware.run(wordListSaga);
export default store;
复制代码
我们将一部分action(基本是异步的)交给saga处理
在workListSaga.js中,
View Code
监听页面的初始化action actionTypes.INIT_PAGE ,获取数据之后再触发一个action ,转交给reducer即可
复制代码
let userInfo = yield call(getUserInfoHandle);
yield put({
type: actionTypes.INIT_USER_INFO,
payload: userInfo
});
复制代码
reducer中做的事主要是更新状态,
复制代码
import * as actionTypes from './types';
import defaultState from './state';
/**
* 工作列表处理
* @param {[type]} state [description]
* @param {[type]} action [description]
* @return {[type]} [description]
*/
function workListReducer(state = defaultState, action) {
switch (action.type) {
// 初始化用户信息
case actionTypes.INIT_USER_INFO:
// 返回新的状态
return Object.assign({}, state, {
userInfo: action.payload
});
// 初始化工作列表
case actionTypes.INIT_WORK_LIST:
return Object.assign({}, state, {
todo: action.payload.todo,
done: action.payload.done
});
// 添加任务
case actionTypes.ADD_WORK_TODO:
return Object.assign({}, state, {
todo: action.payload
});
// 设置任务完成
case actionTypes.SET_WORK_DONE:
return Object.assign({}, state, {
todo: action.payload.todo,
done: action.payload.done
});
default:
return state
}
}
复制代码
在 action.js中可以定义一些常规的action,比如
复制代码
export function addWorkTodo(todoList, content) {
let id = Math.random();
let todo = [...todoList, {
id,
content
}];
return {
type: actionTypes.ADD_WORK_TODO,
payload: todo
}
}
/**
* 初始化页面信息
* 此action为redux-saga所监听,将传入saga中执行
*/
export function initPage(cb) {
console.log(122)
return {
type: actionTypes.INIT_PAGE,
payload: cb
};
}
复制代码
回到刚才的 home.js入口文件,在其引入的主模块 home.jsx中,我们需要将redux的东西和这个 home.jsx绑定起来
复制代码
import {connect} from 'react-redux';
// 子组件
import User from './user';
import WorkList from './workList';
import {getUrlParam} from '../util/util'
import '../../scss/home.scss';
import {
initPage
} from '../store/actions.js';
/**
* 将redux中的state通过props传给react组件
* @param {[type]} state [description]
* @return {[type]} [description]
*/
function mapStateToProps(state) {
return {
userInfo: state.userInfo,
// 假如父组件Home也需要知悉子组件WorkList的这两个状态,则可以传入这两个属性
todo: state.todo,
done: state.done
};
}
/**
* 将redux中的dispatch方法通过props传给react组件
* @param {[type]} state [description]
* @return {[type]} [description]
*/
function mapDispatchToProps(dispatch, ownProps) {
return {
// 通过props传入initPage这个dispatch方法