很多同学用过了Flux,也用过了Redux,但还是觉得不称心?要不要自己造一个?一百行来代码就基本搞定,So easy, so good!
其实,自己造的框架实不实用,并不重要,重要的是思想。有了设计框架的思想后,再去看人家的框架,就会更多地关注人家为何要这么设计?好处在哪?弊端在哪?是否有改进的地方?明白了框架设计者的想法,才能更好地使用框架。
现在,咱们就一起来设计一个React框架,这个框架具备以下几个的特点:
1) 单向数据流:业务数据从UI层触发,经处理到Module层便结束,不再需要人为地将数据反映到UI层。
2) 消息机制:组件与服务之间通过消息总线完成,包括组件与组件之间的嵌套关系。
咱们给这个框架起个响亮的名字——Rebus(React-Bus)。这里的Bus不是公交车的Bus,是计算机基础原理中“Bus”(总线)。很显然,咱们要用“消息总线”这样的思想,实现ReactJs的单向数据流开发模式。一句话概括咱们的框架: Rebus是一个基于消息总线的,单向数据流的,ReactJs开发框架 。
这里是用Rebus写的一个TodoMVC实例: https://github.com/odebo/todomvc-rebus (看在我的代码写得如此粗糙的份上,大虾们赏颗星星鼓励鼓励下呗)。
什么是“单向数据流模式”?这个概念对很多人来说可能有点陌生。下面是Facebook的Flux官网( http://facebook.github.io/flux/ )提供的说明图:
好像有点抽象?那咱们先补补脑,看看什么是双向数据流模式。
什么是双向数据模式?简单地说就是UI层的一个操作经过UI层(View)、控制层(Control)、模式层(Model),做完增、删、改、查等处理后,还得反过来,手动地将增删改查后的数据反映到UI层上。这就双向数据流模式。
而Flux中所谓的单向数据流模式是指:UI层监听应用的“状态”,当一个操作(Action)经过Dispatch(分发器)、Store(状态容器),最后更新了“状态”,UI层自动根据“状态”的变化而更新界面。
这里的“状态”是指一个应用某个时刻的某个状态:比如左侧菜单栏展开与否——状态;导航中高亮项是谁——状态;用户是否登录,用户是谁——状态;Table中多少个Item,分别是什么内容——状态。
简单地说,单向数据流就是单向绑定,UI层与状态绑定,当状态发生变化,UI层自动更新。
可能有的同学会问,既然有AngularJs这样的双向绑定的MVVM模式,还搞什么单向绑定模式,听起来弱爆了。双向绑定肯定比单向绑定高大上得多。
这个问题不太好下结论,双向绑定固然有双向绑定的好处,但也有它的弊端。而相比单向数据流的逻辑处理思路更加单纯清晰。
理解了单向数据流后,咱们给出Rebus框架的数据流模式(如下图)。概括起来就三个步骤:
1) UI层触发的一个Action。
2) Rebus总线根据Action路由表选择对应的Service进行处理。
3) Service处理后,更新状态(State),结束。
这里的Services层指的是业务服务层,提供业务处理接口,包括对状态的修改,对后台数据的异步处理等等。如果觉得这一层太厚,可以分离出专门的Modle接口层。但不管怎样,一个业务操作从UI层到最后修改状态便结束,数据流方向只有一个。
但光这么说还是太抽象了,咱们直接上代码,看看在TodoMVC这个例子中,添加一个新的Todo这个操作是怎么被处理的。
是不是挺简单,简洁的三层结构,清晰的数据流:
1) ReactJs组件只负责渲染和触发Action,具体谁来响应Action,它不管。
2) Rebus总线根据Action路由表,调用对应的Service进行处理。
3) Service层进行完逻辑处理后,通过Rebus.setState()方法更新状态。
但你一定会问:React组件是怎么监听状态的变化的?其实很简单,直接看代码:比如咱们希望添加新的Todo后,TodoBody组件会自动更新。所以TodoBody组件应该监听状态“todos”的变化。
用过Flux的同学都知道Flux中有个叫Dispatch的模块,用来dispatch各种Action。而咱们的Rebus.execute()的作用与Dispatch.dispatch()差不多(如下图)。
不一样的是Rebus.execute(actionHead, arg1,arg2,…)的第一个参数是action头,其它参数直接跟在action头后面。Action头中包含两个信息:要做什么?从哪里来?
“从哪里来”这个参数很重要,因为它给咱们开发、调试提供了极大的便利。试想下,在Action路由表中,咱们能够很清晰地看出一个Action将会到哪个Service处理,但没法直观地看出一个Action是从哪里触发的,而且同样的Action可能由不同的组件触发,这是没法从Action路由表中直观看出来的。
所以,咱们给Rebus增加了一个调试功能,只要打开这个功能,便可以打印Action信息。
另外,如果一个Action被触发,却没在路由表中找到这个Action的路由,Rebus会通过打印错误信息的方式提醒开发者。
自从Action有了源信息,领导再也不用担心我找不到代码的出处了,欧耶!
Action路由表这个概念在Flux与Redux中没有,但也很好理解,就是一个很直观的路由配置信息表。它是在Web应用开始初始化时,加载进来的。
在这张Action路由表中,你可以直观地添加、修改、跟踪一个Action会被哪个Service处理。当你希望某个Action被另一个Service处理时,直接在这个Action路由表中进行修改便是。
另外,在这个Action路由表中,咱们可以通过and()让一个Action触发多个service,如上图的第29行。咱们写了一个日志服务TodoLog.logAddTodo,希望系统处理ADD_TODO的同时也记录这个事件。咱们就可以通过and()函数将这个服务绑定到ADD_TODO这条路由后面,and()的参数是一个数据,意思可以绑定多个服务。
但是,必须提醒的是,不建议and()中的服务也修改State,除非你肯定and()中的服务修改的State与Rebus.connet()中的服务修改的State的监听者没有任何交集。所以,再三提醒and()中只绑定跟State无关的服务,比如一些日志服务、系统统计服务等。
可能你会问,一个Web应用就一张Action路由表吗?是的,也许在后续的版本中咱们可以支持多个Action路由表。但一张路由表也有它的好处——唯一性。比如你设置了某个Action的路由,结果另一个同事在另一张路由表中也设置了同名的Action路由,一开始独立开发时可能没有问题,一旦整合在一起,问题就出现了。所以,只有一张路由表是有好处的,大点没关系。
咱们都知道ReactJs的一大特征就是支持JSX语法,这使得JS代码中可以直接写“类标签代码”,而且一个组件能够被嵌套在另一个组件中,并接受从上级组件传递进来的参数。
这种一层一层嵌套的写法虽然很直观,但也很蛋疼。就拿上面Redux实现的Header组件添加新Todo这个操作,执行的是传递进来的回调函数addTodo(…)。
这么做有几个问题:
1) 写代码时,到底是先约定Header组件要执行的回调函数叫addTodo,写上级组件时按约定传递叫addTodo的参数?还是先写好上级组件,根据上级组件传递的参数名来执行回调函数?到底是先有蛋还是先有鸡?
2) 如果上级组件传参时传错了,或者子组件写回调函数时名称写错了,如何跟踪代码,只知道光从代码上看,我TM怎么知道这个回调函数是从哪个组件传进来的?虽然现在有些工具能够直接在浏览器上查看组件之间的嵌套关系,但那也是在应用能够正常跑起来的情况才能Debug。
3) 组件与组件之间的关系是通过硬编码实现,如果现在有个子组件需要替换,可是这个子组件被嵌入在多个组件中,试问这得怎么找?
组件嵌套是ReactJs的一大亮点,但也是很多人认为ReactJs不适合做大型项目的原因。但我觉得这并不是ReactJs的问题,我们完全可以其他途径解决上面这些问题。比如咱们的Rebus,组件与组件之间不会直接嵌套,而是跟调用后台Service一样,通过Rebus.execute()方法,发起一个Action。比如TodoApp这个上层组件,它嵌套了TodoHead/TodoBody/TodoFoot这三个子组件,但你会发现TodoApp组件是通过execute了三个分别叫GET_TODOHEAD、GET_TODOBODY、GET_TODOFOOT的Action来引入三个子组件,具体引入是怎么的组件,它并不关心。
Rebus总线根据Action路由表(rebus.route.js),分别找到这三个Action对应实现者(在这里咱们通过一个“组件工厂”CompFactory来响应这些Action)。当我们需要替换组件时,只需要在Action路由表中做出修改便是。
换句话说,在Rebus总线面前,每个组件都是平等的。组件只会跟Rebus总线沟通,不会直接嵌入其它组件,也不会被嵌到其它组件中。“组件树”这个概念在Rebus是通过Action消息来实现的,是一种“动态嵌套”关系。
在Flux/Redux中,应用的各种状态以一棵“状态树”的形式都是从根组件上灌进去,所有子组件的状态一律从这个根组件上继承下来(不管组件树的结构有多深)。这样做的好处就是一旦某个状态发生变化,React组件自动从上到下进行更新。
但是,这么做真的好吗?并不是说一个应用就一棵状态树这个想法不好,我也赞同这种设计,因为状态是Web应用中最重要但又非常容易混乱的信息,“唯一性”对状态来说,非常重要。
可是如果所有子组件的状态都是从根组件一层一层传递进来的话,至少会有两个问题:
1) 组件之间的耦合性高,难以并行开发:子组件的状态是由父组件决定。那到底先写父组件还是先写子组件?
2) 状态变化后,难以跟踪变化的组件:假设你的某个操作修改了某个状态,但这个状态的变化会导致哪些组件更新了?光从Store中是看不出的,也无法跟踪,只能从根组件一层一层往下查,看看这个State被传递到哪个组件中。
在Rebus中,咱们同样维系着一棵“状态树”,并在应用初始化的时就加载进来的。
但不同的是,组件的状态不是从上级组件中传递进来,是通过Rebus获得的,而且组件有权决定自己关心哪个State的变化。
这样做有几个好处:
1) 方便并行开发:因为组件之间没有太过的耦合性。状态都是通过Rebus获得的,大部分情况下都是直接返回状态树中的某个状态,这样的“浅处理”非常适用于复杂系统开发中。
2) 方便单元测试:由于组件直接与状态绑定(监听),要对一个组件进行单元测试,直接修改这个组件绑定的状态便是,即是没有上级组件的存在,也不影响测试。
3) 方便维护代码: 从上面的代码中可以清晰地看出某个组件监听哪些状态,但反过来,某个状态被哪些组件监听了?从组件的代码中是没法直观看出来的。这个问题也不应该通过查阅代码的形式来解决,而应该通过咱们的Rebus来解决。咱们可以给Rebus增加一个方法,打印每一个State的监听者。如下图:
现在咱们既可以清晰地看出一个组件监听了哪些状态,也能看出一个状态被哪些组件监听。这为代码的调试与维护提供极大的方便。
另外,我们可以轻松地打印出某个时刻的状态树或具体某个状态的值。
先给有耐心看到这里的人鼓个掌……然后也给我自己鼓个掌……因为对于一个拖延症极度病患者来说,用业余时间写这么一篇技术贴真心不容易。当我写这句话的时候,距离这个帖子的第一句话,整整隔了一个月!——大哥,你是一禅指敲键盘的吗?
言归正传,总结下咱们这个Rebus框架的特点:
1) 实现了单向数据流模式,逻辑层次结构浅,思路清晰。
2) React组件职责单一,只负责渲染与响应交互。
3) 以路由表的形式控制Action数据的流向,直观、易维护。
4) React组件之间通过消息的形式实现动态嵌套。