react redux isomorphic SSR immutable router test node

JerryXia 发表于 , 阅读 (0)

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结构:page这里的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);    }