教你如何在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方法
50000+
5万行代码练就真实本领
17年
创办于2008年老牌培训机构
1000+
合作企业
98%
就业率

联系我们

电话咨询

0532-85025005

扫码添加微信