React/Flux和xhr /路由/缓存

nar*_*rak 12 javascript reactjs reactjs-flux

这更像是"你的意见是什么/我在思考这个问题时是否正确?" 题.

在理解Flux的同时尽量严格,我试图找出XHR调用的位置,处理websockets /外部刺激,路由发生等等.

从我阅读的文章,访谈和浏览facebook示例中,我可以通过几种方式处理这些问题.严格遵循flux,Action创建者是执行所有XHR调用的人,可能PENDING/SUCCESS/FAILURE在请求完成之前和之后触发Actions.
另一个是,来自facebook的Ian Obermiller,所有READ(GET)请求都由Stores直接处理(没有Action创建者/调度员的参与),WRITE(POST)请求由Action Creators处理整个action>dispatcher>store流程.

我们提出/想要坚持的一些理解/结论:

  1. 理想情况下,进出系统的任何事情都只能通过Actions进行.
  2. 离开/进入系统的异步调用将具有PENDING/PROGRESS(think file uploads)/SUCCESS/FAILURE动作.
  3. 整个App中的单一调度程序.
  4. Action>Dispatcher>Store 调用是严格同步的,以坚持调度无法在内部启动另一个调度以避免链接事件/操作.
  5. 存储在视图中持久存在(考虑到它是单页面应用程序,您希望能够重用数据)

我们得出一些结论的一些问题,但我并不完全满意:

  1. 如果采用Stores do Reads和Actions to Writes的方法,您如何处理多个Stores可能能够使用来自单个XHR调用的数据的情况?
    示例:TeamStore发出的API调用/api/teams/{id}返回类似于:

        {  
            entities: {  
                teams: [{  
                    name: ...,  
                    description: ...,  
                    members: [1, 2, 4],  
                    version: ...  
                }],  
                users: [{  
                    id: 1  
                    name: ...,  
                    role: ...,  
                    version: ...  
                },  
                {  
                    id: 2  
                    name: ...,  
                    role: ...,  
                    version: ...  
                },  
                {  
                    id: 3  
                    name: ...,  
                    role: ...,  
                    version: ...  
                }]  
            }  
        }  
    
    Run Code Online (Sandbox Code Playgroud)

    理想情况下,我还想使用此API中返回的信息更新MemberStore.我们维护每个实体的版本号,这些版本在记录更新时更新,这是我们在内部使用的拒绝对陈旧数据的调用等.使用这个,我可以有一个内部逻辑,如果我作为副作用一些其他API调用,我知道我的数据是陈旧的,我触发了对该记录的刷新.
    看起来,解决方案是你需要商店来触发一个动作(这将有效地更新其他相关商店).这会使Store> View> Action to Store> Action短路,我不确定它是不是一个好主意.我们已经有一件事与商店进行他们自己的XHR调用不同步了.像这样的让步最终会开始蔓延到整个系统.
    或知道其他商店并能够与他们沟通的商店.但这打破了商店没有Setters规则.

    1. 解决上述问题的一个简单方法就是坚持将动作作为外部传入/传出刺激的唯一场所.这简化了多个商店更新的逻辑.
      但是现在,你在哪里以及如何处理缓存?我们得出结论,缓存将发生在API Utils/DAO级别.(如果你看一下通量图).
      但这引入了其他问题.为了更好地理解/解释我的意思,例如:

      • /api/teams 返回所有团队的列表,我显示所有团队的列表.
      • 点击团队的链接后,我会查看其详细信息视图,该视图需要来自/api/teams/{id}商店中尚未存在的数据.
        如果Actions处理所有XHR,View将执行类似的TeamActions.get([id])操作TeamDAO.get([id]).为了能够立即返回此调用(因为我们已将其缓存),DAO必须进行缓存,但也要保持集合/项之间的关系.按照设计,这种逻辑已经存在于商店中.
        这里有问题:

      • 你是否在DAO和商店中复制了这个逻辑?

      • 你是否让DAO了解商店,他们可以向商店询问他们是否已经拥有一些数据并且只返回302说,你很高兴你有最新的数据.
    2. 您如何处理涉及XHR API的验证?简单的重复团队名称.
      视图直接命中DAO并做一些TeamDAO.validateName([name])返回承诺的事情,或者你是否创建了一个Action?如果您创建一个Action,Store会通过哪个有效/无效流回View,考虑到它主要是瞬态数据?

    3. 你如何处理路由?我查看了react-router,我不确定是否喜欢它.我不一定认为迫切需要JSX提供路由映射/配置的方法.此外,显然,它使用了自己的RouteDispatcher,它依赖于单个调度程序规则.
      我更喜欢的解决方案来自一些博客帖子/ SO答案,其中路由映射存储在RouteStore中.
      RouteStore还维护CURRENT_VIEW.reactConConiner组件已在RouteStore中注册,并在更改时将其子视图替换为CURRENT_VIEW.当前视图通知AppContainer何时完全加载并且AppContainer触发RouteActions.pending/success/failure,可能带有一些上下文,以通知其他组件达到稳定状态,显示/隐藏忙/加载指示.

    我无法设计干净的东西是你要设计类似于Gmail的路由,你会怎么做?Gmail的一些观察结果我很喜欢:

    • 在页面准备好加载之前,URL不会更改.它在"加载"时保留在当前URL上,并在加载完成后移动到新URL.这样做......
    • 如果失败,您根本不会丢失当前页面.因此,如果您正在撰写,并且"发送"失败,您不会丢失您的邮件(即您不会丢失当前的稳定视图/状态).(他们不会这样做,因为自动保存是可以的,但你明白了)你可以选择将邮件复制/粘贴到某处以便安全保存,直到你可以再次发送.

    一些参考文献:https:
    //github.com/gaearon/flux-react-router-example http://ianobermiller.com/blog/2014/09/15/react-and-flux-interview/ https:// github. COM/Facebook的/焊剂

Pie*_*scy 5

这是我使用facebook Flux和Immutable.js的实现,我认为根据一些经验法则回答你的许多问题:

STORES

  • 商店负责通过Immutable.Record维护数据状态,并通过全局Immutable.OrderedMap引用Record实例维护缓存ids.
  • 商店直接调用WebAPIUtils操作,并触发actions写入操作.
  • 之间关系RecordAFooRecordB从一个分辨RecordA通过实例foo_idPARAMS并且经由呼叫诸如检索FooStore.get(this.foo_id)
  • 商店只公开getters的方法,如get(id),getAll()等.

APIUTILS

  • 我使用SuperAgent进行ajax调用.每个请求都包含在内Promise
  • 我使用由url + params的哈希索引的请求映射Promise
  • 我在Promise解决或拒绝时通过ActionCreators触发操作,例如fooReceived或fooError .
  • fooError action当然应该包含服务器返回的带有验证错误的有效负载.

组件

  • 控制器视图组件侦听存储中的更改.
  • 除了控制器视图组件之外,我的所有组件都是"纯粹的",所以我使用ImmutableRenderMixin只重新渲染它真正需要的东西(这意味着如果你打印Perf.printWasted时间,它应该非常低,几毫秒.
  • Since Relay and GraphQL are not yet open sourced, I enforce to keep my component props as explicit as possible via propsType.
  • Parent component should only passes down the necessary props. If my parent component holds an object such as var fooRecord = { foo:1, bar: 2, baz: 3}; (I'm not using Immutable.Record here for the sake of simplicity of this example) and my child component need to display fooRecord.foo and fooRecord.bar, I do not pass the entire foo object but only fooRecordFoo and fooRecordBar as props to my child component because an other component could edit the foo.baz value, making the child component re-render while this component doesn't need at all this value !

ROUTING - I simply use ReactRouter

IMPLEMENTATION

Here is a basic example :

api

apiUtils/Request.js

var request = require('superagent');

//based on http://stackoverflow.com/a/7616484/1836434
var hashUrl = function(url, params) {
    var string = url + JSON.stringify(params);
    var hash = 0, i, chr, len;
    if (string.length == 0) return hash;
    for (i = 0, len = string.length; i < len; i++) {
        chr   = string.charCodeAt(i);
        hash  = ((hash << 5) - hash) + chr;
        hash |= 0; // Convert to 32bit integer
    }
    return hash;
}

var _promises = {};

module.exports = {

    get: function(url, params) {
        var params = params || {};
        var hash = hashUrl(url, params);
        var promise = _promises[hash];
        if (promise == undefined) {
            promise = new Promise(function(resolve, reject) {
                request.get(url).query(params).end( function(err, res) {
                    if (err) {
                        reject(err);
                    } else {
                        resolve(res);
                    }
                });
            });
            _promises[hash] = promise;
        }
        return promise;
    },

    post: function(url, data) {
        return new Promise(function(resolve, reject) {

            var req = request
                .post(url)
                .send(data)
                .end( function(err, res) {
                    if (err) {
                        reject(err);
                    } else {
                        resolve(res);
                    }
                });

        });
    }

};
Run Code Online (Sandbox Code Playgroud)

apiUtils/FooAPI.js

var Request = require('./Request');
var FooActionCreators = require('../actions/FooActionCreators');

var _endpoint = 'http://localhost:8888/api/foos/';

module.exports = {

    getAll: function() {
        FooActionCreators.receiveAllPending();
        Request.get(_endpoint).then( function(res) {
            FooActionCreators.receiveAllSuccess(res.body);
        }).catch( function(err) {
            FooActionCreators.receiveAllError(err);
        });
    },

    get: function(id) {
        FooActionCreators.receivePending();
        Request.get(_endpoint + id+'/').then( function(res) {
            FooActionCreators.receiveSuccess(res.body);
        }).catch( function(err) {
            FooActionCreators.receiveError(err);
        });
    },

    post: function(fooData) {
        FooActionCreators.savePending();
        Request.post(_endpoint, fooData).then (function(res) {
            if (res.badRequest) { //i.e response return code 400 due to validation errors for example
                FooActionCreators.saveInvalidated(res.body);
            }
            FooActionCreators.saved(res.body);
        }).catch( function(err) { //server errors
            FooActionCreators.savedError(err);
        });
    }

    //others foos relative endpoints helper methods...

};
Run Code Online (Sandbox Code Playgroud)

商店

商店/ BarStore.js

var assign = require('object-assign');
var EventEmitter = require('events').EventEmitter;
var Immutable = require('immutable');

var AppDispatcher = require('../dispatcher/AppDispatcher');
var ActionTypes = require('../constants/BarConstants').ActionTypes;
var BarAPI = require('../APIUtils/BarAPI')
var CHANGE_EVENT = 'change';

var _bars = Immutable.OrderedMap();

class Bar extends Immutable.Record({
    'id': undefined,
    'name': undefined,
    'description': undefined,
}) {

    isReady() {
        return this.id != undefined //usefull to know if we can display a spinner when the Bar is loading or the Bar's data if it is ready.
    }

    getBar() {
        return BarStore.get(this.bar_id);
    }
}

function _rehydrate(barId, field, value) {
    //Since _bars is an Immutable, we need to return the new Immutable map. Immutable.js is smart, if we update with the save values, the same reference is returned.
    _bars = _bars.updateIn([barId, field], function() {
        return value;
    });
}


var BarStore = assign({}, EventEmitter.prototype, {

    get: function(id) {
        if (!_bars.has(id)) {
            BarAPI.get(id);
            return new Bar(); //we return an empty Bar record for consistency
        }
        return _bars.get(id)
    },

    getAll: function() {
        return _bars.toList() //we want to get rid of keys and just keep the values
    },

    Bar: Bar,

    emitChange: function() {
        this.emit(CHANGE_EVENT);
    },

    addChangeListener: function(callback) {
        this.on(CHANGE_EVENT, callback);
    },

    removeChangeListener: function(callback) {
        this.removeListener(CHANGE_EVENT, callback);
    },

});

var _setBar = function(barData) {
    _bars = _bars.set(barData.id, new Bar(barData));
};

var _setBars = function(barList) {
    barList.forEach(function (barData) {
        _setbar(barData);
    });
};

BarStore.dispatchToken = AppDispatcher.register(function(action) {
    switch (action.type)
    {   
        case ActionTypes.BAR_LIST_RECEIVED_SUCESS:
            _setBars(action.barList);
            BarStore.emitChange();
            break;

        case ActionTypes.BAR_RECEIVED_SUCCESS:
            _setBar(action.bar);
            BarStore.emitChange();
            break;

        case ActionTypes.BAR_REHYDRATED:
            _rehydrate(
                action.barId,
                action.field,
                action.value
            );
            BarStore.emitChange();
            break;
    }
});

module.exports = BarStore;
Run Code Online (Sandbox Code Playgroud)

商店/ FooStore.js

var assign = require('object-assign');
var EventEmitter = require('events').EventEmitter;
var Immutable = require('immutable');

var AppDispatcher = require('../dispatcher/AppDispatcher');
var ActionTypes = require('../constants/FooConstants').ActionTypes;
var BarStore = require('./BarStore');
var FooAPI = require('../APIUtils/FooAPI')
var CHANGE_EVENT = 'change';

var _foos = Immutable.OrderedMap();

class Foo extends Immutable.Record({
    'id': undefined,
    'bar_id': undefined, //relation to Bar record
    'baz': undefined,
}) {

    isReady() {
        return this.id != undefined;
    }

    getBar() {
        // The whole point to store an id reference to Bar
        // is to delegate the Bar retrieval to the BarStore,
        // if the BarStore does not have this Bar object in
        // its cache, the BarStore will trigger a GET request
        return BarStore.get(this.bar_id); 
    }
}

function _rehydrate(fooId, field, value) {
    _foos = _foos.updateIn([voucherId, field], function() {
        return value;
    });
}

var _setFoo = function(fooData) {
    _foos = _foos.set(fooData.id, new Foo(fooData));
};

var _setFoos = function(fooList) {
    fooList.forEach(function (foo) {
        _setFoo(foo);
    });
};

var FooStore = assign({}, EventEmitter.prototype, {

    get: function(id) {
        if (!_foos.has(id)) {
            FooAPI.get(id);
            return new Foo();
        }
        return _foos.get(id)
    },

    getAll: function() {
        if (_foos.size == 0) {
            FooAPI.getAll();
        }
        return _foos.toList()
    },

    Foo: Foo,

    emitChange: function() {
        this.emit(CHANGE_EVENT);
    },

    addChangeListener: function(callback) {
        this.on(CHANGE_EVENT, callback);
    },

    removeChangeListener: function(callback) {
        this.removeListener(CHANGE_EVENT, callback);
    },

});

FooStore.dispatchToken = AppDispatcher.register(function(action) {
    switch (action.type)
    {
        case ActionTypes.FOO_LIST_RECEIVED_SUCCESS:
            _setFoos(action.fooList);
            FooStore.emitChange();
            break;

        case ActionTypes.FOO_RECEIVED_SUCCESS:
            _setFoo(action.foo);
            FooStore.emitChange();
            break;

        case ActionTypes.FOO_REHYDRATED:
            _rehydrate(
                action.fooId,
                action.field,
                action.value
            );
            FooStore.emitChange();
            break;
    }
});

module.exports = FooStore;
Run Code Online (Sandbox Code Playgroud)

组件

components/BarList.react.js(控制器视图组件)

var React = require('react/addons');
var Immutable = require('immutable');

var BarListItem = require('./BarListItem.react');
var BarStore = require('../stores/BarStore');

function getStateFromStore() {
    return {
        barList: BarStore.getAll(),
    };
}

module.exports = React.createClass({

    getInitialState: function() {
        return getStateFromStore();
    },

    componentDidMount: function() {
        BarStore.addChangeListener(this._onChange);
    },

    componentWillUnmount: function() {
        BarStore.removeChangeListener(this._onChange);
    },

    render: function() {
        var barItems = this.state.barList.toJS().map(function (bar) {
            // We could pass the entire Bar object here
            // but I tend to keep the component not tightly coupled
            // with store data, the BarItem can be seen as a standalone
            // component that only need specific data
            return <BarItem
                        key={bar.get('id')}
                        id={bar.get('id')}
                        name={bar.get('name')}
                        description={bar.get('description')}/>
        });

        if (barItems.length == 0) {
            return (
                <p>Loading...</p>
            )
        }

        return (
            <div>
                {barItems}
            </div>
        )

    },

    _onChange: function() {
        this.setState(getStateFromStore();
    }

});
Run Code Online (Sandbox Code Playgroud)

组件/ BarListItem.react.js

var React = require('react/addons');
var ImmutableRenderMixin = require('react-immutable-render-mixin')
var Immutable = require('immutable');

module.exports = React.createClass({

    mixins: [ImmutableRenderMixin],

    // I use propTypes to explicitly telling
    // what data this component need. This 
    // component is a standalone component
    // and we could have passed an entire
    // object such as {id: ..., name, ..., description, ...}
    // since we use all the datas (and when we use all the data it's
    // a better approach since we don't want to write dozens of propTypes)
    // but let's do that for the example's sake 
    propTypes: {
        id: React.PropTypes.number.isRequired,
        name: React.PropTypes.string.isRequired,
        description: React.PropTypes.string.isRequired
    }

    render: function() {

        return (
            <li>
                <p>{this.props.id}</p>
                <p>{this.props.name}</p>
                <p>{this.props.description}</p>
            </li>
        )

    }

});
Run Code Online (Sandbox Code Playgroud)

组件/ BarDetail.react.js

var React = require('react/addons');
var ImmutableRenderMixin = require('react-immutable-render-mixin')
var Immutable = require('immutable');

var BarActionCreators = require('../actions/BarActionCreators');

module.exports = React.createClass({

    mixins: [ImmutableRenderMixin],

    propTypes: {
        id: React.PropTypes.number.isRequired,
        name: React.PropTypes.string.isRequired,
        description: React.PropTypes.string.isRequired
    },

    handleSubmit: function(event) {
        //Since we keep the Bar data up to date with user input
        //we can simply save the actual object in Store.
        //If the user goes back without saving, we could display a 
        //"Warning : item not saved" 
        BarActionCreators.save(this.props.id);
    },

    handleChange: function(event) {
        BarActionCreators.rehydrate(
            this.props.id,
            event.target.name, //the field we want to rehydrate
            event.target.value //the updated value
        );
    },

    render: function() {

        return (
            <form onSubmit={this.handleSumit}>
                <input
                    type="text"
                    name="name"
                    value={this.props.name}
                    onChange={this.handleChange}/>
                <textarea
                    name="description"
                    value={this.props.description}
                    onChange={this.handleChange}/>
                <input
                    type="submit"
                    defaultValue="Submit"/>
            </form>
        )

    },

});
Run Code Online (Sandbox Code Playgroud)

components/FooList.react.js(控制器视图组件)

var React = require('react/addons');

var FooStore = require('../stores/FooStore');
var BarStore = require('../stores/BarStore');

function getStateFromStore() {
    return {
        fooList: FooStore.getAll(),
    };
}


module.exports = React.createClass({

    getInitialState: function() {
        return getStateFromStore();
    },

    componentDidMount: function() {
        FooStore.addChangeListener(this._onChange);
        BarStore.addChangeListener(this._onChange);
    },

    componentWillUnmount: function() {
        FooStore.removeChangeListener(this._onChange);
        BarStore.removeChangeListener(this._onChange);
    },

    render: function() {

        if (this.state.fooList.size == 0) {
            return <p>Loading...</p>
        }

        return this.state.fooList.toJS().map(function (foo) {
            <FooListItem 
                fooId={foo.get('id')}
                fooBar={foo.getBar()}
                fooBaz={foo.get('baz')}/>
        });

    },

    _onChange: function() {
        this.setState(getStateFromStore();
    }

});
Run Code Online (Sandbox Code Playgroud)

组件/ FooListItem.react.js

var React = require('react/addons');
var ImmutableRenderMixin = require('react-immutable-render-mixin')

var Bar = require('../stores/BarStore').Bar;

module.exports = React.createClass({

    mixins: [ImmutableRenderMixin],

    propTypes: {
        fooId: React.PropTypes.number.isRequired,
        fooBar: React.PropTypes.instanceOf(Bar).isRequired,
        fooBaz: React.PropTypes.string.isRequired
    }

    render: function() {

        //we could (should) use a component here but this answer is already too long...
        var bar = <p>Loading...</p>;

        if (bar.isReady()) {
            bar = (
                <div>
                    <p>{bar.get('name')}</p>
                    <p>{bar.get('description')}</p>
                </div>
            );
        }

        return (
            <div>
                <p>{this.props.fooId}</p>
                <p>{this.props.fooBaz}</p>
                {bar}
            </div>
        )

    },

});
Run Code Online (Sandbox Code Playgroud)

让我们通过一个完整的循环FooList:

州1:

  • 用户通过FooList控制器视图组件点击页面/ foos /列出Foos
  • FooList控制器视图组件调用 FooStore.getAll()
  • _foosmap是空的,FooStore因此FooStore执行请求FooAPI.getAll()
  • 所述FooList控制器视图组件呈现自身作为自其装载状态state.fooList.size == 0.

这是我们列表的实际外观:

++++++++++++++++++++++++
+                      +
+     "loading..."     +
+                      +
++++++++++++++++++++++++
Run Code Online (Sandbox Code Playgroud)
  • FooAPI.getAll()请求解决并触发FooActionCreators.receiveAllSuccess操作
  • FooStore receive this action, updates its internal state, and emits change.

State 2:

  • FooList controller-view component receive change event and update its state to get the list from the FooStore
  • this.state.fooList.size is no longer == 0 so the list can actually renders itself (note that we use toJS() to explicitly get a raw javascript object since React does not handle correctly mapping on not raw object yet).
  • We're passing needed props to the FooListItem component.
  • By calling foo.getBar() we're telling to the FooStore that we want the Bar record back.
  • getBar() method of Foo record retrieve the Bar record through the BarStore
  • BarStore does not have this Bar record in its _bars cache, so it triggers a request through BarAPI to retrieve it.
  • The same happens for all Foo in this.sate.fooList of FooList controller-view component
  • The page now looks something like this:
++++++++++++++++++++++++
+                      +
+  Foo1 "name1"        +
+  Foo1 "baz1"         +
+  Foo1 bar:           +
+     "loading..."     +
+                      +
+  Foo2 "name2"        +
+  Foo2 "baz2"         +
+  Foo2 bar:           +
+     "loading..."     +
+                      +
+  Foo3 "name3"        +
+  Foo3 "baz3"         +
+  Foo3 bar:           +
+     "loading..."     +
+                      +
++++++++++++++++++++++++

-Now let's say the BarAPI.get(2) (requested by Foo2) resolves before BarAPI.get(1) (request by Foo1). Since it's asynchronous it's totally plausible. - The BarAPI triggers the BAR_RECEIVED_SUCCESS' action via theBarActionCreators. - TheBarStore` responds to this action by updating its internal store and emits change. That's the now the fun part...

State 3:

  • The FooList controller-view component responds to the BarStore change by updating its state.
  • The render method is called
  • The foo.getBar() call now retrieve a real Bar record from BarStore. Since this Bar record has been effectively retrieved, the ImmutablePureRenderMixin will compare old props with current props and determine that the Bar objects has changed ! Bingo, we could re-render the FooListItem component (a better approach here would be to create a separate FooListBarDetail component to let only this component to re-render, here we also re-rendering the Foo's details that have not changed but for the sake of simplicity let's just do that).
  • The page now looks like this :
++++++++++++++++++++++++
+                      +
+  Foo1 "name1"        +
+  Foo1 "baz1"         +
+  Foo1 bar:           +
+     "loading..."     +
+                      +
+  Foo2 "name2"        +
+  Foo2 "baz2"         +
+  Foo2 bar:           +
+    "bar name"        +
+    "bar description" +
+                      +
+  Foo3 "name3"        +
+  Foo3 "baz3"         +
+  Foo3 bar:           +
+     "loading..."     +
+                      +
++++++++++++++++++++++++

If you want me to add more details from a non detailed part (such as action creators, constants, routing, etc., use of BarListDetail component with form, POST, etc.) just tell me in the comments :).


Par*_*ris 0

我的实现中存在一些差异:

  1. 我喜欢采用蝇量级模式的商店。也就是说,除非被迫,所有操作都是“getOrRetrieveOrCreate”

  2. 我不得不放弃承诺大量开发以支持事件/状态。异步通信仍应使用承诺,即操作中的事物使用它们,否则使用事件进行通信。如果视图始终呈现当前状态,那么您需要像“isLoading”这样的状态来呈现微调器。或者您需要触发一个事件,然后更新视图上的状态。我认为用承诺来回应行动可能是一种反模式(不完全确定)。

  3. URL 更改会触发适当的操作。GET 应该有效并且是幂等的,因此 URL 更改通常不会导致失败。然而,它可能会导致重定向。我有一个用于某些操作的“authRequired”装饰器。如果您未通过身份验证,我们会将您重定向到登录页面,并将目标 URL 列为重定向路径。

  4. 为了进行验证,我们考虑从一个操作开始,在开始之前触发“xyzModel:willSaveData”;然后触发“xyzModel:didSaveData”或“xyzModel:failedSaveData”事件。监听这些事件的存储将向关心的视图指示“保存”。它还可能向关心的视图指示“hasValidationError”。如果您想消除错误。您可以从指示错误“wasReceived”的视图中触发一个操作,这会删除“hasValidationError”标志,或者可以选择执行其他操作,例如清除所有验证错误。由于验证的风格不同,验证很有趣。理想情况下,由于输入元素施加的限制,您可以创建一个可接受大多数输入的应用程序。话又说回来,服务器可能不同意这些选择:/。