• 中文
  • ENGLISH
React+Redux 同构应用开发
2016/04/01

背景

随着众多React + Redux 项目在团队中落地,基于此模式的单向数据流应用受到了广泛的推崇。但是在项目开发过程中,尤其是复杂单页应用,JS文件的体积往往高达数百KB。相较于以往开发模式(Kissy、jQuery、Zepto…)几十KB的体积,极大地增加了页面首次加载的时间。PC端中,这些问题并不突出,但对于移动端,尤其是弱网环境下,会大大增加用户的等待时间,从用户体验上来说,是极不友好的。

针对上述问题,一个现在十分火热概念浮出水面 服务端渲染 & 同构

服务端渲染(Server Rendering)

React中提出了 虚拟DOM 的概念,虚拟DOM以对象树的形式保存在内存中,与真实DOM相映射,通过ReactDOM的Render方法,渲染到页面中,并维护DOM的创建、销毁、更新等过程,以最高的效率,得到相同的DOM结构。

虚拟DOM 给页面带来了前所未有的性能提升,但它的精髓不仅局限于此,还给我们带来了另一个福利: 服务端渲染

不同于ReactDOM.render将DOM结构渲染到页面,React中还提供了另外两个方法:ReactDOMServer.renderToStringReactDOMServer.renderToStaticMarkup 。二者将虚拟DOM渲染为一段字符串,代表了一段完整的HTML结构。

同构(Isomorphic)

通过React提供的服务端渲染方法,我们可以在服务器上生成DOM结构,让用户尽早看到页面内容,但是一个能够work的页面不仅仅是DOM结构,还包括了各种事件响应、用户交互。那么意味着,在客户端上,还得执行一段JS代码绑定事件、处理异步交互,在React中,意味着整个页面的组件需要重新渲染一次,反而带来了额外的负担。

因此,在服务端渲染中,有一个十分重要的概念, 同构(Isomorphic) ,在服务端和客户端中,使用完全一致的React组件,这样能够保证两个端中渲染出的DOM结构是完全一致的,而在这种情况下,客户端在渲染过程中,会判断已有的DOM结构是否和即将渲染出的结构相同,若相同,不重新渲染DOM结构,只是进行事件绑定。

在同构应用中,一套代码(不局限于组件),能够同时在客户端和服务端运行,总体结构如下:
isomorphic

配合Redux

上述 服务端渲染 帮我们完成了组件层面的同构问题,对于要使用何种数据流并没有约束,在本次实践中,使用了Redux模式,关于Redux服务端渲染,参看官方文档。其中最重要的一点就是,在服务端和客户端保持 store 一致。

store 的初始状态在Server端生成,为了保持两个端中store的一致,官方示例中通过在页面插入脚本的方式,写入store初始值到window:

window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}

此处输出initialState到页面中,是十分危险的,一定要注意XSS的防范
Redux推荐使用 serialize-javascript 序列化JS对象,这一点十分必要。

实践

要进行服务端渲染,一个node server必不可少,Koa、Express 都是流行的Node端Web框架,前者似乎更受开发者青睐。

Koa

Server端使用 Koa。配合如 xtemplatekoa-jade之类的视图模板,能够快速完成HTML页面的渲染。

关于Koa的使用,并不是本文的重点,在此不过多阐述,选择一个顺手可靠的框架即可。

目录结构

工程的整体结构如下:

├── app                         //服务端
│   ├── controllers                     //控制器
│   ├── routes                          //路由
│   ├── service                         //接口
│   └── views                       //视图
├── assets
├── bin                         
├── build                           //构建,css、js
├── client                      //客户端
│   ├── actions
│   ├── api
│   ├── components
│   ├── constants
│   ├── containers
│   ├── less
│   ├── reducers
│   └── store
├── lib
├── logs
├── mock
└── webpack                     //Webpack配置

其中,所有React组件和Redux 模块都放在 /client 目录下,该目录下存放着一个和我们日常开发React+Redux完全一致的APP。

配置 Koa

我们的HTML页面不再通过静态服务器获取,而是通过Koa Server,配置一个新路由,作为页面的入口。使用xtemplate for Koa作为View层,在/app/routes中新建路由:

'use strict';

var HomeController = require('../controllers/home');
var router = new (require('koa-router'))();
router.get('/home.html', HomeController.index);
module.exports = router;

/app/controllers中新建控制器:

'use strict';

exports.index = function* () {

    //do something

    yield this.render('home');

};

'home', 对应 /app/views下的视图文件

服务端ES6/7支持

通常,在客户端代码中,我们的编程风格里使用了大量的ES6/7语法,如importclass 等,但在服务端,这些语言特性Node还不能完全支持,这就需要我们使用相关的插件,帮助服务端识别此类语法。

引入babel-register

One of the ways you can use Babel is through the require hook. The require hook will bind itself to node’s require and automatically compile files on the fly.

babel-register 通过绑定 require函数的方式(require hook),在 require jsx文件时,使用babel转换语法,因此,应该在任何 jsx 代码执行前,执行 require('babel-register')(config),同时通过配置项config,配置babel语法等级、插件等。具体配置方法可参看官方文档。

处理CSS/LESS文件

babel-register 帮助服务端识别特殊的js语法,但对 less/css 文件无能为力,庆幸的是,在一般情况下,服务端渲染不需要样式文件的参与,css文件只要引入到HTML文件中即可,因此,可以通过配置项,忽略所有 css/less 文件:

require("babel-register")({
  // Optional ignore regex - if any filenames **do** match this regex then they
  // aren't compiled.
  ignore: /(.css|.less)$/,
});

完成jsx语法支持,就可以引入React组件或APP,通过 renderToString 方法进行服务端渲染:

'use strict';

import React from 'react';
import { renderToString } from 'react-dom/server';
import { createStore } from 'redux';
import configureStore from '../../client/store/configureStore';
import { Provider } from 'react-redux';
import App from '../../client/containers/home';

exports.index = function* () {

    //do something

    // 生成store
    const store = configureStore();

    // 从store中获取state
    const finalState = store.getState();
    const html = renderToString(
        <Provider store={store}>
             <App />
        </Provider>
    );

    //将生成的html结构插入模版中
    yield this.render('home', {html: html});

};

使用CSS Modules

通过 babel-register 能够使用babel解决jsx语法问题,对 css/less 只能进行忽略,但在使用了 CSS Modules 的情况下,服务端必须能够解析 less文件,才能得到转换后的类名,否者服务端渲染出的 HTML 结构和打包生成的客户端 css 文件中,类名无法对应。

为了解决这个问题,需要一个额外的工具 webpack-isomorphic-tools,帮助识别less文件。

webpack-isomorphic-tools

简单地说,webpack-isomorphic-tools,完成了两件事:

  1. 以webpack插件的形式,预编译less(不局限于less,还支持图片文件、字体文件等),将其转换为一个 assets.json 文件保存到项目目录下。
  2. require hook,所有less文件的引入,代理到生成的 JSON 文件中,匹配文件路径,返回一个预先编译好的 JSON 对象。

上述过程解决了服务端渲染中不能解析非js文件的痛点,让我们使用CSS Modules时欲罢不能的快感,在服务端得以延续。

配置文件十分冗长,配置细节可查阅官方文档。这里需要注意的是,webpack-isomorphic-tools 的 require hook,是通过一个回调函数进行的:

var WebpackIsomorphicTools = require('webpack-isomorphic-tools'); 
global.webpackIsomorphicTools = new WebpackIsomorphicTools(require('../webpack/webpack-isomorphic-config'))
    .development(__DEVELOPMENT__)
    .server(rootDir, function () {
        //回调
        require('./app.js'); //启动 server
    });

webpack-isomorphic-tools 启动时,会先等待指定目录下 assets.json 文件生成,只有该文件就绪后,require hook 才会进行,进而触发 server 回调,只有在此回调中执行的代码,才能保证进行了require hook。

最终,这个系统大致结构如下图所示:

带来的问题

webpack-isomorphic-tools 这种 hook 方式,将整个Koa Server置于自身的回调中,仿佛『劫持』了整个server,总不是显得那么的优雅。

环境变量

另一个在同构应用中的常见问题就是环境变量,客户端开发中,只需要判断链接、URL参数等,但在server端,并没有清晰的host概念,同一个Server可以在多个host下被访问。 那么,一些环境参数的判断,就要通过环境变量进行。

在webpack中,使用 DefinePlugin 定义环境变量(其实就是global变量):

plugins: [
    ...
    new webpack.DefinePlugin({
        '__ENV__': JSON.stringify('development'),
        __CLIENT__: true,
        __SERVER__: false,
        __DEVELOPMENT__: true,
        __DEVTOOLS__: true
    }),
    ...
]

配置了不同环境变量的 webpack 配置文件,打包得到的也只是固定JS文件,如果要和服务端上多个环境(dev、prod)一一对应,需要使用多个配置文件来完成,发布到不同环境时,使用对应环境下的配置。

构建

通过 gulp,使用不同的 webpack 配置进行打包,生成对应静态资源,发布到CDN即可。

这里需要注意的是,合理使用环境变量,和webpack插件,可以大大减少js文件的体积:

gulp.task("env:prod", function () {
    env({
        BABEL_ENV: 'production',
        NODE_ENV: 'production'
    });
    prodConfig.plugins = prodConfig.plugins.concat(
        new webpack.DefinePlugin({
            'process.env': {
                NODE_ENV: JSON.stringify('production')
            },
            'NODE_ENV': JSON.stringify('production'),
            '__ENV__': JSON.stringify('production')
        }),
        new webpack.optimize.DedupePlugin(),
        new webpack.optimize.OccurenceOrderPlugin(),
        // Compresses javascript files
        new webpack.optimize.UglifyJsPlugin({
            compress: {
                warnings: false
            }
        })
    );
});

通过gulp任务,设置环境变量为production,webpack 将不会把 React 中如PropTypes检查之类的非必需代码打包,同时能够避免babel引入开发环境下的插件。

DedupePluginUglifyJsPlugin 两个webpack插件必不可少,前者帮我们去除重复引入的js代码,后者进行js混淆压缩。

本次项目中,开发阶段的代码 4MB+ 最终被压缩到了 300KB,发布到CDN,浏览器以gzip格式加载,实际大小约为 100KB,即使对于移动端而言,也是一个可以接受的大小。

后续

性能评估

服务端渲染性能的评估、白屏时间优化,需要更为专业和准确的数据来进行判断,有哪些优秀的工具和测试方法,还请不奢赐教~!

总结

本次实践 React+Redux 同构应用,一是出于对新的架构方式的探索,二是需要开发页面本身不复杂,适合用于新技术实践。

其间坎坎坷坷,踩坑无数,也构造了一个同构应用的雏形,虽然还不够完善,但是也希望对后续开发同构应用的同学带来启发。

订阅我们