教你如何在React及Redux项目中进行服务端渲染
? 今儿做了个Demo,看看如何在实际的项目中实现服务端渲染 项目地址?,欢迎围观! 有纯粹的?React,也有?Redux?作为状态管理 使用?webpack 监听编译文件,nodemon 监听服务器文件变动 使用?redux-saga 处理异步action,使用 express 处理页面渲染 本项目包含四个页面,四种组合,满满的干货,文字可能说不清楚,就去看代码吧!
? 一、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‘,userName: ‘王安石‘ },{ id: ‘4‘,content: ‘我是消息我是消息我是消息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 ( <div> <h4>消息列表</h4> <div className="msg-items"> { this.state.msgs.map(item => { return ( <div key={item.id} className="msg-item"> <p className="msg-item__header">{item.userName} - {item.time}</p> <p className="msg-item__content">{item.content}</p> <a href="javascript:;" className="msg-item__read" onClick={this.msgRead.bind(this,item.id)}>×</a> </div> ) }) } </div> </div> ) } } render(<Message />,document.getElementById(‘content‘)); 是不是很简单,代码比较简单就不说了 来看看页面效果 可以看到页面白屏时间比较长 这里有两个白屏 1. 加载完JS后才初始化标题 2. 进行异步请求数据,再将消息列表渲染 看起来是停顿地比较久的,那么使用服务端渲染有什么效果呢? ? 二. React + SSR在讲如何实现之前,先看看最终效果 可以看到页面是直出的,没有停顿 ? 在React 15中,实现服务端渲染主要靠的是 ReactDOMServer 的 renderToString 和 renderToStaticMarkup方法。 let ReactDOMServer = require(‘react-dom/server‘); ReactDOMServer.renderToString(<Message preloadState={preloadState} />) ReactDOMServer.renderToStaticMarkup(<Message preloadState={preloadState} />) 将组件直接在服务端处理为字符串,我们根据传入的初始状态值,在服务端进行组件的初始化 然后在Node环境中返回,比如在Express框架中,返回渲染一个模板文件 res.render(‘messageClient/message.html‘,{ appHtml: appHtml,preloadState: JSON.stringify(preloadState).replace(/</g,‘u003c‘) }); 这里设置了两个变量传递给模板文件 appHtml 即为处理之后的组件字符串 preloadState 为服务器中的初始状态,浏览器的后续工作要基于这个初始状态,所以需要将此变量传递给浏览器初始化 <div id="content"> <|- appHtml |> </div> <script id="preload-state"> var PRELOAD_STATE = <|- preloadState |> </script> 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文件提取等无关的处理的,关注编译代码初始化组件状态即可 client和server只是入口,它们的公共部分在 common 目录中 在client中,直接渲染导入的组件?? import React,{Component} from ‘react‘; import {render,hydrate,findDOMNode} from ‘react-dom‘; import Message from ‘../common/message‘; hydrate(<Message />,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(<Message preloadState={preloadState} />); }; 需要注意的是,这里不能直接使用 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‘,‘u003c‘) }); } }; 使用到Node来开启服务,每次改了服务器文件之后就得重启比较麻烦 使用 nodemon工具来监听文件修改自动更新服务器,添加配置文件 nodemon.json { "restartable": "rs","ignore": [ ".git","node_modules/**/node_modules" ],"verbose": true,"execMap": { "js": "node --harmony" },"watch": [ "server/","public/static/distSSR" ],"env": { "NODE_ENV": "development" },"ext": "js,json" } 当然,对于Node环境不支持JSX这个问题,除了使用webpack进行编译之外, 还可以在Node中执行 babel-node 来即时地编译文件,不过这种方式会导致每次编译非常久(至少比webpack久) ? 在React16 中,ReactDOMServer 除了拥有 renderToString 和 renderToStaticMarkup这两个方法之外, 还有?renderToNodeStream ?和?renderToStaticNodeStream 两个流的方法 它们不是返回一个字符串,而是返回一个可读流,一个用于发送字节流的对象的Node Stream类 渲染到流可以减少你的内容的第一个字节(TTFB)的时间,在文档的下一部分生成之前,将文档的开头至结尾发送到浏览器。 当内容从服务器流式传输时,浏览器将开始解析HTML文档 以下是使用实例,本文不展开 // using Express import { renderToNodeStream } from "react-dom/server" import MyPage from "./MyPage" app.get("/",(req,res) => { res.write("<!DOCTYPE html><html><head><title>My Page</title></head><body>"); res.write("<div id=‘content‘>"); const stream = renderToNodeStream(<MyPage/>); stream.pipe(res,{ end: false }); stream.on(‘end‘,() => { res.write("</div></body></html>"); res.end(); }); }); ? 这便是在React中进行服务端渲染的流程了,说得有点泛泛,还是自己去看?项目代码?吧 ? 三、React + ReduxReact的中的数据是单向流动的,即父组件状态改变之后,可以通过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,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 ( <Provider store={store}> <Home /> </Provider> ) } } render(<App />,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,composeEnhancers( applyMiddleware(sagaMiddleware) )); sagaMiddleware.run(wordListSaga); export default store; 我们将一部分action(基本是异步的)交给saga处理 在workListSaga.js中, 1 import {delay} from ‘redux-saga‘; 2 import {put,fork,takeEvery,takeLatest,call,all,select} from ‘redux-saga/effects‘; 3 4 import * as actionTypes from ‘./types‘; 5 6 /** 7 * 获取用户信息 8 * @yield {[type]} [description] 9 */ 10 function* getUserInfoHandle() { 11 let state = yield select(); 12 13 return yield new Promise((resolve,reject) => { 14 setTimeout(() => { 15 resolve({ 16 sex: ‘male‘,17 age: 18,18 name: ‘王羲之‘,19 avatar: ‘/public/static/imgs/avatar.png‘ 20 }); 21 },500); 22 }); 23 } 24 25 /** 26 * 获取工作列表 27 * @yield {[type]} [description] 28 */ 29 function* getWorkListHandle() { 30 let state = yield select(); 31 32 return yield new Promise((resolve,reject) => { 33 setTimeout(() => { 34 resolve({ 35 todo: [{ 36 id: ‘1‘,37 content: ‘跑步‘ 38 },{ 39 id: ‘2‘,40 content: ‘游泳‘ 41 }],42 43 done: [{ 44 id: ‘13‘,45 content: ‘看书‘ 46 },{ 47 id: ‘24‘,48 content: ‘写代码‘ 49 }] 50 }); 51 },1000); 52 }); 53 } 54 55 /** 56 * 获取页面数据,action.payload中如果为回调,可以处理一些异步数据初始化之后的操作 57 * @param {[type]} action [description] 58 * @yield {[type]} [description] 59 */ 60 function* getPageInfoAsync(action) { 61 console.log(action); 62 63 let userInfo = yield call(getUserInfoHandle); 64 65 yield put({ 66 type: actionTypes.INIT_USER_INFO,67 payload: userInfo 68 }); 69 70 let workList = yield call(getWorkListHandle); 71 72 yield put({ 73 type: actionTypes.INIT_WORK_LIST,74 payload: workList 75 }); 76 77 console.log(‘saga done‘); 78 79 typeof action.payload === ‘function‘ && action.payload(); 80 } 81 82 /** 83 * 获取页面数据 84 * @yield {[type]} [description] 85 */ 86 export default function* getPageInfo() { 87 yield takeLatest(actionTypes.INIT_PAGE,getPageInfoAsync); 88 } 监听页面的初始化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({},{ todo: action.payload.todo,done: action.payload.done }); // 添加任务 case actionTypes.ADD_WORK_TODO: return Object.assign({},{ todo: action.payload }); // 设置任务完成 case actionTypes.SET_WORK_DONE: return Object.assign({},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方法 initPage: (cb) => { dispatch(initPage(cb)); } }; } 当然,并不是只能给store绑定一个组件 如果某个组件的状态可以被其他组件共享,或者这个组件需要访问store,按根组件一层一层通过props传入很麻烦的话,也可以直接给这个组件绑定store 比如这里的 workList.jsx 也进行了绑定,user.jsx这种只需要展示数据的组件,或者其他一些自治(状态在内部管理,和外部无关)的组件,则不需要引入redux的store,也挺麻烦的 ? 绑定之后,我们需要在 Home组件中调用action,开始获取数据 /** * 初始获取数据之后的某些操作 * @return {[type]} [description] */ afterInit() { console.log(‘afterInit‘); } componentDidMount() { console.log(‘componentDidMount‘); // 初始化发出 INIT_PAGE 操作 this.props.initPage(() => { this.afterInit(); }); } 这里有个小技巧,如果在获取异步数据之后要接着进行其他操作,可以传入 callback ,我们在action的payload中置入了这个 callback,方便调用 然后Home组件中的已经没有多少state了,已经交由store管理,通过mapStateToProps传入 所以可以根据props拿到这些属性 <User {...this.props.userInfo} />
或者调用传入的 reducer ,间接地派发一些action // 执行 ADD_WORK_TODO this.props.addWorkTodo(this.props.todo,content.trim()); ? 页面呈现 ? 四、React + Redux + SSR可以看到上图是有一些闪动的,因为数据不是一开始就存在 考虑加入SSR,先来看看最终页面效果,功能差不多,但直接出来了,看起来很美好呀~ 在Redux中加入SSR,其实跟纯粹的React组件是类似的。 官方给了一个简单的例子 都是在服务器端获取初始状态后处理组件为字符串,区别主要是React直接使用state,Redux直接使用store 浏览器中我们可以为多个页面使用同一个store,但在服务器端不行,我们需要为每一个请求创建一个store ? 再来看项目结构,Redux的SSR使用到了红框中的文件 服务端路由homeSSR与messageSSR类似,都是返回数据 服务端入口文件 server中的home.js 则是创建一个新的 store,然后传入ReactDOMServer进行处理返回 import {createStore} from ‘redux‘; import reducers from ‘../store/reducers‘; import App from ‘../common/home‘; import defaultState from ‘../store/state‘; let ReactDOMServer = require(‘react-dom/server‘); export function init(preloadState) { // console.log(preloadState); let defaultState = Object.assign({},preloadState); // 服务器需要为每个请求创建一份store,并将状态初始化为preloadState let store = createStore( reducers,defaultState ); return ReactDOMServer.renderToString(<App store={store} />); }; 同样的,我们需要在common文件中处理 Node环境与浏览器环境的一些差异 比如在 home.jsx 中,加入 // 公共部分,在Node环境中无window document navigator 等对象 if (typeof window === ‘undefined‘) { // 设置win变量方便在其他地方判断环境 global.win = false; global.window = {}; global.document = {}; } 另外组件加载之后也不需要发请求获取数据了 /** * 初始获取数据之后的某些操作 * @return {[type]} [description] */ afterInit() { console.log(‘afterInit‘); } componentDidMount() { console.log(‘componentDidMount‘); // 初始化发出 INIT_PAGE 操作; // 已交由服务器渲染 // this.props.initPage(() => { this.afterInit(); // }); } common中的home.js入口文件用于给组件管理store,与未用SSR的文件不同(js目录下面的home.js入口) 它需要同时为浏览器端和服务器端服务,所以增加一些判断,然后导出 if (module.hot) { module.hot.accept(); } import React,{Component} from ‘react‘; import {render,findDOMNode} from ‘react-dom‘; import Home from ‘./homeComponent/home.jsx‘; import {Provider} from ‘react-redux‘; import store from ‘../store‘; class App extends Component { render() { // 如果为Node环境,则取由服务器返回的store值,否则使用 ../store中返回的值 let st = global.win === false ? this.props.store : store; return ( <Provider store={st}> <Home /> </Provider> ) } } export default App; 浏览器端的入口文件 home.js 直接引用渲染即可 import React,findDOMNode} from ‘react-dom‘; import App from ‘../common/home‘; // render(<App />,document.getElementById(‘content‘)); hydrate(<App />,document.getElementById(‘content‘)); ? 这便是Redux 加上 SSR之后的流程了 ? 其实还漏了一个Express的server.js服务文件,也就一点点代码 1 const express = require(‘express‘); 2 const path = require(‘path‘); 3 const app = express(); 4 const ejs = require(‘ejs‘); 5 6 // 常规路由页面 7 let home = require(‘./routes/home‘); 8 let message = require(‘./routes/message‘); 9 10 // 用于SSR服务端渲染的页面 11 let homeSSR = require(‘./routes/homeSSR‘); 12 let messageSSR = require(‘./routes/messageSSR‘); 13 14 app.use(express.static(path.join(__dirname,‘../‘))); 15 16 // 自定义ejs模板 17 app.engine(‘html‘,ejs.__express); 18 app.set(‘view engine‘,‘html‘); 19 ejs.delimiter = ‘|‘; 20 21 app.set(‘views‘,path.join(__dirname,‘../views/‘)); 22 23 app.get(‘/home‘,home); 24 app.get(‘/message‘,message); 25 26 app.get(‘/ssr/home‘,homeSSR); 27 app.get(‘/ssr/message‘,messageSSR); 28 29 let port = 12345; 30 31 app.listen(port,function() { 32 console.log(`Server listening on ${port}`); 33 }); ? 文章说得错错乱乱的,可能没那么好理解,还是去看 项目文件 自己琢磨吧,自己弄下来编译运行看看 ? 五、其他如果项目使用了其他服务器语言的,比如PHP Yii框架 Smarty ,把服务端渲染整起来可能没那么容易 其一是 smarty的模板语法和ejs的不太搞得来 其二是Yii框架的路由和Express的长得不太一样 ? 在Nginx中配置Node的反向代理,配置一个 upstream ,然后在server中匹配 location ,进行代理配置 upstream connect_node { server localhost:54321; keepalive 64; } ... server { listen 80; ... location / { index index.php index.html index.htm; } location ~ (home|message)/d+$ { proxy_pass http://connect_node; } ... 更多配置 ? 想得头大,干脆就不想了,有用过Node进行中转代理实现SSR的朋友,欢迎评论区分享哈~ (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |