分享 同构化的 React + Redux 服务端渲染

nightire · 2016年03月15日 · 最后由 unbug 回复于 2016年04月21日 · 17509 次阅读
本帖已被设为精华帖!

我们的产品有一个面向 C 端且主要运行于移动设备的部分,在我接手之前是 Meteor+Blaze+.Net API 的架构。除了 .Net API 是历史遗留问题(主服务)姑且不谈,Meteor 其实也算满前卫的东西了。然而之前的实现并没有利用到 Meteor 的精髓部分:数据获取主要是通过 API,虽然传递给 Blaze 的过程借用了 Meteor 的同构机制,但由于 Meteor 没有负责数据模型层,所以纯粹的通信仅仅是利用了 WebSocket 而已。但是这个部分的应用对即时通信的需求并不多,在上线运行一段时间后发现真正的瓶颈还在于客户端动态渲染上(有些时候会需要渲染相当大的页面),而且移动端首次访问需要加载一堆未加充分利用的 Meteor 代码……总之效果并没有想象中那么好。

之后我受命重构这个应用,并且上头希望用 React,本来我个人觉得 React 应对客户端动态渲染已经足够好了,但是首次访问总是需要加载 React 的,于是上头的意思是越快越好……咳咳,大家懂得~所以就得试一试 SSR(服务端渲染)。我还是第一次搞这个东西,过程着实艰辛,不过苦水就免吐了,直接进入正题。

SSR 是如何工作的?

要点如下:

  • 你有一套组件——不管你写多少个组件,其相互应用关系最终就是一棵组件树,在这里组件的具体数量并不重要,也不需要关心,所以我就叫它(们)一套组件了;
  • 当用户请求一个 URL,首先由 Node 端响应然后进过一系列步骤(详见后文)最终返回一段 HTML 文本给浏览器渲染;
  • 这段 HTML 和你用相同的组件在客户端渲染出来的结果是一模一样的(必须保证这一点,否则会报错),当然其中也包含了客户端所需要的 JavaScript 文件;
  • 当客户端的 JavaScript 加载完毕会执行和之前相同的渲染过程(区别在于渲染用的函数不同,见后文),然而用户不会察觉到这一点,因为他们早已经看到了静态渲染的页面;
  • 之后的一切过程由客户端接管,不再有 SSR 什么事儿了。

同构在其中如何体现?

我在这里讲的同构其实是狭义的,和真正的 Universal Application 还有不小的差距。不过从概念上来说,同构就是相同的代码可以同时运行于服务端与客户端。只不过我们没有去做 100% 的同构,只有部分而已。

首先,组件是可以同构的。前提是组件不要有独立的私有状态,因为 SSR 很难保证重现这些私有状态。试想,如果某组件定义了一个私有状态,其值是依赖于浏览器环境的(比如:window.navigator.language)那么服务端执行它是会有问题的——当然你可以尝试模拟客户端环境,但是极其困难甚至某些情形是不可能或不可控的。这个大前提就意味着一些东西:

  1. 用于 SSR 的组件树必须能通过服务端获取到初始渲染时需要的一切状态,如果确实有不可避免的依赖客户端的私有状态,你得想办法让它顺利渡过 SSR 的过程并在客户端渲染时重新初始化它——这是可行的。
  2. 为了便于服务端为 SSR 准备初始化的状态,很显然,这里用单一状态树是最合理的,因此 Redux 就成了上上之选。Flux 当然也不是不行,就是得考虑一下如何规划数据传递。实际上用了 Redux 就可以避免一切私有状态所带来的影响。这里并不是说你不能为组件定义私有状态,只是说 SSR 为这些私有状态准备初始化状态会更方便更安全。(比方说 SSR 初始化一个默认值,客户端接手后用实际值替换——如果你没有全局状态树的话,你需要深入到相应的组件层级为其初始化默认值;反之私有状态可以通过检查上层传递下来的数据进行初始化或者使用 Context/Container 等等方式——总之 SSR 不需要深入只需要准备全局初始状态)。

Redux 里的 store 也是可以同构的而且在这里必须同构,因为 SSR 和 CSR(客户端渲染)需要同样的初始状态。SSR 可以直接把初始状态写入在返回的 HTML 文本中,客户端接手之后可以清理它或者干脆 let it be。

既然 store 可以同构,那么相应的 reducers 还有 actions 也是可以同构的。在这个层面上同事们有着不小的困惑,在经过一系列讨论之后我意识到根本的原因在于大家对于 Redux 的理解上,大家表达的共同疑惑在于:业务逻辑的处理怎么可能前后通用

我不打算在此时详细解释,就先说明一个关键点:Redux 负责的是数据结构的表征而不是具体的业务逻辑。通常我们会认为数据结构的表征就是业务逻辑的一部分,那是因为常规的处理模式的确是把业务逻辑的处理与数据结构的变迁混合在一起的。

如果你把应用程序的数据库看作是一个单一状态树(实际上是完全可行的,只需要结构上的转换,但实际中我们并不需要整个数据库作为应用程序的状态树,因为状态是可以变迁的,状态树只要满足当前状态下 UI 的需要即可,而且现实中的状态树还可能会包含数据库里没有的数据,比如说不需要持久化的 UI 特定的状态),那么某个业务逻辑的执行无非就是要更新这颗树,只不过这颗树有其特定的形态,而我们的 raw data 往往不能满足这种形态,因此才需要代码逻辑来进行必要的前置处理。这部分的代码逻辑是前后无法通用的,但是之后的数据形态前后却是一致的。

Redux 所体现的就是纯粹的数据形态及其变更(这是前后可以一致的地方),至于你怎么变更(这是前后无法一致的地方)并不是 Redux 关心的事情,只是由于我们习惯了无论前后都把这两件事情一起做,所以才会觉得“怎么可能?”。

之后我可能会单独就此写一些东西,但本篇不涉及具体的业务逻辑层面,手头也没有现成的代码示例,所以先就此打住——你只需要知道这是完全可能的,也就是你写一份 reducers + actions 就可以同时应用于前后两端。

另外,路由也是可以同构并且最好同构,能做,何乐而不为呢,对吧?

Show me the code

OK,下面说一说重点的代码部分。前提是你有一套组件了,我不关心它们具体如何,只假设入口是 <Home/>

HTTP Server

既然是 SSR 那就没有什么分离一说了,只是我们想用 webpack,所以开发环境下是 Node HTTP Server + WebpackDevMiddleware 的组合,部署时候去掉 webpack 的部分即可:

// server/server.js

import express, {Router} from 'express';
import webpack from 'webpack';
import config from '../webpack.config.babel.js';
import bootup from './bootup';
import React from 'react';
import Main from '../common/components/Main';

const app = express();
const ssr = Router();

if (process.env.NODE_ENV !== 'production') {
  const compiler = webpack(config);
  app.use(require('webpack-dev-middleware')(compiler, {publicPath: config.output.publicPath}));
}

ssr.get('*', (req, res, next) => {
  bootup(<Main/>, (err, page) => {
    if (err) return next(err);
    res.status(200).end(page);
  });
});

app.use('/', ssr);

const server = app.listen(process.env.PORT || 1337, () => {
  const {port} = server.address();
  console.info(`Listening on -> http://localhost:${port}`);
});

上面的代码基本都是大路货,只是我们匹配了所有的请求,统统转入 bootup 做最后的渲染处理。此时你看到的 bootup 只是最初级形态,随着架构的升级它也会逐渐跟着适应,由于此处是 SSR 的核心逻辑,所以我们从最初的形态开始逐步理解。

Basic SSR

// server/bootup.js

import React from 'react';
import {renderToString} from 'react-dom/server';

const generatePage = (content, options = {
  title: 'SSR Demo',
}) => `
<!DOCTYPE html>
<html>
  <head>
    <title>${options.title}</title>
  </head>
  <body>
    <div id="root">${content}</div>
    <script src="/assets/client.js"></script>
    <script src="/assets/vendor.js"></script>
  </body>
</html>
`;

export default (components, callback) => {
  const content = renderToString(components);
  callback(null, generatePage(content));
};

renderToString 把组件渲染为字符串(HTML),等价于编译模版,然后我们用 generatePage 函数把它插入到 div#root 里。这个过程和以下的客户端代码干的是类似的事情:

// client/index.js

import React from 'react';
import {render} from 'react-dom';
import Main from '../common/components/Main';

render(<Main/>, document.getElementById('root'));

实际上 client/index.js 就是 <script src="/assets/client.js"></script> 内容,webpack 用在了这里来生成最终的客户端脚本文件。所以一次完整的 SSR 会有两次组件的渲染过程,先是服务端 --> 生成静态 HTML --> 浏览器快速呈现 --> 加载客户端脚本 --> 执行再次渲染一遍——但其实第二次渲染几乎等于没做,因为 SSR 的时候生成的标签里已经有了 data-react-checksum,由于同构的关系,CSR 渲染后的 data-react-checksum 是一模一样的(如果不一样会报错的,时刻留意开发者工具控制台),因此不会产生任何 DOM 的修改(除了一件事:CSR 会追加必要的事件处理函数)。我观测了 Recalculate Style 事件,客户端渲染花费的时间恒定在 0ms,完美~

即使是如此简单的一个 Demo,你已经可以获得极速的渲染体验并可以无缝过渡到客户端应用程序,plus,毫无障碍的 SEO 适应性。唯一的瓶颈可能只剩下客户端的网速了,我用 Chrome 模拟了 3G/4G 的网络加载速度,有 SSR 的时候几乎可以忽略不计(当然也是因为渲染出来的 HTML 并不大,但就算大也大不到哪儿去的),没有 SSR 的时候就得取决于客户端最终的脚本文件有多大了。

这样看来 React 的服务端渲染似乎也蛮简单的,不过一个 "Hello World" 的 Demo 顶个屁用,接下来我们要看看整合了一切需要用上的技术之后会是何种情形吧。

Give me some color to see see?

样式方面,SSR 是不能用外链样式表文件的,因为会有短暂的“裸屏”。所以你需要生成样式文本,就和 HTML 文本一样(哪怕是指满足首屏需要也可以)一起填充到 SSR 的返回内容中。原理是很简单的,具体做法就多了去了。比如说你用了 Sass/Less 之类的,那就要在服务端先编译了然后插入进去;又或者你可以用 CSS Module,配合 classnames 直接在组件里面编写(于是我也释怀了在 JavaScript 里编写样式这件事情)。代码范例我就省略了,不过留意前面的 generatePage 函数,它的第二个参数就是用来传递所有需要 SSR 填充的东西的,你当然也可以用模版引擎等东西简化这个函数的实现,不过我觉得 ES2015 的 String Template 已经足够好用了。

React Hot Module Replacement (optional)

热代码替换也是现在很火热的话题,但是牵涉到 React 以后,这里面水就深了……在我动手加上它之前,我特意边跑边读了好几个 Google 到的 demo projects,各式各样的写法五花八门,真是越看越糊涂。

后来看到了“始作俑者” Dan Abramov 最近的一篇文章才闹明白:原来之前几乎都是瞎折腾(指的是他最早开始的实践 react-hot-loader 以及后续基于此的各种实验性项目)。当然,这个项目并非没有价值,只不过它的价值是发现“此路不灵”,需要另觅他途。我对他未来打算使用的基于 ES2015 Proxy 的方案非常感兴趣,但是目前就不要趟这浑水了,老老实实的用更通用的 webpackHotMiddleware 吧。

增加模块热替换的方法如下(原理就不讲了,webpack 官网有,Google 也能找到不少资料):

  1. 添加中间件

    // server/server.js
    
    // ...
    if (process.env.NODE_ENV !== 'production') {
      const compiler = webpack(config);
      app.use(require('webpack-dev-middleware')(compiler, {publicPath: config.output.publicPath}));
      app.use(require('webpack-hot-middleware')(compiler);
    }
    // ...
    
  2. 配置 webpack。要注意,如果你有多个 entries,每一个都需要追加一样的入口,如下所示:

    // webpack.config.babel.js
    
    import webpack from 'webpack';
    
    export default {
      entry: {
        client: [
          './client/index.jsx',
          'webpack-hot-middleware/client'
        ],
        vendor: [
          'react',
          'react-dom',
          'webpack-hot-middleware/client',
        ],
      },
      plugins: [
        new webpack.optimize.CommonsChunkPlugin('vendor', 'vendor.js'),
        new webpack.optimize.OccurrenceOrderPlugin(),
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NoErrorsPlugin(),
      ]
    }
    

    我没把配置写全,以上只有必须要有和推荐要有的部分,其他的部分按自己的要求随便写就是了。

  3. 在需要的地方描述如何进行热替换,之前的 Demo 需要这样:

    // client/index.js
    
    import React from 'react';
    import ReactDOM from 'react-dom';
    
    const render = () => {
      const Main = require('../common/components/Main').default;
      ReactDOM.render(<Main/>, document.getElementById('root'));
    };
    
    if (module.hot) {
      module.hot.accept('../common/components/Main', () => {
        setTimeout(render);
      });
    }
    
    render();
    
  • <Main/> 组件没再用 ES2015 模块是因为我们需要每次加载“新鲜”的模块,Babel Loader 把 import 转换成了 require 于是你就没机会了……这个问题将在 webpack v2 得到解决,届时我们禁用 Babel 的模块转换,webpack 会对所有的 import 做即时绑定处理;另外一个原因是 ES2015 模块必须得在文件顶端声明,将来有了支持异步的 System Loader,那么这部分则可以变为:

    const render = () => {
      System.import('../common/components/Main').then(Main => {
        ReactDOM.render(<Main/>, rootElement);
      });
    }
    
  • module 对象是 webpackHotMiddleware 自动暴露出来的,由于我们只在开发环境才用所以无需在这里做环境判断。将来要想在生产环境也用那就连改都不要改了

  • setTimeout(render) 是为了避免 render 抛出异常会打断热替换的正常工作,为了得到更好的反馈,我们可以用 redbox-react 一类的工具重新封装一下 render 方法

    // client/index.js
    
    import React from 'react';
    import ReactDOM from 'react-dom';
    
    const rootElement = document.getElementById('root');
    let render = () => {
      const Main = require('../common/components/Main').default;
      ReactDOM.render(<Main/>, rootElement);
    };
    
    if (module.hot) {
      const renderNormally = render;
    
      const renderException = (error) => {
        const RedBox = require('redbox-react').default;
        ReactDOM.render(<RedBox error={error}/>, rootElement);
      };
    
      render = () => {
        try {
          renderNormally();
        } catch (error) {
          renderException(error);
        }
      };
    
      module.hot.accept('../common/components/Main', () => {
        render;
      });
    }
    
    render();
    

    我们做了一些细节调整去捕获每次渲染可能会抛出的异常,然后用 redbox-react 来对错误进行更加友好的反馈(看起来和 react native 类似的红色示警),这样应该不会影响热替换的正常工作——我直觉是这样,需要时间验证——所以我们可以去掉 setTimeout 了。

热替换就是这样子,等到 Redux 加入之后,需要单独为 reducers 做一下热替换;而 react-router 加入之后,则有一个小坑要填,我们后面再回到这个话题。

Bring the Redux in...

如同之前谈过的,SSR 需要为应用提供最初始的数据形态,也就是最开始的全局状态树;我们也谈到过,在后端你要如何生成组装这个状态树完全是你的自由(这是业务逻辑的部分),SSR 唯一关心的就是当我们有了 initial state 之后,如何写到 response HTML 里去;我们还谈到过,Redux 里的 store 是可以且必须要同构的,所以我们需要有一个同构的创建 store 的逻辑。

不过我打算倒过来描述这个过程,先假设我们已经可以生成前后统一的 store 了,那么 SSR 和 CSR 分别是什么样子的呢?

以下是一个典型的应用了 Redux 的 CSR:

// client/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import {Provider} from 'react-redux';
import createStore from '../common/store';

const store = createStore(window.__INITIAL_STATE__);
const rootElement = document.getElementById('root');
let render = () => {
  const Main = require('../common/components/Main').default;
  ReactDOM.render(
    <Provider store={store}>
      <Main/>
    </Provider>,
    rootElement
  );
};

// ...

这里的 createStore 是我们自己的定制版同构化的方法,不同于 Redux.createStore,它只需要接收 initial state 一个参数就够了;细节我们稍后再讲。

对应到 SSR,让我犹豫了许久的事情是创建 store 的过程应该放在 bootup 内部还是外部?一开始我没有把同构这件事情想透彻,天真的认为:“当多个路由的场景介入的时候,可能需要针对 store 做不同的处理,因此放在 bootup 外面会比较灵活”,而且我看到的 demo 里面有一些也是这么做的。但实践到后面我才意识到:“store 的结构应该是恒定的(一旦设计完成),对应不同的路由场景,真正可能变化的只是 initial state 而已——否则 store 就不好同构了“。

因此 bootup 需要做以下调整:

// server/bootup.js

import React from 'react';
import {renderToString} from 'react-dom/server';
import {Provider} from 'react-redux';
import createStore from '../common/store';

const generatePage = (content, state, options = {
  title: 'SSR Demo',
}) => `
<!DOCTYPE html>
<html>
  <head>
    <title>${options.title}</title>
  </head>
  <body>
    <div id="root">${content}</div>
    <script>window.__INITIAL_STATE__ = ${JSON.stringify(state)};</script>
    <script src="/assets/client.js"></script>
    <script src="/assets/vendor.js"></script>
  </body>
</html>
`;

export default (components, initialState, callback) => {
  const store = createStore(initialState);
  const content = renderToString(
    <Provider store={store}>{components}</Provider>
  );
  const state = store.getState();
  callback(null, generatePage(content, state));
};

还有服务端匹配路由的部分:

// server/index.js

// ...

ssr.get('*', (req, res, next) => {
  const initialState = {};

  bootup(initialState, <Main/>, (err, page) => {
    if (err) return next(err);
    res.status(200).end(page);
  });
});

// ...

很显然,在服务端的 initialState 就是在创建 store 前你需要使用你的业务逻辑去准备好的数据结构,如果在服务端你也完全使用了 Redux(而不仅仅是为了 SSR 准备一个 store 而已),那么这个 initialState 的准备会简单很多,否则视应用程序的复杂度它的准备工作也会同比增加——这和我们在客户端用还是不用 Redux 的感受是一模一样的。

而在 bootup 处理过后,store.getState() 帮我们吐出最终的 state,SSR 需要把它传递给 CSR 来接手。在上面的代码中我直接写进了全局变量 window.__INITIAL_STATE__,当然你可以用任何办法,只要别影响 CSR 的效率就好。CSR 接手后重新 createStore,但这一次用的是 SSR 吐出来的最终形态的 state,所以得到的 store 自然也是一样一样的。

所以拼图剩下的部分就是我们自己的 createStore 了:

// common/store/index.js

import {applyMiddleware, createStore as _createStore} from 'redux';
import middlewares from '../middlewares';
import reducers from '../reducers';

export default (initialState = {}) => {
  const createStore = applyMiddleware(...middlewares)(_createStore);
  const store = createStore(reducers, initialState);

  module.hot && module.hot.accept('../reducers', () => {
    store.replaceReducer(require('../reducers').default);
  });

  return store;
};

reducer 还有 middleware 都是 Redux 相关的东西,没有特殊性。另外我们针对 reducers 实施了模块热替换,这是 Redux 唯一需要指定热替换的地方。在 client/index.js 创建的 store 和这里的 store 是同一个对象,当这里 store.replaceReducer() 之后会让应用程序得到更新的 store。

...as well the React Router

当路由牵涉到同构化和 SSR 的时候,解决方案就不是那么显而易见了。主要是因为客户端路由需要依赖 history / location 等浏览器专属的 API,因此你需要谨慎考虑很多细节。

比方说,由于组件是有生命周期的,你不能指望 componentDidMount 内部的代码会在 SSR 时得到执行(请温习 React Component LifeCycle 的文档,里面有说明哪些生命周期不会在 SSR 时执行),所以如果你有在 componentDidMount 内部异步更改数据的习惯,这会造成 SSR 与 CSR 无法同步的问题。这一点让我开始纠结了好久,不知如何处理更加妥当。在没有 Redux 的时候,我们可以把这种逻辑抽象以下,利用回调机制让它在服务/客户两端分别执行不同的逻辑(可参考这篇文章,里面的一些细节分析对本文有更深入的补充),这在客户端编程相对比较容易一些,但在 SSR 的时候,如果这些组件不在顶层,或者这些回调不是从顶层逐层向下传递的,你就不得不想办法深入进入单独渲染这些组件然后再回填到整棵组件树里——太折磨人了!

有了 Redux 会简单很多(配合一些第三方的工具库,会增加理解成本,但非常舒服),特别是考虑到同构化之后,我们 mapDispatchToProps 以及 bindActionCreators 的动作都是放在顶层处理的,也就意味着我们有机会使用同一套 actions,但在服务/客户两端 dispatch 时执行不同的逻辑。我还没有做足够多的实验,而且这是 more Redux specific 的话题,以后有机会再结合实例讲解吧。总而言之,在施行了 SSR 之后,你在写组件的时候会不由自主提前考虑这个组件在 SSR 时能否正常渲染,我可以预期到这对我们整个团队来讲都是一个考验,未来的一段时间内一定会有很多坑等着我去填的……

好,让我们先简单点:假设我们手中的一套组件都是可以顺利 SSR 的,那么路由怎么做?我们还是用分析 Redux 的顺序来一遍好了。

先是 CSR 的部分:

// client/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import {Provider} from 'react-redux';
import {syncHistoryWithStore} from 'react-router-redux';
import {Router, Route, browserHistory} from 'react-router';
import createStore from '../common/store';

const store = createStore(window.__INITIAL_STATE__);
const history = syncHistoryWithStore(browserHistory, store);
const rootElement = document.getElementById('root');

let render = () => {
  const Main = require('../common/components/Main').default;
  ReactDOM.render(
    <Provider store={store}>
      <Router history={history}>
        <Route path="/" component={Main}/>
      </Router>
    </Provider>,
    rootElement
  );
};

// ...

这里我用了 react-router-redux 目的是为了得到一个“增强版”的 history 提供给 react-router,用以增强 router 与 redux store 的同步性。以上只完成了一半,另外一半需要扩展一下 reducers 以获得更佳的整合性:

// common/reducers/index.js

import {combineReducers} from 'redux';
import {routerReducer as routing} from 'react-router-redux';

export default combineReducers({
  ...otherReducers,
  routing,
});

不管你自己的 reducers 是什么样的,追加一个 key 为 routing 的 reducer,它是 react-router-redux 提供的。

至此我们还不能直接动手去实现等价的 SSR 端,还记得最开始我们在服务端匹配了所有的路由吗?我想我们可以使用服务端路由(虽然我没这么做):在每一个路由处理中 1)准备 initialState;2)调用 bootup 渲染。但在现实场景中,我们通常都会把客户端的路由定义抽取成单独的模块,由于 react-router 提供了 SSR 的实现,所以我们可以同构使用路由定义。

SSR 在这部分的处理有别于 CSR 的部分在于:

  1. 对于错误需要响应 500
  2. 对于跳转需要响应 30x
  3. 对于资源定位错误需要响应 404
  4. maybe more...
  5. 最后,在渲染前获取必要的初始数据,特别是路由相关的。

为了实现这些需求,我们要利用 react-router 提供的几个机制:

  1. match 函数:这个方法用于 SSR,传入路由定义和一个 location,它会对齐进行匹配。匹配到了给你一个签名为 function(error, redirectLocation, renderProps) 的 callback,由你来定义其中的逻辑。errorredirectLocation 可用于错误和跳转的处理,而 match 本身并不会渲染路由定义的组件,它只会给你准备就绪的 renderProps,于是接下来我们可以——

  2. <RouterContext/> 组件:当你拿到了 renderProps 之后,可以利用 <RouterContext/> 渲染所有的组件,它比客户端使用的 Router 更底层一些,在 CSR 阶段,Router 就是调用它的,而在 SSR 阶段则需要我们来主动调用之。

此外,我还使用了一个库叫做 history,用于在服务端创建可用的 history 及 location,这些是 match 函数的前置依赖。

// server/server.js

import createHistory from 'history/lib/createMemoryHistory';
import createRoutes from '../common/routes';
import {match} from 'react-router';

ssr.get('*', (req, res, next) => {
  const history = createHistory();
  const routes = createRoutes(history);
  const location = history.createLocation(req.url);

  match({routes, location}, (error, redirectLocation, renderProps) => {
    if (error) {
      res.status(500).send(error.message);
    }

    if (redirectLocation) {
      res.redirect(302, redirectLocation.pathname + redirectLocation.search);
    }

    if (renderProps) {
      let initialState;

      switch (renderProps.location.pathname) {
        case '/':
          initialState = {mainData: {...}};
          break;
        default:
          initialState = {mainData: {}};
          break;
      }

      bootup(initialState, renderProps, (err, page) => {
        if (err) return next(err);
        res.status(200).end(page);
      });
    } else {
      res.status(404).send('Not Found');
    }
  });
});

上述代码是所有变化的部分。

在 SSR 这边,没有必要去创建“增强版”的 history;另外我们需要匹配当前 req.urllocationcreateRoutes 见后文。

switch 那端代码演示了我们可以获得判定不同路由的机会,这里的主要目的就是为不同路由准备 initialState 了(在写本文的时候我已经意识到这里应该叫做 initialData,在 bootup 里经过 store.getState() 取出的才应该叫 initialState,这是命名上的瑕疵)。在这里丢一段 switch 固然不好看,不过我手头要做的事情比较简单,还没有到需要再封装一层的必要,各位可根据情况自便。我想我应该对 Redux 还不太了解的看官们稍微解释一下。

假设在进入某一路由时,UI 需要这样的一个状态树:

{
  mainData: {...},
  routing: {...},
}

routing 在 SSR 阶段是不需要的,你可以忽略它(但你不能提供一个和 CSR 阶段不一样的 routing),到了 CSR 接手之后会有 routerReducer 补充它(见前文);

mainData 是 SSR 阶段需要提供的,因为 UI 的首次渲染需要它,你必须确保 CSR 接收后保持 mainData 不变——然而现实中你有可能需要变(在没有用户交互的情况下自动做一些客户端特定的事情来改变 mainData),那么你一定要把这类变化用 Redux 的方式来执行(类似于上面的 routerReducer),这样 store 才会认为这是正确的状态迁移,而不是 SSR 与 CSR 不同步。

以上理论目前我不确定是否完全正确或是没有漏洞,因为我也还在逐步深入体会中,所以不必尽信,权当抛砖引玉就好。

Now, 让我们看看 createRoutes 吧:

// common/routes/index.js

import React from 'react';
import {Router, Route} from 'react-router';

import Main from '../components/Main';

export default history => (
  <Router history={history}>
    <Route path="/" component={Main}/>
  </Router>
);

相应的,以下变更是必要的:

// client/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import {Provider} from 'react-redux';
import {syncHistoryWithStore} from 'react-router-redux';
import {Router, Route, browserHistory} from 'react-router';
import createStore from '../common/store';

const store = createStore(window.__INITIAL_STATE__);
const history = syncHistoryWithStore(browserHistory, store);
const rootElement = document.getElementById('root');

let render = () => {
  const createRoutes = require('../common/routes').default;
  ReactDOM.unmountComponentAtNode(rootElement);
  ReactDOM.render(
    <Provider store={store}>
      {createRoutes(history)}
    </Provider>,
    rootElement
  );
};

// ...
// server/bootup.js

import React from 'react';
import {renderToString} from 'react-dom/server';
import {Provider} from 'react-redux';
import {RouterContext} from 'react-router';
import createStore from '../common/store';

const generatePage = (content, state, options = {
  title: 'SSR Demo',
}) => `
<!DOCTYPE html>
<html>
  <head>
    <title>${options.title}</title>
  </head>
  <body>
    <div id="root">${content}</div>
    <script>window.__INITIAL_STATE__ = ${JSON.stringify(state)};</script>
    <script src="/assets/client.js"></script>
    <script src="/assets/vendor.js"></script>
  </body>
</html>
`;

export default (renderProps, initialState, callback) => {
  const store = createStore(initialState);
  const content = renderToString(
    <Provider store={store}>
      <RouterContext {...renderProps}/>
    </Provider>
  );
  const state = store.getState();
  callback(null, generatePage(content, state));
};

That's it~

当你定义了完整的 routes 之后,尽管 SSR 这边匹配的是 *,但 match 只会匹配到 routes 里定义的路由,其他的会转到 404(或者你追加 middleware 来处理)。再者,你可以在匹配 * 之前定义完全和 react-router 无关的路由,这样就可以和 React App 混搭合作了。而 Redux 是完全可以前后通用的,尽管我现在还对一些细节存有疑惑,但并非“能不能”的疑惑,而是“好不好”或“怎样好”的疑惑。

希望在这个架构里正式开发一段时间之后能回来跟大家分享更多的东西,现在我要去整理一下这个 demo 了,尽快给大家提供一个完整的 repo 来测试。

Oh, one more thing

如果想要连接 Redux DevTools 并且保持同构化的 store,你需要判定服务端/客户端,并且如果是后者还要检查有没有这个扩展然后重写 createStore 方法。这是可选的,等 repo 准备好了之后参见 common/store/index.js 文件。

可以运行的演示项目

https://github.com/nightire/isomorphic

这只是一个演示用的 repo,只需要 npm install & npm start 就可以运行。比起网络上能够搜到的类似项目,完成度方面只能算中上,但是代码逻辑和结构会更简单和清晰。在此基础之上我还完成了一些东西(不在此 repo 中):

  1. 常见应用场景里的 css modules/actions/reducers/async middleware 的支持和实例
  2. 服务端重启时的缓存处理(性能优化)
  3. 测试/I18n/API 文档
  4. 部署阶段的配置(webpack/node server)及运行脚本
  5. docker 部署/分发支持等

这些方面的代码目前还处在私有状态,因为会随着对业务的改造不断改善所以还处于不稳定状态。我想等这个项目基本完成之后再做一次分享,除了上述几点之外也许还可以多说些对于 Redux 的感想。

Thank you all & Happy Hackin'

共收到 15 条回复

楼主也React了,我大Ember.js还有希望么。。

#1楼 @ericguo 哪里,这是应用场景需要才搞的,React 又不难。在我的眼里 React 和 Ember 是一样的,无非就是 Ember 不需要你选择,React 则可以随便选择。实际中想把 React 整合到和大 Ember 一样的程度还是很费力气的,当然整好了你会很有成就感,但这是架构师的活儿,只为开发应用还是咱大 Ember 来得省心……前提是用好了。

#2楼 @nightire 想不到您这浓眉大眼的楼主也背叛 Ember.js 了……

赞一个!这个得慢慢看,里面我不懂的太多了…… 早上看了一下 Redux 的文档,思路和行文都非常好,很久没有文档看的这么带劲了。

#3楼 @ugoa 哈哈,我朱时茂么?说正经的,我真没有背叛 Ember 呀,它依然是我心目中最爱 JavaScript 框架~但是同构+SSR 的确是很有好处的事情,即便是 Ember 也是可以做的,马上 EmberConf 开始了,这里面会有一个分布式 Ember 应用程序架构的 talk,我猜想里面会有一些非常有趣的事情。

Mark一下慢慢看,nightire的,即使没看都要无理由赞!

可以用 babel-plugin-add-module-exports 这个插件 然后 require('../common/routes').default 后面default 就不用写了.

写的好

#7楼 @rubyless Thanks, nice tip.

#3楼 @ugoa react + redux现在是最流行的前端框架吗?

现在的框架很多

12楼 已删除

刚上手redux,之前写了一个多月reflux,真想吐槽,去你妹的!

之前angular刚感觉熟练了,结果被迫丢掉(算一门技能)。开始写react,真是几乎又新学了一门技能(对于年龄大的人而言,真是要命),由于业务原因,原先的项目是reflux做的,我接手以后(也是我第一次用react),辛辛苦苦花了几天强记reflux的api文档,然后写了个把月,算是有些理解了,妈逼的又要叫我改写redux,然后苦逼的redux(真的想说给傻逼用的),又是看文档,搜教程(对于年纪大的我而言真是心脏受不了,都想改行了)。学redux一来就是一个下马威,全部es6(先不说一堆需要polyfill的特性,他妈的搞得我好郁闷啊,这个方法到底用不用啊?!!!),然后一堆(action,actionTypes,reducers(如何combineReducer或不combine, normalizr数据,到现在我对于设计全局store的state还是一头雾水),然后react又是class写法,又是staeless写法(完全搞不明白为什么又是这样定义一个react component,怎么又是那样定义一个component),然后妈逼的蛋疼的router处理,先来个react-router,结果处理跳转各种其他需求,又来了个recat-router-redux(妈逼的)。花了好多时间终于能把counter这个小小的简单应用写出来了。 然后又是async处理,redux middleware,还有store 映射到react component(所谓的map(State||Dispatch)ToProps),怪哉,他妈的怎么有些情况在dispatch里面要访问stateProps,还要多写个mergeProps, 最后把state和dispatch handlers绑定到component。你妈的,想到这里就想骂人了。 跟什么P的风呀,怀疑angular的性能,臃肿(其实一般业务哪里有那么高的需求),不行就jquery吧,再不行,尝试vanilla javascript吧,反正现在的浏览器对于兼容性都基本趋向一致了,至于模块管理,什么browserify, webpack(code-splitting, chunk)全都是负担,我觉得requireJS就很靠谱了,毕竟咱们现在还是小前端。踏踏实实能把代码写的漂漂亮亮,不要每次动不动就推到重来就行(基本上公司换人都不能接手别人的项目,还有蛋疼的coffeescript)。

服务端渲染对SEO是一个节省开发资源的方案

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册