jinzhubaofu / ei

efe isomorphic framework
9 stars 1 forks source link

efe isomorphic framework

Build Status Coverage Status

简洁的flux同构框架

特点

术语

Store

ei中,store是一个页面中全部的数据。

State

ei中,state是指store在某一时刻的状态。所以,state也就是页面中所有的数据。一般来讲是一个Object或者是一个key-value的集合。但理论上来说,它可以是你想要任何一种数据类型。

我们会将state传递给react,作为react组件的数据来使用;通过react组件的翻译,数据将被转化为DOM,最终成为可见、可交互的页面。

Action

Action是一个数据包裹,用来描述系统内一个事件。比如,用户点击一个添加按钮,可以通过下面这个action来描述:


{
    type: 'ADD'
}

完成了一个ajax请求,可以被描述为:


{
    type: 'AJAX_SUCCEED',
    data: {
        // all the data from the datasource
    }
}

基于这样的约定,我们可以把页面理解成一个持续产生action的事件流系统。每个行为都会对我们页面中当前的state造成一定影响,使其发生变化。因此,我们每个时刻的state都可以理解为之前所有的action的积累。

reducer

基于前边两个概念我们可以知道,版本1state在一个action的作用下会转变成版本2state,这个过程我们称之为reduce(归并)。我们当然希望reduce的过程由我们自己来掌握,在ei中抽象为reducer

我们可给出一个非常简洁的函数原型来描述这个过程:


var state2 = reducer(state1, action);

我们非常希望可以通过 state1 === state2 这种简单的方法来判断数据是否发生了变化,只要(只有)数据发生变化,我们才会通知view(react)来完成视图上的更新。

因此,这里非常适合使用Immutable数据结构来管理state

这种行为在ei中是默认行为,ei会自动state1 === state2的方式来检测state的变化,并将变化即时地通知给react

如果你的视图不更新了,那么请检查reduce返回的结果是不是同一个对象。请确保当数据需要发生变化时 state1 !== state2

由于,ei中所有的数据都存放在state中,因此我们只需要一个顶级的reducer就作为入口即可。

我们设计的reducer是一个纯函数,我们可以非常容易地进行组合完成复杂的业务逻辑,比如这样:


var add = function (state, action) {

    return state + 1;

};

var minus = function (state, action) {

    return state - 1;

};

var reducer = function (state, action) {

    switch (action.type) {

        case 'ADD':

            return add(state, action);

        case 'MINUS':

            return minus(state, action);

    }

};

因此,我们不再需要fluxstoreregister回调中使用dispatcher.waitFor方法来完成依赖,我们只需要按逻辑执行不同的子reducer即可。举个例子:


var a = function (state, action) {
    // some operation on state according to action;
    return state;
};

var needWaitForA = function (state, action) {
    // some operation on state according to action;
    return state;
};

var reducer = function (state, action) {

    state = a(state, action);

    state = needWaitForA(state, action);

    return state;

};

实际上,我们还可以把这样的系统理解为一个有限状态自动机,每一个action可以理解为一个输入,而reducer则是状态转移函数。

dispatch

为了使 state / action / reducer可以结合在一起正常工作,我们引入了dispatchdispatch用来连接 state / action/ reducer

当系统接收到一个action时,我们找到store,取得它的当前state,再将stateaction传入reducer。最后,将reducer的返回结果写回到store中。

dispatch可以接收两种数据结构。第一种是传入一个action,这非常容易理解,正是我们想要的。另一种情况是传入一个函数,这是为了支持异步操作。

当传入dispatch的是一个函数中,这个函数会得到两个参数,分别是dispatchstate。也就是说在这个函数中,既可以得到所有的数据,也可以多次dispatch动作。

举个例子,


dispatch(function (dispatch, state) {

    dispatch({
        type: 'AJAX_START'
    });

    http
        .get(
            '/some/data/from/any/datasource',
            {
                query: state.someData
            }
        )
        .then(
            function (data) {
                dispatch({
                    type: 'AJAX_SUCCEED',
                    data: data
                });
            },
            function (error) {
                dispatch({
                    type: 'AJAX_FAILED',
                    error: error
                });
            }
        );

});

可以看到,在这一次dispatch过程中,实际上派发了多个action。因此,我们可以通过reducer来调整state,从而在视图上给用户良好的反馈。

ActionCreator

出于重复利用action的目的,我们提出ActionCreator的概念。每个ActionCreator是一种action的工厂(action factory)。

这它是一个函数,接收的参数格式不限,但返回值必须是一个action或者是一个function

举个例子


function syncAddActionCreator(count) {

    return {
        type: 'SYNC_ADD',
        data: count
    };

}

function asyncAddActionCreator(count) {

    return function (dispatch, state) {

        dispatch({
            type: 'AJAX_START'
        });

        http
            .get(
                '/some/data/from/any/datasource',
                {
                    query: state.someData
                }
            )
            .then(
                function (data) {
                    dispatch({
                        type: 'AJAX_SUCCEED',
                        data: data
                    });
                },
                function (error) {
                    dispatch({
                        type: 'AJAX_FAILED',
                        error: error
                    });
                }
            );

    };

}

var syncAddAction = syncAddActionCreator(count);
var asyncAddAction = asyncAddActionCreator(count);

同样,ActionCreator是一个函数,它也很容易进行封装或者组合,比如:


function doA(count) {

    return {
        type: 'DO_A',
        data: count
    };

}

function doB(count) {

    return function (dispatch, state) {

        dispatch({
            type: 'DO_B'
        });

        dispatch(doA(count));

    };

}

Context

把上边所有的dispatch / reducer / store(state) 概念结合在一起,就是ContextContext的实例数据结构包括了以下内容:


// Context instance
{

    // 归并(状态转移)函数
    reducer: function () {

    },

    // 实际上store可以是任何类似的值
    store: {

    },

    // 派发函数
    dispatch: function () {

    }

}

Context实例不是单例的,每个页面中应当包含有一个。 这样的设计是为了支持在服务器端使用ei。我们知道在服务器端,可以同时处理多个http请求。那么一定需要同时存在多个Context的实例,并且彼此相互隔离。

Page

这是ei对页面的抽象。实际上,Page是Web网站最基本的概念。每次用户发起一个浏览页面的http请求,我们都应当为他响应一个页面。

即使是在spa(single page application,单页面应用)中,其为用户提供的基本感知还是一个基于多个页面的程序,只不过这些页面是虚拟的。

ei所提供的Page是同构的,它既可以在服务器端渲染成了一段html,也可以成为在spa应用中的一个虚拟页面。

ei也提供了基础的spa支持。详见App

实际上,在eiPageContext一对一的关系,既一个Page实例持有一个Context实例。

App

ei中,App是一个应用的概念。eiApp是同构的,在服务器端可以以html格式输出多个页面,也可以在浏览器端内实现spa。

我们可以这样得到一个App实例:


var ei = require('ei');

var app = ei({

    routes: [{
        path: '/a',
        page: 'iso/IndexPage'
    }]

});

可以在服务器端绑定到一个express应用上,例如:


var express = require('express');
var ei = require('ei');

var app = express();

var eiApp = ei({

    // 路由配置
    // 在调用eiApp.execute(request)对请求进行处理时,
    // 首先会使用此处设定的path进行路由匹配,找到相应的Page来进行下一步的处理
    // 如果路由配置不存在,则Promise会进入reject状态
    routes: [{
        path: '/a',
        page: 'iso/IndexPage',
        template: 'some/template'
    }]

});

app.use(function (req, res, next) {

    eiApp
        .execute(req)
        .then(function (result) {

            // result的结构是这样的
            {
                // 路由配置
                route: route,

                // 当前的页面
                page: page

            }

            // 可以从page中取出所有的数据
            var state = page.getState();

            // 还可以把page渲染成html
            var html = page.renderToString();

            // 如果请求是ajax,那么可以直接以state作为响应
            if (req.xhr) {
                res.status(200).send(state);
                return
            }

            // 如果不是ajax,那可以输出为一段html
            // 这样可以灵活地将page的内容输出到指定的位置
            // 还可以灵活地输出同步数据, 比如这样
            // <script>window.data = {%data|json%}</script>
            res.render(route.template, {

                html: html,

                data: data

            });

        }, function (error) {

            // 在整个处理过程中,发生任何错误都会在此处回调,以供处理

        });

});

或者在浏览器端使用,例如:


var ei = require('ei');

var app = ei({

    // 在浏览器端需要指定一个main元素,作为react渲染的根结点
    main: document.getElementById('app'),

    // 与服器端同一样的路由配置
    routes: [{
        path: '/a',
        page: 'iso/IndexPage',
        template: 'some/template'
    }]

});

var data = window.data;

// 直接使用同步数据进行初始化
// 此时,app会接管window.onpopstate事件,
// 浏览器在前进/后退时会把当前的url转化为一个`request`对象
// 与服务器端相同,使用app.execute(request)对其进行处理
// 此时一个多页面网站就成功地转化成了一个spa网站
app.bootstrap(data);

window.data = null;

Resource

Resource是对系统外部资源的一种描述。通常我们会在ActionCreator中使用它,例如:


var countResource = require('resource/count');

function asyncAddActionCreator(count) {

    return function (dispatch, state) {

        countResource
            .add(count)
            .then(function () {

                dispatch({
                    type: 'ADD_SUCCEED'
                });

            }, function () {

                dispatch({
                    type: 'ADD_FAILED'
                });

            });

    };

}

除了通过这种抽象,我们可以重复利用这些资源之外,更重要的是我们需要通过Resource的概念来解除服务器端与浏览器端对资源需求的差异。

我们都知道在浏览器上我们可以使用的资源是有限制的,一般是通过http / socket两种方式。而在服务器端,可使用的资源,比如 redis / mongodb / mysql / file system 以及各种各样的基于 http / tcp 的数据服务器。这是一个基本的事实是浏览器端与服务器端无法抹平的差异。但是我们的业务代码需要同时运行在浏览器端与服务器端,那么我们必须解决这个问题。

这里我们通过Resource依赖注入、控制反转来解决这个问题,将对模块的依赖,转化为对一个资源标识符的依赖。举个例子:


// 同构的 CountActionCreator

var Resource = require('ei').Resource;

function asyncAddActionCreator(count) {

    return function (dispatch, state) {

        Resource.get('count')
            .add(count)
            .then(function () {

                dispatch({
                    type: 'ADD_SUCCEED'
                });

            }, function () {

                dispatch({
                    type: 'ADD_FAILED'
                });

            });

    };

}

// CountResource on client

var Resource = require('ei').Resource;

Resource.register('count', {

    add: function (count) {

        return ajax(count);

    }

});

// CountResource on server

Resource.register('count', {

    add: function (count) {

        return mysql.query('DO WHATEVER YOU NEED');

    }

});

与React相关

child context 机制

这是React的一个隐藏功能,官网上并没有它的明确文档。原因是目前的实现机制并不理想,不久的将来将会被替换成另一个机制。

这里提到的两种机制是:

  1. 基于owner的context机制
  2. 基于parent的context机制

目前的实现机制是第1种,将会被替换成第2种。React在开发模式中会对这两种模式进行检查,一个组件的ownerparent不一致,并且使用了context,那么你会得到一条警告。也就是说,目前我们可以做到的最好情况就是使ReactElement的ownerparent保持一致。

owner 是创建这个ReactElement的ReactElement

parent 是指在DOM层级上的parentNode

更多的资料可以看这里

如果没有context,那我们会遇到一个非常麻烦的问题:组件的数据,必须在父级组件通过props来传递。这样就导致父级组件需要知道所有的数据,并且一层一层地传递下去。

通过context机制,我们可以非常容易地取到最顶层组件的数据,中间的任意多层组件都不需要关心数据是如何传递了。ei中就是通过context机制来解决数据逐层传递的问题的。

但是React对context的使用提出了要求,第一点:

必须明确地声明一个可以提供context的组件,并且要求它必须描述它能提供的context类型,同时实现获取context的函数,即:


var ContextProvider = React.createClass({

    // 必须有
    childContextTypes: {
        context: React.propTypes.object.isRequired
    },

    // 必须有
    getChildContext: function () {
        return {
            context: {}
        };
    },

    render: function () {}

});

第二点:使用context的组件也必须明确地描述contextTypes,即:


var ContextUser = React.createClass({

    contextTypes: {
        context: React.propTypes.object.isRequired
    },

    render: function () {}

});

对,就是这样的喵。

这个我们可以通过两个mixin来解决,比如contextProviderMixin和contextUserMixin,但是ei使用的是higher order component的方法。ei提供了两个组件,ContextProviderContextConnector分别替代contextProviderMixincontextUserMixin。 下边我们分别描述一下:

ContextProvider

ContextProvider是由ei提供的上下文提供包装组件,大概的原理是这样的:


// 假设这个是你的顶层组件
var YourTopLevelComponent = React.createClass({

    render: function () {}

});

// `ei`的`ContextProvider`简化版本
var ContextProvider = React.createClass({

    // 必须有
    childContextTypes: {
        context: React.propTypes.object.isRequired
    },

    // 必须有
    getChildContext: function () {
        return {
            // 这个是你想要共享的context,它来自输入参数
            context: this.props.context
        };
    },

    render: function () {
        // 这里这么做是为了避免我们前边刚刚讲到的`owner`与`parent`不一致的问题
        return this.props.children();
    }

});

// 在生成ReactElement时,是这样的

var element = React.createElement(

    ContextProvider,
    {
        // 这个会被作为context提供给子组件使用
        context: {}
    },
    // 这里这么做的原因是为了避免我们前边刚刚讲到的`owner`与`parent`不一致的问题
    function () {
        return React.createElement(YourTopLevelComponent);
    }
);

当然,在ei中,我们不需要大家来写这些代码,只需要这样做就可以了:


var ei = require('ei');

var IndexPage = ei.Page.extend({

    // `ei`会自动对`view`进行`ContextProvider`包装,提供完整的`ei`上下文
    // 通过`ei`的上下文,可以完成从`store`取数据和`dispatch`动作
    view: React.createClass({

        render: function () {}

    }),

    // 你的reducer在这里
    reducer: function () {}

    // 你只需要关注上边这两个属性

});

ContextConnector

前边我们讲了如何提供上下文,接下来我们讲一下如何访问上下文

其实原理是类似的,也是通过封装组件的方式完成的。

ei中可以很方便地将一个野生组件转化为可以使用上下文的组件:


var ei = require('ei');

var Hello = React.createClass({

    render: function () {

        return (

            // 我们绑定的`ActionCreator`
            // 点击时我们就可以派发动作了
            <div onClick={this.props.add}>

                // 我们选取的数据
                {this.props.name}

            </div>

        );

    }

});

var selector = {

    // 选取`store`中的属性`name`,注入到Hello的props中
    name: function (store) {
        return store.name;
    }

};

var actions = {

    // 这是一个`ActionCreator`
    // 在Hello被实例化为,这个`ActionCreator`将成为`Hello`的`props.add`
    // 执行这个方法,将会将返回的动作派发给`reducer`
    add: function () {

        return {
            type: 'ADD'
        };

    }

};

// 只需要在这里使用`ei`提供的`connect`方法即可
Hello = ei.connect(
    Hello,
    selectors,
    actions
);

module.exports = Hello;

编码建议

目录安排

我们建议在src目录下使用这样的一个目录安排:


- dep          // 存放client端依赖包
- node_modules // 存放server端依赖包
- src
    - client   // 此目录下存放浏览器代码,Client Resource / 启动脚本等
    - server   // 此目录下存放服务器代码,Server Resource / server(express) / server模板 / server配置
    - iso      // 此目录下存放同构代码,Page / Component / Reducer

注意事项

ei 需要以下 shim 支持

  1. es5
  2. promise

cjs or amd?

由于nodejs和浏览器上对于脚本资源获取方式上存在巨大不同,所以我们习惯上是在nodejs使用cjs格式的模块,而在浏览器端我们习惯使用amd格式的模块。

我们建议全部使用cjs的格式编写源码,通过构建工具将client和iso目录下所有的源码从cjs包装成amd格式(这个非常简单,因为amd规范中强调了需要支持cjs格式,所以常见的amd加载器requirejsesl都只需要将cjs代码包装一下define函数,就可以完美使用了)

依赖包的选取

建议直接选取可以同时运行在client/server端的依赖包,例如

  1. http请求:axios / superagent
  2. promise:es6-promise
  3. 日志: ei-logger

相关链接

api文档

测试覆盖率

thanks & Inspired

react flux redux ReactiveX