React/Flux 和 xhr/路由/缓存

IT技术 javascript reactjs reactjs-flux
2021-05-22 15:35:00

这更像是“您的意见/我的想法是否正确?” 问题。

在理解 Flux 的同时尝试尽可能严格,我试图弄清楚 XHR 调用的位置、处理的 websockets/外部刺激、路由发生等。

从我阅读的文章、采访和浏览 Facebook 示例来看,有几种方法可以处理这些事情。严格遵循通量,动作创建者是执行所有 XHR 调用的人,并且有可能PENDING/SUCCESS/FAILURE在请求完成之前和之后触发动作。
另一个是,来自 facebook 的 Ian Obermiller,所有 READ(GETs) 请求都由 Stores 直接处理(不涉及 Action creator/dispatcher),而 WRITE(POSTs) 请求由 Action Creators 处理,贯穿整个action>dispatcher>store流程。

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

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

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

  1. 如果您采用 Stores 执行读取和写入操作的方法,您如何处理多个 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: ...  
                }]  
            }  
        }  
    

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

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

      • /api/teams 返回所有团队的列表,我显示所有团队的列表。
      • 单击团队的链接后,我会转到其详细信息视图,/api/teams/{id}如果 Store 中尚不存在该数据,则该视图需要数据
        如果 Actions 处理所有 XHR,则 View 会做类似TeamActions.get([id])which do 的事情TeamDAO.get([id])为了能够立即返回这个调用(因为我们已经缓存了它),DAO 必须进行缓存,但还要维护集合/项目之间的关系。按照设计,这种逻辑已经存在于 Stores 中。
        问题来了:

      • 你在 DAO 和 Stores 中复制这个逻辑吗?

      • 你让 DAO 知道 Stores,他们可以询问 Store 是否已经有一些数据,然后返回 302 说,你很好,你有最新的数据。
    2. 您如何处理涉及 XHR API 的验证?一些简单的东西,比如重复的团队名称。
      视图直接命中 DAO 并执行诸如TeamDAO.validateName([name])返回Promise或您是否创建操作之类的操作?如果您创建一个操作,考虑到其主要是瞬态数据,Store 通过该操作将有效/无效流回视图?

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

    我无法干净利落地设计的一点是,如果您要设计类似于 Gmail 的路由,您会怎么做?我非常喜欢 Gmail 的一些观察结果:

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

    一些参考资料:https :
    //github.com/gaearon/flux-react-router-example http://ianobermiller.com/blog/2014/09/15/react-and-flux-interview/ https://github。 com/facebook/flux

2个回答

这是我使用 facebook Flux 和 Immutable.js 的实现,我认为它响应了您的许多担忧,基于一些经验法则:

商店

  • Stores 负责通过Immutable.Record维护数据状态,并通过全局Immutable.OrderedMap引用Record实例维护缓存ids
  • 商店直接调用WebAPIUtils读取操作和触发actions操作。
  • RecordA之间的关系通过参数FooRecordBRecordA实例解析foo_id并通过调用检索,例如FooStore.get(this.foo_id)
  • 商店只公开getters的方法,如get(id)getAll()等。

应用程序

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

组件

  • 控制器视图组件侦听存储中的更改。
  • 我的所有组件,除了控制器视图组件,都是“纯”的,所以我使用ImmutableRenderMixin只重新渲染它真正需要的东西(这意味着如果你打印Perf.printWasted时间,它应该非常低,几毫秒。
  • 由于Relay 和 GraphQL尚未开源,我强制props通过propsType.
  • 父组件应该只传递必要的props。如果我的父母组件中包含诸如对象var fooRecord = { foo:1, bar: 2, baz: 3};(我使用的不是Immutable.Record这里的这个例子的简单起见)和我的子组件需要显示fooRecord.foofooRecord.bar,我传递整个foo对象,但只fooRecordFoofooRecordBarprops到我的子组件因为其他组件可以编辑该foo.baz值,使子组件重新渲染,而该组件根本不需要这个值!

路由 - 我只是使用ReactRouter

执行

这是一个基本示例:

接口

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);
                    }
                });

        });
    }

};

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...

};

商店

商店/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;

商店/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;

组件

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();
    }

});

组件/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>
        )

    }

});

组件/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>
        )

    },

});

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();
    }

});

组件/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>
        )

    },

});

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

状态 1:

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

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

++++++++++++++++++++++++
+                      +
+     "loading..."     +
+                      +
++++++++++++++++++++++++
  • FooAPI.getAll()请求解析并触发FooActionCreators.receiveAllSuccess操作
  • FooStore 接收此操作,更新其内部状态,并发出更改。

状态 2:

  • FooList 控制器视图组件接收更改事件并更新其状态以从 FooStore
  • this.state.fooList.size不再== 0因此列表可以实际呈现自身(请注意,我们使用toJS()显式获取原始 javascript 对象,因为React尚未正确处理非原始对象上的映射)。
  • 我们将需要的props传递给FooListItem组件。
  • 通过打电话,foo.getBar()我们告诉FooStore我们想要Bar恢复记录。
  • getBar()Foo记录方法Bar通过检索记录BarStore
  • BarStoreBar在它的_bars缓存中没有这个记录,所以它触发一个请求BarAPI来检索它。
  • 同样的情况对于所有Foothis.sate.fooListFooList控制器视图分量
  • 该页面现在看起来像这样:
++++++++++++++++++++++++++
+ +
+ Foo1 "name1" +
+ Foo1 "baz1" +
+ Foo1 条:+
+ "加载中..." +
+ +
+ Foo2 "name2" +
+ Foo2 "baz2" +
+ Foo2 栏:+
+ "加载中..." +
+ +
+ Foo3 "name3" +
+ Foo3 "baz3" +
+ Foo3 栏:+
+ "加载中..." +
+ +
++++++++++++++++++++++++++

- 现在假设BarAPI.get(2)(由 Foo2 请求)在BarAPI.get(1)(由 Foo1 请求)之前解决由于它是异步的,因此完全合理。-BarAPI触发BAR_RECEIVED_SUCCESS' action via theBarActionCreators . - TheBarStore` 通过更新其内部存储并发出更改来响应此操作。这就是现在有趣的部分......

状态 3:

  • 所述FooList控制器查看组件来响应BarStore通过更新其状态改变。
  • render方法被称为
  • foo.getBar()调用现在Bar从 中检索真实记录BarStore由于此Bar记录已被有效检索,因此ImmutablePureRenderMixin将比较旧props与当前props并确定Bar对象已更改!Bingo,我们可以重新渲染FooListItem组件(这里更好的方法是创建一个单独的 FooListBarDetail 组件,只让这个组件重新渲染,这里我们也重新渲染了 Foo 没有改变的细节,但为了简单,让我们这样做)。
  • 该页面现在看起来像这样:
++++++++++++++++++++++++++
+ +
+ Foo1 "name1" +
+ Foo1 "baz1" +
+ Foo1 条:+
+ "加载中..." +
+ +
+ Foo2 "name2" +
+ Foo2 "baz2" +
+ Foo2 栏:+
+ "酒吧名称" +
+ "条形描述" +
+ +
+ Foo3 "name3" +
+ Foo3 "baz3" +
+ Foo3 栏:+
+ "加载中..." +
+ +
++++++++++++++++++++++++++

如果您希望我从非详细部分添加更多详细信息(例如动作创建者、常量、路由等,使用BarListDetail带有表单组件、POST 等),请在评论中告诉我:)。

我的实现中有一些不同之处:

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

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

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

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