react redux isomorphic SSR immutable router test node
React开源一年多以来,发展迅猛,已经不再是一个单纯的前端组件化开发框架,而是形成了一套完整的生态系统。
一直想写一个充分应用react生态系统的boilerplate,但又不想仅仅写一个没有任何功能的空壳。正好之前看到慕课网有Mater Liu贡献的react画廊应用教程,兼具学习型和实用性。那么我就从这里开始吧!
完成教程内容
Mater Liu的教程讲得很详细、透彻,我一节我就一笔带过了。唯一需要注意的是教程所使用的yeoman generator
generator-react-webpack
目前的版本已经全部改用ES6的实现了,不过对聪明的你来说应该不是问题,对吧?
通过教程的学习,我们已经用纯react完成了一个很漂亮的图片画廊应用,接下来让我们以此为基础去探索更广阔的react生态系统吧。
redux
Redux 是 JavaScript 状态容器,提供可预测化的状态管理。笔者在实际工作中充分体会过使用redux的便利性。通过他来管理单向数据流可以让页面的每次状态变化都有案可查。配合上devtool在应对庞大项目,页面状态频繁变化的情况中,他可以帮助你理顺你的业务流程,快速定位bug。所以让我们首先把他引进来。
原来的教程中把所有dom节点的生成,事件响应,数据绑定都放在一个js文件中,这显然是不合理的。我们先按照redux推荐的最佳实践来重构代码。首先把代码结构拆分如下:
我们把ImgFigure和ControllerUnit这两个类抽取出来作为展示组件,原先的stage作为容器组件。这样在程序的入口index.js中我们需要这样引进redux
ReactDOM.render( <Provider store={store}> <Stage /> </Provider>, document.getElementById('app'));我们通过
const store = (window.devToolsExtension ? window.devToolsExtension()(middleware(createStore)) : middleware(createStore))(rootReducer, initialState);来生成store,这样只要我们在chrome浏览器中安装了DevTools插件,就可以看到体验状态变化的时间之旅了。
redux帮我们维护了一个全局state树。重点是我们打算把哪些数据放到state中由redux保管。画廊应用这个项目中贯穿全局的有3个对象:stage表示页面各个区域的坐标一旦生成就不再改变,所以不需要redux管理,生成后放到stage组件内部state即可。imageDatas表示所有图片的文件名,url,描述等信息,生成后也没有改变过,但是一个完整项目这种数据一般通过后端API传入,并且将来可以做成可编辑的。imgsArrangeArr表示每张图片的位置、旋转、正反面等状态。根据用户的点击可以不断变化。因此我们只需要一个reducer,里面保存imageDatas和imgsArrangeArr两个数组记录image模块的状态即可。
const imgsArrangeArr = []; const imageDatas = require('../data/imageDatas.json').map((imageData) => { imageData.imageURL = require('../images/' + imageData.fileName); imgsArrangeArr.push({ pos: { left: 0, top: 0 }, rotate: 0, isInverse: false, isCenter: false }); return imageData;});const initialState = { imageDatas: imageDatas, imgsArrangeArr: imgsArrangeArr};export default function image(state = initialState, action = {}) { switch (action.type) { case INVERSE_IMAGE: const arrange = state.imgsArrangeArr[action.index]; const before = state.imgsArrangeArr.slice(0, action.index); const after = state.imgsArrangeArr.slice(action.index + 1); return { ...state, imgsArrangeArr: [ ...before, { ...arrange, isInverse: !arrange.isInverse }, ...after ] }; case REARRANGE: return { ...state, imgsArrangeArr: [...action.imgsArrangeArr] }; default: return state; }}很简单的我们这个项目只有两个action。INVERSE_IMAGE表示翻转图片。REARRANGE表示图片重新布局。
immutable.js
刚刚接触redux的人一定很不习惯reducer中修改state的方式。是的,facebook也想到了,他们捣鼓出一个immutable.js来解决这个痛点。那么我们上述的reducer可以改写成这样:
export default function image(state = initialState, action = {}) { const newState = Immutable.fromJS(state); switch (action.type) { case INVERSE_IMAGE: return newState.updateIn(['imgsArrangeArr', action.index, 'isInverse'], isInverse => !isInverse).toJS(); case REARRANGE: return newState.set('imgsArrangeArr', action.imgsArrangeArr).toJS(); default: return state; }}immutable让代码精简了许多,也好写了许多。不过笔者工作中并没有用它。其实习惯了ES6的展开运算符,不用immutable也能很轻松地修改state的。以上的示例代码在进入reducer函数中,总是先把state转成Immutable对象,修改完再转成js对象,这也是笔者工作中不用它的原因。react本身的state当然是支持Immutable对象的。但是redux对immutable.js就不那么友好了。首先,redux的state只支持plain js object。想要让redux直接管理Immutable对象的state必须引入redux-immutable中间件。但是别以为这样就万事大吉了,因为我们还要用redux的connect方法,把需要的state映射到组件的props上。如果state是Immutable对象并且结构还有点小复杂,那么redux又要罢工了,所以还是悠着点用吧。或者像笔者这样仅仅把它用在reducer方法中来修改state,而不在初始化state的时候就是用Immutable对象。否则遇到的坑我这里就不帮你填了。
Isomorphic
伴随着react+redux的流行,前后端同构的概念又着实火了一把。
到目前为止,我们的页面都是完全由前端渲染的,至于后端我们只是用webpack的webpack-dev-server插件给我们起了台啥都不干的nodejs服务器。好好地我们为什么又要折腾服务端渲染了。首先我们看看服务端渲染到前后端同构这一路到底是怎么走来的。
后台包办
服务端渲染的方案早在后台程序前后端包办的时代上就有了,那时候使用JSP、PHP等动态语言将数据与页面模版整合后输出给浏览器,一步到位
这个时候,前端开发跟后端揉为一体,项目小的时候,前后端的开发和调试还真可以称为一步到位。但当项目庞大起来的时候,无论是修改某个样式要起一个庞大服务的尴尬,还是前后端糅合的地带变得越来越难以维护,都很难过。
前后分离
前后端分离后,服务端渲染的模式就开始被淡化了。这时候的服务端渲染比较尴尬,由于前后端的编码语言不同,连页面模板都不能复用,只能让在前后端开发完成后,再将前端代码改为给后端使用的页面模板,增大了工作量。最终也还是跟后台包办殊途同归。
语言变通
Node 驾着祥云腾空而来,谷歌 V8 引擎给力支持,众前端拿着看家本领(JavaScript)开始涉足服务端,于是服务端渲染上又一步进阶
由于前后端使用相同的语言,所以前后端在代码的共用上达到了新的高度,页面模版、node modules 都可以做成前后通用。同构的雏形,只是共用的代码还是有局限。
前后端同构
有了Node 后,前端便有了更多的想象空间。前端框架开始考虑兼容服务端渲染,提供更方便的 API,前后端共用一套代码的方案,让服务端渲染越来越便捷。当然,不只是 React 做了这件事,但 React 将这种思想推向高潮,同构的概念也开始广为人传。
通过前后端同构,提高了开发效率,提升了首页加载速度,同时也让站点对搜索引擎更加友好。
React 的虚拟 Dom 以对象树的形式保存在内存中,并存在前后端两种展露原型的形式
1. 客户端上,虚拟 Dom 通过 ReactDOM 的 Render 方法渲染到页面中
2. 服务端上,React 提供的另外两个方法:ReactDOMServer.renderToString 和 ReactDOMServer.renderToStaticMarkup 可将其渲染为 HTML 字符串。
既然是前后端同构,那我们就不能再用webpack提供的那个简易的后端了,首先我们要对webpack的配置文件动刀了。
我们先把公共的loader放到webpack-base.js中去,然后我们写webpack-dev-server.js这个配置文件用来将前后端代码打包成server.js以便在首次加载页面时,由服务端渲染页面。
var path = require('path'); var webpack = require('webpack'); var config = require('../config'); var loaders = require('./webpack-base');var assetsPath = path.join(config.baseDir, 'dist', 'assets');var configure = { // The configuration for the server-side rendering name: 'server-side rendering', cache: true, context: path.join(config.baseDir, 'client'), entry: { server: './server' }, target: 'node', output: { path: assetsPath, filename: 'server.js', publicPath: '/assets/', libraryTarget: 'commonjs2' }, module: loaders.commonLoaders, resolve: { root: [path.join(config.baseDir, 'client')], extensions: ['', '.js', '.jsx'] }, plugins: [ new webpack.DefinePlugin({ __DEVCLIENT__: false, __DEVSERVER__: true }), new webpack.IgnorePlugin(/vertx/) ]};configure.module.loaders.push(loaders.serverStyle); configure.module.loaders.push({ test: /\.(js|jsx)$/, loader: 'babel-loader', include: [].concat( [path.join(config.baseDir, 'client')] )});module.exports = configure;再编写用于前端渲染的webpack-dev-client.js
var path = require('path'); var webpack = require('webpack'); var config = require('../config'); var loaders = require('./webpack-base');var assetsPath = path.join(config.baseDir, 'dist', 'assets'); var hotMiddlewareScript = 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000&reload=true';var configure = { name: 'client', cache: true, devtool: 'eval-source-map', context: path.join(config.baseDir, 'client'), entry: { app: ['./client', hotMiddlewareScript] }, output: { path: assetsPath, filename: 'app.js', publicPath: '/assets/' }, module: loaders.commonLoaders, resolve: { root: [path.join(config.baseDir, 'client')], extensions: ['', '.js', '.jsx'] }, plugins: [ new webpack.HotModuleReplacementPlugin(), new webpack.NoErrorsPlugin(), new webpack.DefinePlugin({ __DEVCLIENT__: true, __DEVSERVER__: false }) ], postcss: function () { return []; }};configure.module.loaders.push(loaders.clientStyle); configure.module.loaders.push({ test: /\.(js|jsx)$/, loader: 'react-hot!babel-loader', include: [].concat( [path.join(config.baseDir, 'client')] )});module.exports = configure;注意服务端渲染时应该用isomorphic-style-loader代替style-loader。否则会在服务端调用浏览器的document对象,从而渲染失败。
我们再写一个webpack-prod.js作为prod模式运行时的配置文件。
var path = require('path'); var ExtractTextPlugin = require('extract-text-webpack-plugin'); var InlineEnviromentVariablesPlugin = require('inline-environment-variables-webpack-plugin'); var webpack = require('webpack');var config = require('../config'); var loaders = require('./webpack-base');var assetsPath = path.join(config.baseDir, 'dist', 'assets'); var publicPath = '/assets/';var babelLoaders = [ { test: /\.js$|\.jsx$/, loader: 'babel-loader', query: { presets: ['es2015', 'react', 'stage-0'], plugins: [ 'transform-decorators-legacy', 'transform-react-remove-prop-types', 'transform-react-constant-elements', 'transform-react-inline-elements' ] }, include: path.join(config.baseDir, 'client'), exclude: path.join(config.baseDir, 'node_modules') }];var styleLoaders = [ { test: /\.css$/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader') }, { test: /\.sass/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader!sass-loader?outputStyle=expanded&indentedSyntax') }, { test: /\.scss/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader!sass-loader?outputStyle=expanded') }, { test: /\.less/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader!less-loader') }, { test: /\.styl/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader!stylus-loader') }];var commonLoaders = babelLoaders.concat(styleLoaders).concat(loaders.commonLoaders.loaders);var configure = [ { name: 'browser', devtool: 'cheap-module-source-map', context: path.join(config.baseDir, 'client'), entry: { app: './client' }, output: { path: assetsPath, filename: '[name].js', publicPath: publicPath }, module: { loaders: commonLoaders }, resolve: { root: [path.join(config.baseDir, 'client')], extensions: ['', '.js', '.jsx'] }, plugins: [ // extract inline css from modules into separate files new ExtractTextPlugin('styles/main.css', { allChunks: true }), new webpack.optimize.UglifyJsPlugin({ compressor: { warnings: false } }), new webpack.DefinePlugin({ __DEVCLIENT__: false, __DEVSERVER__: false }), new InlineEnviromentVariablesPlugin({ NODE_ENV: 'production' }) ], postcss: function () { return []; } }, { // The configuration for the server-side rendering name: 'server-side rendering', context: path.join(config.baseDir, 'client'), entry: { server: './server' }, target: 'node', output: { path: assetsPath, filename: 'server.js', publicPath: publicPath, libraryTarget: 'commonjs2' }, module: { loaders: commonLoaders }, resolve: { root: [path.join(config.baseDir, 'client')], extensions: ['', '.js', '.jsx'] }, plugins: [ new webpack.optimize.OccurenceOrderPlugin(), new ExtractTextPlugin('styles/main.css', { allChunks: true }), new webpack.optimize.UglifyJsPlugin({ compressor: { warnings: false } }), new webpack.DefinePlugin({ __DEVCLIENT__: false, __DEVSERVER__: false }), new webpack.IgnorePlugin(/vertx/), new InlineEnviromentVariablesPlugin({ NODE_ENV: 'production' }) ], postcss: function () { return []; } }];module.exports = configure;其不同之处主要在于prod模式下将样式表打包到外部文件然后引入到html中,从而减少服务器负担,dev模式下则是把样式表打包到js文件中的。
在dev模式下,我们用nodemon启动服务,使我们的后台代码也可以热更新。一下是nodemon.json的配置
{ "verbose": false, "watch": [ "client/server.js", "client/meta.js", "client/helmconfig.js", "server", "webpack/webpack-dev-client.js", "webpack/webpack-dev-server.js", "webpack/webpack-base.js" ], "exec": "npm run build:dev && npm run babel-node -- server/index.js"}在package.json中添加命令
"build:dev": "NODE_ENV=development webpack --colors --config ./webpack/webpack-dev-server.js"
这样dev模式启动时会首先执行webpack-dev-server.js将代码打包到server.js。然后用nodemon启动服务器。页面请求就会调用server.js执行服务端渲染。
让我们看看后端代码目录:
其中入口代码如下
import express from 'express'; import webpack from 'webpack'; import path from 'path'; import bodyParser from 'body-parser'; import config from '../config'; import webpackDevConfig from '../webpack/webpack-dev-client'; import homepage from '../dist/assets/server'; import {fetchData} from './api';const app = express();if (process.env.NODE_ENV === 'development') { const compiler = webpack(webpackDevConfig); app.use(require('webpack-dev-middleware')(compiler, { noInfo: true, publicPath: webpackDevConfig.output.publicPath })); app.use(require('webpack-hot-middleware')(compiler));}app.use(bodyParser.json({strict: false})); app.use(bodyParser.urlencoded({extended: false}));app.use(express.static(path.join(config.baseDir, 'dist')));//pageapp.get('/', (req, res) => { homepage(req, res);});app.get('/api/fetch', fetchData);//exceptionapp.get('/*', (req, res) => { res.redirect('/');});app.listen(config.port); 注意两点: 1. 当dev模式时,我们加载上文提到的webpack-dev-client.js处理客户端代码。2. 当访问根路径时,调用homepage(req, res)来处理,这个homepage方法来自于上文提到的webpack-dev-server.js生成的server.js文件,从而实现了服务端渲染。试想一下,以前我们是怎么做的,以前我们这里是渲染一个模板给前端,jade也好,ejs也好。都是服务端渲染。这里不同的是我们没有用任何模板而是前后端都用react+redux也即,前后端同构。
让我们看看这个homepage究竟从何而来。代码如下:
import React from 'react'; import {renderToString} from 'react-dom/server'; import {Provider} from 'react-redux'; import configureStore from 'stores/configureStore'; import {setImage} from 'actions/image'; import Stage from 'containers/stage'; import header from './meta';export default function render(req, res) { const store = configureStore(); new Promise(resolve => { return resolve(store.dispatch(setImage())); }).then(() => { const initialState = store.getState(); const componentHTML = renderToString( <Provider store={store}> <Stage /> </Provider> ); return res.status(200).send(` <!doctype html> <html> <head> ${header.title.toString()} ${header.meta.toString()} ${header.link.toString()} </head> <body> <div id="app" class="content">${componentHTML}</div> <script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};</script> <script type="text/javascript" src="/assets/app.js"></script> </body> </html> `); });}如前文所说,我们通过react-dom/server的renderToString方法从后端渲染了一模一样的内容给浏览器。另外我们还把初始化image state的工作放到了后端,通过window对象传给前端。最后我们看看新加的setImage action
export function setImage() { return dispatch => { let url = ''; if(__DEVCLIENT__) { url = '/api/fetch'; } else { url = `http://localhost:${config.port}/api/fetch`; } return request.get(url) .then(response => { const imgsArrangeArr = []; const imageDatas = response.data.map((imageData) => { imageData.imageURL = 'images/' + imageData.fileName; imgsArrangeArr.push({ pos: { left: 0, top: 0 }, rotate: 0, isInverse: false, isCenter: false }); return imageData; }); return { imageDatas: imageDatas, imgsArrangeArr: imgsArrangeArr }; }) .then(images => { dispatch({type: types.INIT_IMAGE, images}); }) .catch(err => { console.log('SSR render error' + err.stack); }); }}我们在webpack的配置文件中已经用DefinePlugin插件定义了DEVCLIENT这个变量了,可以根据它判断是后端渲染还是前端,从而决定调用api的url。
我们看最终页面的dom结构:
这里的data-react-checksum就表示服务端渲染成功
react-router
复杂的网站不可能只有一张页面,这就需要react-router来做路由。同样,我们的图片画廊也不应该仅仅只能展示图片,还应该提供编辑图片的页面。
首先这里需要把图片信息保存在数据库中,并且在后端要修改图片尺寸,这些都是纯nodejs的知识,这里就不细说了。我们再加一个upload组件,并修改前端入口代码:
ReactDOM.render( <Provider store={store}> <Router history={history} onUpdate={onUpdate}> {routes} </Router> </Provider>, document.getElementById('app'));对于routes,代码如下:
export default () => { return ( <Route path="/"> <IndexRoute component={Stage} /> <Route path="upload" component={Upload} /> <Redirect from='*' to='/' /> </Route> );}需要注意的是由于我们采用了服务端渲染,后台的入口也要加上react-router,保证与前端入口一致。
import React from 'react'; import {renderToString} from 'react-dom/server'; import { createMemoryHistory, match, RouterContext } from 'react-router'; import { Provider } from 'react-redux'; import createRoutes from 'routes'; import configureStore from 'stores/configureStore'; import {setImage} from 'actions/image'; import header from './meta';export default function render(req, res) { const history = createMemoryHistory(); const store = configureStore({}, history); const routes = createRoutes(); match({routes, location: req.url}, (err, redirect, props) => { if (err) { res.status(500).json(err); } else if (redirect) { res.redirect(302, redirect.pathname + redirect.search); } else if (props) { new Promise(resolve => { return resolve(store.dispatch(setImage())); }).then(() => { const initialState = store.getState(); const componentHTML = renderToString( <Provider store={store}> <RouterContext {...props} /> </Provider> ); return res.status(200).send(` <!doctype html> <html> <head> ${header.title.toString()} ${header.meta.toString()} ${header.link.toString()} </head> <body> <div id="app" class="content">${componentHTML}</div> <script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};</script> <script type="text/javascript" src="/assets/app.js"></script> </body> </html> `); }).catch((err) => { console.log('Error happened ' + err.stack); res.status(500).json(err); }); } else { res.sendStatus(404); }