React Redux Sever Rendering(Isomorphic JavaScript)入门

React Redux Sever Rendering(Isomorphic)入门

前言

由于可能有些读者没听过 Isomorphic JavaScript 。因此在进到开发 React Redux Sever Rendering 应用程式的主题之前我们先来聊聊 Isomorphic JavaScript 这个议题。

根据 Isomorphic JavaScript 这个网站的说明:

Isomorphic JavaScript
Isomorphic JavaScript apps are JavaScript applications that can run both client-side and server-side.
The backend and frontend share the same code.

Isomorphic JavaScript 系指浏览器端和伺服器端共用 JavaScript 的程式码。

另外,除了 Isomorphic JavaScript 外,读者或许也有听过 Universal JavaScript 这个用词。那什么是 Universal JavaScript 呢?它和 Isomorphic JavaScript 是指一样的意思吗?针对这个议题网路上有些开发者提出了自己的观点: @mjackson/universal-javascript-4761051b7ae9#.67xsay73m">Universal JavaScript、@ghengeveld/isomorphism-vs-universal-javascript-4b47fb481beb#.qvggcp3v8">Isomorphism vs Universal JavaScript。其中 Isomorphism vs Universal JavaScript 这篇文章的作者 Gert Hengeveld 指出 Isomorphic JavaScript 主要是指前后端共用 JavaScript 的开发方式,而 Universal JavaScript 是指 JavaScript 程式码可以在不同环境下运行,这当然包含浏览器端和伺服器端,甚至其他环境。也就是说 Universal JavaScript 在意义上可以涵盖的比 Isomorphic JavaScript 更广泛一些,然而在 Github 或是许多技术讨论上通常会把两者视为同一件事情,这部份也请读者留意。

Isomorphic JavaScript 的好处

在开始真正撰写 Isomorphic JavaScript 前我们在进一步探讨使用 Isomorphic JavaScript 有哪些好处?在谈好处之前,我们先看看最早 Web 开发是如何处理页面渲染和 state 管理,还有遇到哪些挑战。

最早的时候我们谈论 Web 很单纯,都是由 Server 端进行模版的处理,你可以想成 template 是一个函数,我们传送资料进去,template 最后产生一张 HTML 给浏览器显示。例如:Node 使用的(EJSJade)、Python/Django 的 Template 或替代方案 Jinja、PHP 的 SmartyLaravel 使用的 Blade,甚至是 Ruby on Rails 用的 ERB。都是由后端去 render 所有资料和页面,前端处理相对单纯。

然而随着前端工程的软件工程化和使用者体验的要求,开始出现各式前端框架的百花齐放,例如:Backbone.jsEmber.jsAngular.js 等前端 MVC (Model-View-Controller) 或 MVVM (Model-View-ViewModel) 框架,将页面于前端渲染的不刷页单页式应用程式(Single Page App)也因此开始流行。

后端除了提供初始的 HTML 外,还提供 API Server 让前端框架可以取得资料用于前端 template。复杂的逻辑由 ViewModel/Presenter 来处理,前端 template 只处理简单的是否显示或是元素迭代的状况,如下图所示:

React Redux Sever Rendering(Isomorphic)入门

然而前端渲染 template 虽然有它的好处但也遇到一些问题包括效能、SEO 等议题。此时我们就开始思考 Isomorphic JavaScript 的可能性:为什么我们不能前后端都使用 JavaScript 甚至是 React?

React Redux Sever Rendering(Isomorphic)入门

事实上,React 的优势就在于它可以很优雅地实现 Server Side Rendering 达到 Isomorphic JavaScript 的效果。在 react-dom/server 中有两个方法 renderToStringrenderToStaticMarkup 可以在 server 端渲染你的 components。其主要都是将 React Component 在 Server 端转成 DOM String,也可以将 props 往下传,然而事件处理会失效,要到 client-side 的 React 接收到后才会把它加上去(但要注意 server-side 和 client-side 的 checksum 要一致不然会出现错误),这样一来可以提高渲染速度和 SEO 效果。renderToStringrenderToStaticMarkup 最大的差异在于 renderToStaticMarkup 会少加一些 React 内部使用的 DOM 属性,例如:data-react-id,因此可以节省一些资源。

使用 renderToString 进行 Server 端渲染:

  1. import ReactDOMServer from 'react-dom/server';
  2. ReactDOMServer.renderToString(<HelloButton name="Mark" />);

渲染出来的效果:

  1. <button data-reactid=".7" data-react-checksum="762752829">
  2. Hello, Mark
  3. </button>

总的来说使用 Isomorphic JavaScript 会有以下的好处:

  1. 有助于 SEO
  2. Rendering 速度较快,效能较佳
  3. 放弃蹩脚的 Template 语法拥抱 Component 组件化思考,便于维护
  4. 尽量前后端共用程式码节省开发时间

不过要注意的是如果有使用 Redux 在 Server Side Rendering 中,其流程相对复杂,不过大致流程如下:
由后端预先载入需要的 initialState,由于 Server 渲染必须全部都转成 string,所以先将 state 先 dehydration(脱水),等到 client 端再 rehydration(覆水),重建 store 往下传到前端的 React Component。

而要把资料从伺服器端传递到客户端,我们需要:

  1. 把取得初始 state 当做参数并对每个请求建立一个全新的 Redux store 实体
  2. 选择性地 dispatch 一些 action
  3. 把 state 从 store 取出来
  4. 把 state 一起传到客户端

接下来我们就开始动手实作一个简单的 React Server Side Rendering Counter 应用程式。

专案成果截图

React Redux Sever Rendering(Isomorphic)入门

环境安装与设定

  1. 安装 Node 和 NPM

  2. 安装所需套件

    1. $ npm install --save react react-dom redux react-redux react-router immutable redux-immutable redux-actions redux-thunk babel-polyfill babel-register body-parser express morgan qs
    1. $ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react babel-preset-stage-1 eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react html-webpack-plugin webpack webpack-dev-server redux-logger

接下来我们先设定一下开发文档。

  1. 设定 Babel 的设定档: .babelrc

    1. {
    2. "presets": [
    3. "es2015",
    4. "react",
    5. ],
    6. "plugins": []
    7. }
  2. 设定 ESLint 的设定档和规则: .eslintrc

    1. {
    2. "extends": "airbnb",
    3. "rules": {
    4. "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
    5. },
    6. "env" :{
    7. "browser": true,
    8. }
    9. }
  3. 设定 Webpack 设定档: webpack.config.js

    1. // 让你可以动态插入 bundle 好的 .js 档到 .index.html
    2. const HtmlWebpackPlugin = require('html-webpack-plugin');
    3. const HTMLWebpackPluginConfig = new HtmlWebpackPlugin({
    4. template: `${__dirname}/src/index.html`,
    5. filename: 'index.html',
    6. inject: 'body',
    7. });
    8. // entry 为进入点,output 为进行完 eslint、babel loader 转译后的档案位置
    9. module.exports = {
    10. entry: [
    11. './src/index.js',
    12. ],
    13. output: {
    14. path: `${__dirname}/dist`,
    15. filename: 'index_bundle.js',
    16. },
    17. module: {
    18. preLoaders: [
    19. {
    20. test: /\.jsx$|\.js$/,
    21. loader: 'eslint-loader',
    22. include: `${__dirname}/src`,
    23. exclude: /bundle\.js$/
    24. }
    25. ],
    26. loaders: [{
    27. test: /\.js$/,
    28. exclude: /node_modules/,
    29. loader: 'babel-loader',
    30. query: {
    31. presets: ['es2015', 'react'],
    32. },
    33. }],
    34. },
    35. // 启动开发测试用 server 设定(不能用在 production)
    36. devServer: {
    37. inline: true,
    38. port: 8008,
    39. },
    40. plugins: [HTMLWebpackPluginConfig],
    41. };

太好了!这样我们就完成了开发环境的设定可以开始动手实作 React Server Side Rendering Counter 应用程式了!

先看一下我们整个专案的资料结构,我们把整个专案分成三个主要的资料夹(clientserver,还有共用程式码的 common):

React Redux Sever Rendering(Isomorphic)入门

动手实作

首先,我们先定义了 clientindex.js

  1. // 引用 babel-polyfill 避免浏览器不支援部分 ES6 用法
  2. import 'babel-polyfill';
  3. import React from 'react';
  4. import ReactDOM from 'react-dom';
  5. import { Provider } from 'react-redux';
  6. import CounterContainer from '../common/containers/CounterContainer';
  7. import configureStore from '../common/store/configureStore'
  8. import { fromJS } from 'immutable';
  9. // 从 server 取得传进来的 initialState。由于从字串转回物件,又称为 rehydration(覆水)
  10. const initialState = window.__PRELOADED_STATE__;
  11. // 由于我们使用 ImmutableJS,所以需要把在 server-side dehydration(脱水)又在前端 rehydration(覆水)的 initialState 转成 ImmutableJS 资料型态,并传进 configureStore 建立 store
  12. const store = configureStore(fromJS(initialState));
  13. // 接下来就跟一般的 React App 一样,把 store 透过 Provider 往下传到 Component 中
  14. ReactDOM.render(
  15. <Provider store={store}>
  16. <CounterContainer />
  17. </Provider>,
  18. document.getElementById('app')
  19. );

由于 Node 端要到新版对于 ES6 支援较好,所以先用 babel-registersrc/server/index.js 去即时转译 server.js,但目前不建议在 production 环境使用。

  1. // use babel-register to precompile ES6 syntax
  2. require('babel-register');
  3. require('./server');

接着是我们 server 端,也是这个范例最重要的一个部分。首先我们用 express 建立了一个 port 为 3000 的 server,并使用 webpack 去执行 client 的程式码。这个范例中我们使用了 handleRender 当 request 进来时(直接拜访页面或重新整理)就会执行 fetchCounter() 进行处理:

  1. import Express from 'express';
  2. import qs from 'qs';
  3. import webpack from 'webpack';
  4. import webpackDevMiddleware from 'webpack-dev-middleware';
  5. import webpackHotMiddleware from 'webpack-hot-middleware';
  6. import webpackConfig from '../webpack.config';
  7. import React from 'react';
  8. import { renderToString } from 'react-dom/server';
  9. import { Provider } from 'react-redux';
  10. import { fromJS } from 'immutable';
  11. import configureStore from '../common/store/configureStore';
  12. import CounterContainer from '../common/containers/CounterContainer';
  13. import { fetchCounter } from '../common/api/counter';
  14. const app = new Express();
  15. const port = 3000;
  16. function handleRender(req, res) {
  17. // 模仿实际非同步 api 处理情形
  18. fetchCounter(apiResult => {
  19. // 读取 api 提供的资料(这边我们 api 是用 setTimeout 进行模仿非同步状况),若网址参数有值择取值,若无则使用 api 提供的随机值,若都没有则取 0
  20. const params = qs.parse(req.query);
  21. const counter = parseInt(params.counter, 10) || apiResult || 0;
  22. // 将 initialState 转成 immutable 和符合 state 设计的格式
  23. const initialState = fromJS({
  24. counterReducers: {
  25. count: counter,
  26. }
  27. });
  28. // 建立一个 redux store
  29. const store = configureStore(initialState);
  30. // 使用 renderToString 将 component 转为 string
  31. const html = renderToString(
  32. <Provider store={store}>
  33. <CounterContainer />
  34. </Provider>
  35. );
  36. // 从建立的 redux store 中取得 initialState
  37. const finalState = store.getState();
  38. // 将 HTML 和 initialState 传到 client-side
  39. res.send(renderFullPage(html, finalState));
  40. })
  41. }
  42. // HTML Markup,同时也把 preloadedState 转成字串(stringify)传到 client-side,又称为 dehydration(脱水)
  43. function renderFullPage(html, preloadedState) {
  44. return `
  45. <!doctype html>
  46. <html>
  47. <head>
  48. <title>Redux Universal Example</title>
  49. </head>
  50. <body>
  51. <div id="app">${html}</div>
  52. <script>
  53. window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\x3c')}
  54. </script>
  55. <script src="/static/bundle.js"></script>
  56. </body>
  57. </html>
  58. `
  59. }
  60. // 使用 middleware 于 webpack 去进行 hot module reloading
  61. const compiler = webpack(webpackConfig);
  62. app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: webpackConfig.output.publicPath }));
  63. app.use(webpackHotMiddleware(compiler));
  64. // 每次 server 接到 request 都会呼叫 handleRender
  65. app.use(handleRender);
  66. // 监听 server 状况
  67. app.listen(port, (error) => {
  68. if (error) {
  69. console.error(error)
  70. } else {
  71. console.info(`==> ? Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`)
  72. }
  73. });

处理完 Server 的部份接下来我们来处理 actions 的部份,在这个范例中 actions 相对简单,主要就是新增和减少两个行为,以下为 src/actions/counterActions.js

  1. import { createAction } from 'redux-actions';
  2. import {
  3. INCREMENT_COUNT,
  4. DECREMENT_COUNT,
  5. } from '../constants/actionTypes';
  6. export const incrementCount = createAction(INCREMENT_COUNT);
  7. export const decrementCount = createAction(DECREMENT_COUNT);

以下为输出常数 src/constants/actionTypes.js

  1. export const INCREMENT_COUNT = 'INCREMENT_COUNT';
  2. export const DECREMENT_COUNT = 'DECREMENT_COUNT';

在这个范例中我们使用 setTimeout() 来模拟非同步的产生资料让 server 端在每次接收 request 时读取随机产生的值。实务上,我们会开 API 让 Server 读取初始要汇入的 initialState。

  1. function getRandomInt(min, max) {
  2. return Math.floor(Math.random() * (max - min)) + min
  3. }
  4. export function fetchCounter(callback) {
  5. setTimeout(() => {
  6. callback(getRandomInt(1, 100))
  7. }, 500)
  8. }

谈完 actions 我们来看我们的 reducers,在这个范例中 reducers 也是相对简单的,主要就是针对新增和减少两个行为去 set 值,以下是 src/reducers/counterReducers.js

  1. import { fromJS } from 'immutable';
  2. import { handleActions } from 'redux-actions';
  3. import { CounterState } from '../constants/models';
  4. import {
  5. INCREMENT_COUNT,
  6. DECREMENT_COUNT,
  7. } from '../constants/actionTypes';
  8. const counterReducers = handleActions({
  9. INCREMENT_COUNT: (state) => (
  10. state.set(
  11. 'count',
  12. state.get('count') + 1
  13. )
  14. ),
  15. DECREMENT_COUNT: (state) => (
  16. state.set(
  17. 'count',
  18. state.get('count') - 1
  19. )
  20. ),
  21. }, CounterState);
  22. export default counterReducers;

准备好了 rootReducer 就可以使用 createStore 来创建我们 store,值得注意的是由于 configureStore 需要被 client-side 和 server-side 使用,所以把它输出成 function 方便传入 initialState 使用。以下是 src/store/configureStore.js

  1. import { createStore, applyMiddleware } from 'redux';
  2. import thunk from 'redux-thunk';
  3. import createLogger from 'redux-logger';
  4. import rootReducer from '../reducers';
  5. export default function configureStore(preloadedState) {
  6. const store = createStore(
  7. rootReducer,
  8. preloadedState,
  9. applyMiddleware(createLogger({ stateTransformer: state => state.toJS() }), thunk)
  10. )
  11. return store
  12. }

最后来到了 componentscontainers 的时间,这次我们的 Component 主要有两个按钮让使用者可以新增和减少数字并显示目前数字。以下是 src/components/Counter/Counter.js

  1. import React, { Component, PropTypes } from 'react'
  2. const Counter = ({
  3. count,
  4. onIncrement,
  5. onDecrement,
  6. }) => (
  7. <p>
  8. Clicked: {count} times
  9. {' '}
  10. <button onClick={onIncrement}>
  11. +
  12. </button>
  13. {' '}
  14. <button onClick={onDecrement}>
  15. -
  16. </button>
  17. {' '}
  18. </p>
  19. );
  20. // 注意要检查 propTypes 和给定预设值
  21. Counter.propTypes = {
  22. count: PropTypes.number.isRequired,
  23. onIncrement: PropTypes.func.isRequired,
  24. onDecrement: PropTypes.func.isRequired
  25. }
  26. Counter.defaultProps = {
  27. count: 0,
  28. onIncrement: () => {},
  29. onDecrement: () => {}
  30. }
  31. export default Counter;

最后把取出的 count 和事件处理方法用 connect 传到 Counter 就大功告成了!以下是 src/containers/CounterContainer/CounterContainer.js

  1. import 'babel-polyfill';
  2. import { connect } from 'react-redux';
  3. import Counter from '../../components/Counter';
  4. import {
  5. incrementCount,
  6. decrementCount,
  7. } from '../../actions';
  8. export default connect(
  9. (state) => ({
  10. count: state.get('counterReducers').get('count'),
  11. }),
  12. (dispatch) => ({
  13. onIncrement: () => (
  14. dispatch(incrementCount())
  15. ),
  16. onDecrement: () => (
  17. dispatch(decrementCount())
  18. ),
  19. })
  20. )(Counter);

若一切顺利,在终端机打上 $ npm start,你将可以在浏览器的 http://localhost:3000 看到自己的成果!

React Redux Sever Rendering(Isomorphic)入门

总结

本章阐述了 Web 页面浏览的进程和 Isomorphic JavaScript 的优势,并介绍了如何使用 React Redux 进行 Server Side Rendering 的应用程式设计。下一个章节我们将整合后端资料库,运用 React + Redux + Node(Isomorphic)开发一个简单的食谱分享网站。

延伸阅读

  1. DavidWells/isomorphic-react-example
  2. RickWong/react-isomorphic-starterkit
  3. Server-rendered React components in Rails
  4. Our First Node.js App: Backbone on the Client and Server
  5. Going Isomorphic with React
  6. A service for server-side rendering your JavaScript views
  7. Isomorphic JavaScript: The Future of Web Apps
  8. React Router Server Rendering

(image via airbnb

| 勘误、提问或许愿 |