大家好,现在是2017年4月。过去的3年里,前端开发领域可谓风起云涌,革故鼎新。除了开发语言的语法增强和工具体系的提升之外,大部分人开始习惯几件事:
组件化
MDV(Model Driven View)
所谓组件化,很容易理解,把视图按照功能,切分为若干基本单元,所得的东西就可以称为组件,而组件又可以一级一级组合而成复合组件,从而在整个应用的规模上,形成一棵倒置的组件树。这种方法论历史久远,其实现方式或有瑜亮,理念则大同小异。
而MDV,则是对很多低级DOM操作的简化,把对DOM的手动修改屏蔽了,通过从数据到视图的一个映射关系,达到了只要操作数据,就能改变视图的效果。
Model-Driven-View
给定一个数据模型,可以得到对应的的视图,这一过程可以表达为:
其中的f就是从Model到View的映射关系,在不同的框架中,实现方式有差异,整体理念则是类似的。
当数据模型产生变化的时候,其对应的视图也会随之变化:
另外一个方面,如果从变更的角度去解读Model,数据模型不是无缘无故变化的,它是由某个操作引起的,我们也可以得出另外一个表达式:
把每次的变更综合起来,可以得到对整个应用状态的表达:
state := actions.reduce(reducer, initState) |
这个表达式的含义是:在初始状态上,依次叠加后续的变更,所得的就是当前状态。这就是当前最流行的数据流方案Redux的核心理念。
从整体来说,使用Redux,相当于把整个应用都实现为命令模式,一切变动都由命令驱动。
Reactive Programming 库简介
在传统的编程实践中,我们可以:
复用一种数据
复用一个函数
复用一组数据和函数的集合
但是,很难做到:提供一种会持续变化的数据让其他模块复用。
而一些基于Reactive Programming的库可以提供一种能力,把数据包装成可持续变更、可观测的类型,供后续使用,这种库包括:RxJS,xstream,most.js等等。
对数据的包装过程类似如下:
const a$ = xs.of(1) const arr$ = xs.from([1, 2, 3]) const interval$ = xs.periodic(1000) |
这段代码中的a$、arr$、interval$都是一种可观测的数据包装,如果对它们进行订阅,就可以收到所有产生的变更。
interval$.subscribe(console.log) |
我们可以把这种封装结构视为数据管道,在这种管道上,可以添加统一的处理规则,这种规则会作用在管道中的每个数据上,并且形成新的管道:
const interval$ = xs.periodic(1000) const result$ = interval$ .filter(num => num % 3) .map(num => num * 2) |
管道可被连续拼接,并形成新的管道。
需要注意的是:
管道是懒执行的。一个拼接起来的数据管道,只有最末端被订阅的时候,附加在管道上的所有逻辑才会被执行。
一般情况下,管道的执行过程可以被共享,比如b$和c$两个管道,都从a$变形得出,它们就共享了a$之前的所有执行过程。
也可以把多个管道组合在一起形成新的管道:
const priv$ = xs.combine(user$, article$) .map(arr => { const [user, article] = arr return user.isAdmin || article.creator === user.id }) |
从这个关系中可以看出,当user$或task$中的数据发生变更的时候,priv$都会自动计算出最新结果。
在业务开发的过程中,可以使用数据流的理念,把很多东西提高一个抽象等级:
const data$ = xs.fromPromise(service(params)) .map(data => ({ loading: false, data })) .replaceError(error => xs.of({ loading: false, error })) .startWith({ loading: true, error: null, }) |
比如上面这个例子,统一处理了一个普通请求过程中的三种状态:请求前、成功、异常,并且把它们的数据:loading、正常数据、异常数据都统一成一种,视图直接订阅处理就行了。
高度抽象的数据来源
很多时候,我们进行业务开发,都是在一种比较低层次的抽象维度上,在低层抽象上,存在着太多的冗余过程。如果能够对数据的来源和去向做一些归纳会怎样呢?
比如说,从实体的角度,很可能一份数据初始状态有多个来源:
应用的默认配置
HTTP请求
本地存储
...等等
也很可能有多个事件都是在修改同一个东西:
用户从视图发起的操作
来自WebSocket的推送消息
来自Worker的处理消息
来自其它窗体的postMessage调用
...等等
如果不做归纳,可能会写出包含以上各种东西的逻辑组合。若干个类似的操作,在过滤掉额外信息之后,可能都是一样的。从应用状态的角度,我们不会需要关心一个数据究竟是从哪里来的,也不会需要关心是通过什么东西发起的修改。
用传统的Redux写法,可能会提取出一些公共方法:
const changeTodo = todo => { dispatch({type: 'updateTodo', payload: todo}) }
const changefromDOMEvent = () => {
const todo = formState
changeTodo(todo)
}
const changefromWebSocket = () => {
const todo = fromWS
changeTodo(todo)
} |
基于方法调用的逻辑不能很好地展示一份数据的生命周期,它可能有哪些来源?可能被什么修改?它是经过几千年怎样的辛苦修炼之后才能够化成人形,跟你坐在一张桌子上喝咖啡?
我们可以借助RxJS或者xstream这样的库,以数据管道的理念,把这些东西更加直观地组织在一起:
初始状态来源
const fromInitState$ = xs.of(todo) const fromLocalStorage$ = xs.of(getTodoFromLS())
// initState
const init$ = xs
.merge(
fromInitState$,
fromLocalStorage$
)
.filter(todo => !todo)
.startWith({}) |
数据变更过程的统一
const changeFromHTTP$ = xs.fromPromise(getTodo()) .map(result => result.data) const changeFromDOMEvent$ = xs .fromEvent($('.btn', 'click')) .map(evt => evt.data) const changeFromWebSocket$ = xs .fromEvent(ws, 'message') .map(evt => evt.data)
// 合并所有变更来源
const changes$ = xs
.merge(
changeFromHTTP$,
changeFromDOMEvent$,
changeFromWebSocket$
) |
在这样的机制里,我们可以很清楚地看到一块数据的来龙去脉,它最初是哪里来的,后来可能会被谁修改过。所有这样的数据都放置在管道中,除了指定的入口,不会有其他东西能够修改这些数据,视图可以很安全地订阅他们。
基于Reactive理念的这些数据流库,一般是没有针对业务开发的强约束的,也以直接订阅并设置组件状态,也可以拿它按照Redux的理念来使用,丰俭由人。
简单的使用
changes$.subscribe(({ payload }) => { xxx.setState({ todo: payload }) }) |
类似Redux的使用方式
const updateActions$ = changes$ .map(todo => ({type: 'updateTodo', payload: todo}))
const todo$ = changeActions$
.fold((state, action) => {
const { payload } = action
return {...state, ...payload}
}, initState) |
组件与外置状态
我们前面提到,组件树是一个树形结构。理想中的组件化,是所有视图状态全部内置在组件中,一级一级传递。只有这样,才能达到组件的最佳可复用状态,并且,组件可以放心把自己该做的事情都做了。
但事实上,组件树的层级可能很多,这会导致传递层级很多,很繁琐,而且,存在一个经典问题,那就是兄弟组件,或者是位于组件树的不同树枝上的组件之间的通信很麻烦,必须通过共同的最近的祖先节点去转发。
像Redux这样的机制,把状态的持有和更新外置,然后通过connect这样的方法,去把特定组件所需的外部状态从props设置进去,但它不仅仅是一个转发器。
我们可以看到如下事实:
转发器在组件树之外
部分数据在组件树之外
对这部分数据的修改过程在组件树之外
修改完数据之后,通知组件树更新
所以:
组件可以通过中转器修改其他组件的状态
组件可以通过中转器修改自身的状态
组件可以通过中转器修改全局的其他状态
这样看来,可以通过中转器修改应用中的一切状态。那么,如果所有状态都可以通过中转器修改,是否意味着都应当通过它修改?
这个问题很大程度上等价于:
组件是否应当拥有自己的内部状态?
我们可能会有如下的选择:
一切状态外置,组件不管理自己状态
部分内置,由组件自己管理,另外一些由全局Store管理
这两种方式,在传统软件开发领域分别称为贫血组件、充血组件,它们的差别是:组件究竟是纯展示,还是带一些逻辑。
也可以拿蚁群和人群来形容这两种组件实践。单个蚂蚁的智能程度很低,但它可以接受蚁王的指令去做某些事情,所有的麻烦事情都集中在上层,决策层的事务非常繁琐。而人类则不同,每个人都有自己的思考和执行能力,一个管理有序的体系中,管理者只需决定他和自己直接下属所需要做的事情就可以了。
在React体系中,纯展示组件可被简化为这样的形式:
const ComponentA = (props) => { return (<div>{props.data}</div>) } |
显而易见,这种组件的优势在于它的展示结果只跟输入数据有关,所有状态外置,因此,在热替换等方面,可以做到极致。
然而,一旦这个组件复杂起来,自带交互,可能就需要在事件、生命周期上做文章,免不了会需要一些中间状态来表达组件自身的形态。
我们当然可以把这种状态也外置,但这么做有几个问题:
这样的状态只跟某组件自己有关,放出去到全局Store,会增加Store的不必要的复杂度
组件的自身形态状态被外置,将导致组件与状态的距离变远,从而对这些状态的读写变得比原先繁琐
带交互的组件,无法独立、完整地描述自身的行为,必须借助外部管理器
如果是一种单独提供的组件库,比如像Ant Design这样的,却要依赖一个外部的状态管理器,这是很不合适的,它会导致组件库带有倾向性,从而对使用者造成困扰。
总的来说,状态全外置,组件退化为贫血组件这种实践,可以得到不少好处,但代价是比较大的。
在You might not need Redux这篇文章中,Redux的作者Dan Abramov提到:
Local State is Fine.
因此,我们就可能会面临一个尴尬的状况,在大部分实践中:
一个组件的状态,可能一半在组件内管理,一半在全局的Store里
以React为例,大致是这样一个状况:
constructor(props) { super(props) this.state = { b: 1 } }
render(props) {
const a = this.state.b + props.c;
return (<div>{a}</div>)
} |
我们看到,在render里面,需要合并state和props的数据,但是在这里做这个事情,是破坏了render函数的纯洁性的。可是,除了这里,别的地方也不太适合做这种合并,怎么办呢?
所以,我们需要一种机制,能够把本地状态和props在render之外统一起来,这可能就是很多实践者倾向于把本地状态也外置的最重要原因。
在React + Redux的实践中,通常会使用connect对视图组件包装一层,变成一种叫做容器组件的东西,这个connect所做的事情就是把全局状态映射到组件的props中。
那么,考虑如下代码:
const mapStateToProps = (state: { a }) => { return { a } }
// const localState = { b: 1 }
// const mapLocalStateToProps = localState =>
localState
const ComponentA = (props) => {
const { a, b } = props
const c = a + b
return (<div>{ c }</div>)
}
return connect(mapStateToProps/*, mapLocalStateToProps*/)(ComponentA) |
我们是否可以把一个组件的内部状态外置到被注释掉的这个位置,然后也connect进来呢?这段代码其实是不起作用的,因为对localState的改变不会被检测到,所以组件不会刷新。
我们先探索这种模式是否可行,然后再来考虑实现的问题。
MVI架构
在Plug and Play All Your Observable Streams With Cycle.js这篇文章中,我们可以看到一组理念:
一切都是事件源
使用Reactive的理念构建程序的骨架
使用sink来定义应用的逻辑
使用driver来隔离有副作用的行为(网络请求、DOM渲染)
基于这套理念,编写代码的方式可以变得很简洁流畅:
从driver中获取action
把action映射成数据流
处理数据流,并且渲染成界面
从界面的事件中,派发action去进行后续事项的处理
在CycleJS的理念中,这种模式叫做MVI(Model View Intent)。在这套理念中,我们的应用可以分为三个部分:
Intent,负责从外部的输入中,提取出所需信息
Model,负责从Intent生成视图展示所需的数据
View,负责根据视图数据渲染视图
整体结构可以这样描述:
App := View(Model(Intent({ DOM, Http, WebSocket }))) |
对比Redux这样的机制,它的差异在于:
Intent实际上做的是action执行过程的高级抽象,提取了必要的信息
Model做的是reducer的事情,把action的信息转换之后合并为状态对象
View跟其他框架没什么区别,从状态对象渲染成视图。
此外,在CycleJS中,View是纯展示,连事件监听也不做,这部分监听的工作放在Intent中去做。
const model = (a$, b$) => { return xs.combine(a$, b$) }
const view = (state$) => {
return state$.map(({ a, b }) => {
const c = a + b;
return h2('c is ' + c)
})
} |
我们可以从中发掘这么一些东西:
View还是纯渲染,接受的唯一参数就是一个表达视图状态的数据流
Model的返回结果就是上面那个流,不分内外状态,全部合并起来
Model所合并的东西的来源,是从Intent中来的
对我们来说,这里面最大关键在于:所有东西的输入输出都是数据流,甚至连视图接受的参数、还有它的渲染结果也是一个流!奥秘就在这里。
因此,我们只需在把待传入视图的props与视图的state以流的方式合并,直接把合并之后的流的结果传入视图组件,就能达到我们在上一节中提出的需求。
组件化与分形
我们之前提到过一点,在一个应用中,组件是形成倒置的树形结构的。当组件树上的某一块越来越复杂,我们就把它再拆开,延伸出新的树枝和叶子,这个过程,与分形有异曲同工之妙。
然而,因为全局状态和本地状态的分离,导致每一次分形,我们都要兼顾本组件、下级组件、全局状态、本地状态,在它们之间作一些权衡,这是一个很麻烦的过程。在React的主流实践中,一般可以利用connect这样的高阶函数,把全局状态映射进组件的props,转化为本地状态。
上一节提及的MVI结构,不仅仅能够描述一个应用的执行过程,还可以单独描述一个组件的执行过程。
Component := View(Model(Intent({ DOM, Http, WebSocket }))) |
所以,从整体来理解我们的应用,就是这样一个关系:
APP [ View <-- Model <-- Intent ] | ------------------------------------------------ | | ComponentA [ ViewA <-- ModelA <-- IntentA ] ComponentB |
这样一直分形下去,每一级组件都可以拥有自己的View、Model、Intent。
状态的变更过程
在模型驱动视图这个理念下,视图始终会是调用链的最后一段,它的职责就是消费已经计算好的数据,渲染出来。所以,从这个角度看,我们的重点工作在于怎么管理状态,包括结构的定义和变更的流转过程。
Redux提供了对状态定义和变更过程的管理思路,但有不少值得探讨的地方。
基于标准Flux/Redux的实践有一个共同点:繁琐。产生这种繁琐的最主要原因是,它们都是以自定义事件为核心的,自定义事件本身就是繁琐的。由于收发事件通常位于两个以上不相同的模块中,不得不以封装的事件对象为通信载体,并且必须显式定义事件的key,否则接收方无法指定自己的响应。
一旦整个应用都是以此为基石,其中的繁琐程度可想而知,所以社区会存在一些简化action创建,或者通过约定来减少action收发中间环节的Redux周边。
如果不从根本上对事件这种机制进行抽象,就不可能彻底解决繁琐的问题,基于Reactive理念的这几个库天然就是为了处理对事件机制的抽象而出现的,所以用在这种场景下有奇效,能把action的派发与处理过程描述得优雅精妙。
const updateActions$ = changes$ .map(todo => ({type: 'updateTodo', payload: todo}))
const todo$ = updateActions$
.fold((state, action) => {
const { payload } = action
return {...state, ...payload}
}, initState) |
注意一个问题,既然我们之前得到一种思路,把全局状态和本地状态分开,然后合并注入组件,就需要考虑这样的问题:如何管理本地状态和全局状态,使用相同的方式去管理吗?
在Redux体系中,我们在修改全局状态的时候,使用指定的action去修改状态,原因是要区分那个哪个action修改state的什么部分,怎样修改。但是考虑本地状态的情况,它反映的只是组件内部的数据变化,一般而言,其结构复杂程度远远低于全局状态,继续采用这种方式的话并不划算。
Redux这类东西出现的初衷只是为了提供一种单向数据流的思路,防止状态修改的混乱。但是在基于数据管道的这些库中,数据天然就是单向流动的。在刚才那段代码里,其实action的type是没有意义的,一直就没有用到。
实际上,这个代码中的updateActions$自身就表达了updateTodo的含义,而它后续的fold操作,实际上就是直接在reduce。理解了这一点之后,我们就可以写出反映若干种数据变更的合集了,这个时候,可以根据不同的action去选择不同的reducer操作:
// 我们可以先把这些action全部merge之后再fold,跟Redux的理念类似 const actions = xs.merge( addActions$, updateActions$, deleteActions$ )
const localState$ = actions.fold((state, action)
=> {
switch(action.type) {
case 'addTodo':
return addTodo(state, action)
case 'updateTodo':
return updateTodo(state, action)
case 'deleteTodo':
return deleteTodo(state, action)
}
}, initState) |
我们注意到,这里是把所有action全部merge了之后再fold的,这是符合Redux方式的做法。有没有可能各自fold之后再merge呢?
其实是有可能的,我们只要能够确保action导致的reducer粒度足够小,比如只修改state的同一个部分,是可以按照这种维度去组织action的。
const a$ = actionsA$.fold(reducerA, initA) const b$ = actionsB$.fold(reducerB, initB) const c$ = actionsC$.fold(reducerC, initC)
const state$ = xs.combine(a$, b$, c$)
.map(([a, b, c]) => ({a, b, c})) |
如果我们一个组件的内部状态足够简单,甚至连action的类型都可以不需要,直接从操作映射到状态结果。
const state$ = xs.fromEvent($('.btn'), click) .map(e => e.data) |
这样,我们可以在组件内运行这种简化版的Redux机制,而在全局状态上运行比较完善的。这两种都是基于数据管道的,然后在容器组件中可以把它们合并,传入视图组件。
整个流程如图所示:
--------------------- ↑ ↓ |-- LocalState View <-- | |-- GlobalState ↓ ↑ Action --> Reducer |
状态的分组与管理
基于redux-saga的封装库dva提供了一种分类机制,可以把一类业务的东西进行分组:
export const project = { namespace: 'project', state: {}, reducers: {}, effects: {}, subscriptions: {} } |
从这个结构可以看出,这个在dva中被称为model的东西,定义了:
它是面向的什么业务模型
需要在全局存储什么样的数据结构
经过哪些操作去变更数据
面向同一种业务实体的数据结构、业务逻辑可以组织到一起,这样,对业务代码的维护是比较有利的。对一个大型应用来说,可以根据业务来划分model。Vue技术栈的Vuex也是用类似的结构来进行业务归类的,它们都是受elm的启发而创建,因此会有类似结构。
回想到上一节,我们提到,如果若干个reducer修改的是state的不同位置,可以分别收敛之后,再进行合并。如果我们把状态结构按照上面这种业务模型的方式进行管理,就可以采用这种机制来分别收敛。这样,单个model内部就形成了一个闭环,能够比较清晰的描述自身所代表的业务含义,也便于做测试等等。
MobX的Store就是类似这样的一个组织形式:
class TodoStore { authorStore @observable todos = [] @observable isLoading = true
constructor(authorStore) {
this.authorStore = authorStore
this.loadTodos()
}
loadTodos() {}
updateTodoFromServer(json) {}
createTodo() {}
removeTodo(todo) {}
} |
依照之前的思路,我们所谓的model其实就是一个合并之后生成state结构的数据管道,因为我们的管道是可以组合的,所以没有特别的必要去按照上面那种结构定义。
那么,在整个应用的最上层,是否还有必要去做combineReducer这种操作呢?
我们之前提到一个表达式:
整个React-Redux体系,都是倾向于让使用者尽可能去从整体的角度关注变化,比如说,Redux的输入输出结果是整个应用变更前后的完整状态,React接受的是整个组件的完整状态,然后,内部再去做diff。
我们需要注意到,为什么不是直接把Redux接在React上,而是通过一个叫做react-redux的库呢?因为它需要借助这个库,去从整体的state结构上检出变化的部分,拿给对应的组件去重绘。
所以,我们发现如下事实:
在触发reducer的时候,我们是精确知道要修改state的什么位置的
合并完reducer之后,输出结果是个完整state对象,已经不知道state的什么位置被修改过了
视图组件必须精确地拿到变更的部分,才能排除无效的渲染
整个过程,是经历了变更信息的拥有——丢失——重新拥有过程的。如果我们的数据流是按照业务模型去分别建立的,我们可以不需要去做这个全合并的操作,而是根据需要,选择合并其中一部分去进行运算。
这样的话,整个变更过程都是精确的,减少了不必要的diff和缓存。
如果为了使用redux-tool的话,可以全部合并起来,往redux-tool里面写入每次的全局状态变更信息,供调试使用,而因为数据管道是懒执行的,我们可以做到开发阶段订阅整个state,而运行时不订阅,以减少不必要的合并开销。
Model的结构
我们从宏观上对业务模型作了分类的组织,接下来就需要关注每种业务模型的数据管道上,数据格式应当如何管理了。
在Redux,Vuex这样的实践中,很多人都会有这样的纠结:
在store中,应当以什么样的形式存放数据?
通常,会有两种选择:
打平了的数据,尽可能以id这样的key去索引
贴近视图的数据,比如树形结构
前者有利于查询和更新,而后者能够直接给视图使用。我们需要思考一个问题:
将处理过后的视图状态存放在store中是否合理?
我认为不应当存太偏向视图结构的数据,理由如下:
某一种业务数据,很可能被不同的视图使用,它们的结构未必一致,如果按照视图的格式存储,就要在store中存放不同形式的多份,它们之间的同步是个大问题,也会导致store严重膨胀,随着应用规模的扩大,这个问题更加严重。
既然这样,那就要解决从这种数据到视图所需数据的关联关系,这个处理过程放在哪里合适呢?
在Redux和Vuex中,为了数据的变更受控,应当在reducer或者mutation中去做状态变更,但这两者修改的又是store,这又绕回去了:为了视图渲染方便而计算出来的数据,如果在reducer或者mutation中做,还是得放在store里。
所以,就有了一个结论:从原始数据到视图数据的处理过程不应当放在reducer或mutation中,那很显然就应当放在视图组件的内部去做。
我们理一下这个关系:
[ View <-- VM ] <-- State ↓ ↑ Action --> Reducer |
这个图中,方括号的部分是视图组件,它内部包含了从原始state到view所需数据的变动,以React为例,用代码表示:
render(props) { const { flatternData } = props const viewData = formatData(flatternData) // ...render viewData } |
经过这样的拆分之后,store中的结构更加简单清晰,reducer的职责也更少了,视图有更大的自主权,去从原始数据组装成自己要的样子。
在大型业务开发的过程中,store的结构应当尽早稳定无争议,避免因为视图的变化而不停调整,因此,存放相对原始一些的数据是更合理的,这样也会避免视图组件在理解数据上的歧义。多个视图很可能以不同的业务含义去看待状态树上的同一个分支,这会造成很多麻烦。
我们期望在store中存储更偏向于更扁平化的原始数据。即使是对于从后端返回的层级数据,也可以借助normalizr这样的辅助库去展开。
展开前:
[{ id: 1, title: 'Some Article', author: { id: 1, name: 'Dan' } }, { id: 2, title: 'Other Article', author: { id: 1, name: 'Dan' } }] |
展开后:
{ result: [1, 2], entities: { articles: { 1: { id: 1, title: 'Some Article', author: 1 }, 2: { id: 2, title: 'Other Article', author: 1 } }, users: { 1: { id: 1, name: 'Dan' } } } } |
很明显,这样的结构对我们的后续操作是比较便利的。因为我们手里有数据管道这样的利器,所以不担心数据是比较原始的、离散的,因为对它们作聚合处理是比较容易的,所以可以放心地把这些数据打成比较原始的形态。
前端的数据建模
之前我们提到过store里面存放的是扁平化的原始数据,但是需要注意到,同样是扁平化,可能有像map那样基于id作索引的,也可能有基于数组形式存放的,很多时候,我们是两种都要的。
在更复杂的情况下,还会需要有对象关系的关联,一对一,一对多,多对多,这就导致视图在需要使用store中的数据进行组合的时候,不管是store的结构定义还是组合操作都比较麻烦。
如果前端是单一业务模型,那我们按照前一节的方案,已经可以做到当数据变更的时候,把当前状态推送给订阅它的组件,但实际情况下,都会比这个复杂,业务模型之间会存在关联关系,在一个模型变更的时候,可能需要自动触发所关联到的模型的更新。
如果复杂度较低,我们可以手动处理这种关联,如果联动关系非常复杂,可以考虑对数据按照实体、关系进行建模,甚至加入一个迷你版的类似ORM的库来定义这种关系。
举例来说:
组织可以有下层组织
组织下可以有人员
组织和人员是一对多的关系
如果一个数据流订阅了某个组织的基本信息,它可能只反映这个组织自身实体上的变更,而另外一个数据流订阅了该组织的全部信息,用于形成一个实时更新的组织全视图,则需要聚合该组织和可能的下级组织、人员的变动汇总。
上层视图可以根据自己的需要,选择从不同的数据流订阅不同复杂度的信息。在这种情况下,可以把整个ORM模块整体视为一个外部的数据源。
整个流程如下:
[ View <-- VM ] <-- [State <-- ORM] ↓ ↑ Action --> Reducer |
这里面有几个需要注意的地方:
一个action实际上还是对应到一个reducer,然后发起对state的更改,但因为state已经不是简单结构了,所以我们不能直接改,而是通过这层类似ORM的关系去改。
对ORM的一次修改,可能会产生对state的若干处改动,比如说,改了一个数据,可能会推导出业务上与之有关系的一块关联数据的变更。
如果是基于react-redux这样基于diff的机制,同时修改state的多个位置是可以的,但在我们这套机制里,因为没有了先合并修改再diff的过程,所以很可能多个位置的修改需要通过ORM的关联,延伸出不同的管道来。
视图订阅的state变更,只能组合运算,不应当再干别的事情了。
在这么一种体系下,实际上前端存在着一个类似数据库的机制,我们可以把每种数据的变动原子化,一次提交只更新单一类型的实体。这样,我们相当于在前端部分做了一个读写分离,读取的部分是被实时更新的,可以包含一种类似游标的机制,供视图组件订阅。
下面是Redux-ORM的简单示例,是不是很像在操作数据库?
class Todo extends Model {} Todo.modelName = 'Todo'; Todo.fields = { user: fk('User', 'todos'), tags: many('Tag', 'todos'), };
class Tag extends Model {}
Tag.modelName = 'Tag';
Tag.backend = {
idAttribute: 'name';
};
class User extends Model {}
User.modelName = 'User'; |
小结
文章最开始,我们提到最理想的组件化开发方式是依托组件树的结构,每个组件完成自己内部事务的处理。当组件之间出现通信需求的时候,不得不借助于Redux之类的库来做转发。
但是Redux的理念,又不仅仅是只定位于做转发,它更是期望能管理整个应用的状态,这反过来对组件的实现,甚至应用的整体架构造成了较大的影响。
我们仍然会期望有一种机制,能够像分形那样进行开发,但又希望能够避免状态管理的混乱,因此,MVI这样的模式某种程度上能够满足这种需求,并且达到逻辑上的自洽。
如果以MVI的理念来进行开发,它的一个组件其实是:数据模型、动作、视图三者的集合,这么一个MVI组件相当于React-Redux体系中,connect了store之后的高阶组件。
因此,我们只需把传统的组件作一些处理:
视图隔离,纯化为展示组件
内部状态的定义清晰化
描述出内部状态的来源关系:state := actions.reduce(reducer, initState)
将内部的动作以action的方式输出到上面那个表达式关系中
这样,组件就是自洽的一个东西,它不关注外面是不是Redux,有没有全局的store,每个组件自己内部运行着一个类似Redux的东西,这样的一个组件可以更加容易与其他组件进行配合。
与Redux相比,这套机制的特点是:
不需要显式定义整个应用的state结构
全局状态和本地状态可以良好地统一起来
可以存在非显式的action,并且action可以不集中解析,而是分散执行
可以存在非显式的reducer,它附着在数据管道的运算中
异步操作先映射为数据,然后通过单向联动关系组合计算出视图状态
回顾整个操作过程:
数据的写入部分,都是通过类似Redux的action去做
数据的读取部分,都是通过数据管道的组合订阅去做
借助RxJS或者xstream这样的数据管道的理念,我们可以直观地表达出数据的整个变更过程,也可以把多个数据流进行便捷的组合。如果使用Redux,正常情况下,需要引入至少一种异步中间件,而RxJS因为自身就是为处理异步操作而设计的,所以,只需用它控制好从异步操作到同步的收敛,就可以达到Redux一样的数据单向流动。如果想要在数据管道中接入一段承担中间件职责的东西,也是非常容易的。
而RxJS、xstream所提供的数据流组合功能非常强大,天然提供了一切异步操作的统一抽象,这一点是其他异步方案很难相比的。
所以,这些库,因为拥有下面这些特性,很适合做数据流控制:
对事件的高度抽象
同步和异步的统一化处理
数据变更的持续订阅(订阅模式)
数据的连续变更(管道拼接)
数据变更的的组合运算(管道组合)
懒执行(无订阅者,则不执行)
缓存的中间结果
可重放的历史记录
……等等 |