本系列博文从 Shadow Widget 作者的视角,解释该框架的设计要点。本篇解释 Shadow Widget 在 MVC、MVVM、Flux 框架之间如何做选择。
1. React Flux 框架
Facebook 官方为 React 提出了 flux 框架,也自己实现了一个 flux.js,尽管这个库设计得很差劲,但所有第三方为 React 开发的单向数据流方案,起点都是该库官方所提的 Flux concepts,下面是经典结构图:
Action 可简单理解为指令(或命令),由命令字 type 与命令参数 data(或称 payload)组成。Dispatcher 是分发器,Store 是数据与逻辑处理器,Store 会在 Dispatcher 注册针对各个命令字的响应回调函数。View 就是 React Component,View 常使用 Store 中的数据并订阅 Store 发生变化来刷新自身显示。
几个部件之间数据单向流动,如下:
Action -> Dispatcher -> Store -> View
形成单向流动的原理较简单,大致这样,Store 在 Dispatch 注册的回调函数,由 Action 触发,Dispatcher 解析命令字,找出相应回调用函数实现调用即可。当 Dispatcher 按如下方式触发回调时,回调函数具备事件的特性。
setTimeout( function() {
callback();
},0);
如果立即调用 callback,那只是回调,如果延时 0 秒会让 callback 在下个周期被调用,就成事件了,单向数据流因此得到保证。
当然,上面介绍非常简略,把核心机制讲明白,reflux、redux 让注册回调变事件也都用这个机制。当然,事件化回调的处理过程可能很复杂,比如 Dispatcher 还提供 waitFor()
等待一项或多项 Action 的接口,我们略去不细讲。
2. React 中的 MVC
React 实现的虚拟 DOM 部分(即核心库 react.js
与 react-dom.js
)是 MVC 中的 "V"
,其 MVC 框架图如下:
当你只使用 React 的核心库,未使用 reflux、redux 等单向数据流机制时,所用的 MVC 就是上图样子。如何构造 Controller 与 Model 是自由的,甚至你想将它改造成 MVVM 也是自由的,毕竟 React 的核心库只提供虚拟 DOM 映射,与 HTML 原生的 DOM 一起提供 "View"
。后面我们真的要介绍怎么改成 MVVM。
Flux、MVC、MVVM 这三者是对等的架构,我们不能直接将 Flux 框架往 MVC 上套。
3. 复杂环境对 MVC 框架的影响
在 React 中使用 MVC 主要缺陷是:当应用规模变大,M, V, C
之间依赖关系会变复杂。下图还不算太复杂,只用到 2 个 Module。
React 虚拟 DOM 对真实 DOM 做了一次抽像,附加 props
、state
等概念,再加上异步时序干扰,原先还勉强玩得转的 MVC,已变得很不好用,开发、调试、定位问题都变困难了。
引入 Flux 能有针对性的缓解上述困难。其一,用单数据流向串接各 View,让与 Model 交互的那个 View(也称 Controller View)承担设计复杂性,其它 View 只做简单工作,如展示界面、简单响应鼠标点击等操作。
其二,用 Action 与 Dispatcher 简化 Controller,不弄那么多 Controller,归总到一个 Dispatcher。其三,采用 Functional Reactive Programming 方法构造响应式的单向数据流机制,以此应对异步时序问题。
React 生态链中有多种 Flux 实现,他们本质一样,表面差别不算大,通常几句话就能概括。reflux 采用多 store 方案,把用于集中分发的 Dispatcher 简化掉了,redux 采用单 store 方案,把分发 Action 后的处理分解给众多 Reducer 函数,也就是说,上图多个 Store 的功能,用 "单 Store + 众多 Reducer 函数" 替换。
4. Shadow Widget 与 Redux 走在两个方向上
Redux 最大优点是实施彻底函数式编程,最大缺点也是彻底函数式。它本身并未简化设计复杂性,只是转移复杂性,但按官方原生的 Flux 概念,我们是按对象方式理解一个个 Store 的,在设计时,处理 Store 与 View,以及与 Action 之间关系时,都按对象方式去思考的,现在把复杂性转移到众多 reducer 函数上,函数式思维不利于设计分解(相对对象化思维而言)。
Redux 之所以能盛行,与 React 自身限制有关。React 的虚拟 DOM 树限制数据单向(向下)传递,跨节点读取属性极不方便,如果我们把所有服务于 render 的 state 数据,独立到节点之外的全局函数(reducer)中去组装呢?所有用到的 state 串一起,形成一个大的全局变量(就是单 Store),reducer 函数想怎么读就怎么读。这个方案以大幅度函数式改造为代价,来突破 React 的限制。
Shadow Widget 做的正相反,尝试维持对象化思维习惯,把 Store 与 ViewModel 合一(后面还有详细说明)以便减轻思考负担;通过建立 Widget 树,用 this.componentOf()
快速检索相关节点,以求方便的存取属性;再设计 duals 双源属性,建立一套能自动识别数据变化,并驱动单向数据流的机制。
5. Controller View 数据传递
我们研究一下 Controller View 与 Store 对接及与下级 View 的连接关系,取上图局部,放大讲解,如下:
当 Store 中有数据更新,通知 Controller View 更新界面,Controller View 就从 Store 读得 state 数据,来更新自己的 state。而自身 state 变化将触发下级 View 联动更新,变化的信息在各子级借助 props 属性实现传递。
为下文讲解作准备,这里我们先拎一拎 Store 该具备的特性:
要提供事件通知功能,当 Store 中的 state 数据有变化,通知 Controller View 刷新界面。
对 Controller View 暴露 state 数据,有两种设计可选,一是让事件通知中带 state 数据,二是事件通知不带数据,要由 Controller View 主动到 Store 查询。结合 FRP 编程特点,第二个设计更好,如果数据连续多次更新,从 Store 读数据应合并为一次,取最新值。
何时通知 Controller View 刷新可能比较复杂,涉及条件组合,比如要 Action A 与 Action B 都发生后,才能触发事件通知。
6. 向 MVVM 演化
我们换一个角度看 flux 框架,传递 Action 相当于 "emit
,将它弱化考虑,另外,Dispatcher 也可弱化,reflux 相比官方的 React flux,一个重要改进就是去掉 Dispatcher,工具复杂性因此降了不少。
这么弱化、简化后,Flux 框架就剩 Store 与 View,参照 MVC 框架,这里 Store 与 MVC 中 Model 是对应的,某种程度上说,Flux 概念与 MVC 具备一定兼容性。
reflux 的 Store 仿 React Component 设计 API,学习成本进一步降低,遗憾的是它是多 Store 结构,一个 Store 对应一个 View(有时对应多个),Store 变多后容易让开发者感到困惑,许多属性设计一时想不清楚该放在 Store,还是放在 View,经常换来换去。这里我没说多 Store 设计不对,单 Store 有单 Store 的问题。而是,多 Store 与 多 View 之间如何思考定位有点拧巴,不像 MVVM 那么直接。
MVVM 采用双向绑定,View 的变动自动反映到 ViewModel,这是非常简单易用的方式,MVVM 在人性化方面比前端其它框架好出很多,因为设计一项功能,开发者首先想的是界面怎么体现,加个按钮,还是加个输入框,然后围绕着按钮或输入框,思考有什么动作,比如,点击按钮后下一步做什么。换成 Flux 思考方式,Store 与 View 之间如何交互要多思考一次,还不以 "界面该怎么呈现" 为思考原点,因为 Action 与 Dispatch 的设计促使你先考虑 Store 的数据结构。
如果让 MVVM 再支持 "所见即所得" 的可视化设计,它的易用性将拉开 Flux 更远,加上 Flux 天然的函数式编程倾向,叠加 react-router 等工具,也自然以路由指令、Action命令、状态数据为思考出发点。比如 react-router 强调,以 "路由" 如何设置为功能开发的第一出发点,不像 MVVM 是以交互界面设计为第一出发点。所以,说句实话,React 生态链上的工具比 Vue 难用得多,这也是 React 急需 Shadow Widget 之类工具的理由。
现在我们明确了引入 MVVM 的收益,非常值得做。问题关键是,它如何与 Flux 共存?
首先,Flux 中的 Store 与 Controller View 可以合并,大胆一点,肯定不会死人。以 reflux 现有设计为例,如果一个 React Component 节点不显示到界面,比如 其次,由前面总结的 Flux 中 Store 该具备的 3 项特性,与 MVVM 的双向数据绑定需求高度重合,以 Shadow Widget 已实现的功能举例: 双源属性具有事件通知功能,它可以被侦听,修改双源属性的值可以触发事件,刷新 trigger 表达式也能触发事件。 将 Controller View 与 Store 合二为一,state 数据也合二为一,省去了两者之间同步。 Shadow Widget 的可计算性属性支持 当然,这些 Flux 中 Store 的需求是附加在 React Component 之上的,如果 Component 想显示界面(而不是用作纯 Store,把界面隐藏起来),尽管显示好了,无非这样的节点还同时具备 Store 的功能。 改造后 Shadow Widget 的 MVVM 如下图: 其中,双合一 Flux 要求的 Action 与 Dispatcher 已被各节点的 至于 Model,它最简的形态就是各 View 节点的 值得一提的是:Shadow Widget 的 MVVM 与 Flux 框架是兼容的,与 Functional Reactive Programming 编程也是兼容的。上图按 Flux 方式绘图,若要体现 MVVM,这么绘制: 上图中,区分 View 与 ViewModel 的主要依据是:一个 Component 节点是否纳入编程,若纳入编程(定义投影定义,或 idSetter 函数)应视作 ViewModel,否则应视作 View,即使这个 View 使用一些 一个 Vue 的 MVVM 例子如下。 对应于 Shadow Widget,界面 View 定义如下: VM 定义如下: Shadow Widget 的 MVVM 与 Vue 相比,更突出从 "界面布局" 出发思考设计,更倾向于函数式编程风格。比如: 在一个 VM 中,Shadow Widget 将 Model 分散在各层多个 React Componet 中,数据服务以 数据分散,处理函数也分散定义,所以上面 Shadow Widget 的事件函数,要用 Shadow Widget 要支持界面可视化设计,可视设计的产出是界面元素的叠加物,当这种叠加物含有函数定义时,保存设计成果,或缓存设计结果(用于 undo 与 redo)将很成问题。因为函数定义要附带上下文才有意义,另外,函数定义体(即 JS 脚本)可以是任意字符,混在界面定义中,给结构化的解析设计结果也带来挑战。所以,Shadow Widget 限制可视设计过程中使用函数化数据,设计态的 props 数据传递不能有函数对象。 在 Shadow Widget 中,与 JSX 对等的界面数据化描述格式叫 json-x,因为 JSON 数据不能带 function 定义,在数据的序列化方面与 JSON 接近,所以就叫 json-x 格式了。界面的可视化设计过程中,输出的(或缓存的),就是这个基于 json-x 的数据。 Shadow Widget 借助在 不过在设计态,某些第三方库需要让特定构件捆绑函数对象,比如封装 slides.js 形成直方图、饼图等样板,在可视化设计中,捆绑的函数就要启用,否则可视化交互设计中直方图、饼图等不被绘制。 Shadow Widget 为这类需求提供两种解决方案。其一,使用 初始化列表(注意,不是 其二,类似 我一直认为,开发语言、编程框架只是人类思维的辅助表达器,人脑观照世界,见山是山,见水是水,人要一个个去认,事物要一件件识别,探究复杂的事物,都是分层拆解的思路。具体到前端开发,客户需求高频变化,在并不纯粹的浏览器方框之中,过分强调纯粹的函数式编程肯定要误人子弟。 见过 React 家族的太多开发者,太多工具陷在追求 "纯正" 的泥淖里,无法自拔,阿弥陀佛!但愿我的观点是正确的。 本文参考资料: 阮一峰:MVC,MVP 和 MVVM 的图示 facebook/flux:Flux Concepts fluxxor.com:What is Flux? Andrew Ray:The ReactJS Controller View Pattern 本专栏历史文章: 介绍一项让 React 可以与 Vue 抗衡的技术 React 可视化开发工具 Shadow Widget 非正经入门(之一:React 三宗罪) React 可视化开发工具 Shadow Widget 非正经入门(之二:分离界面设计) React 可视化开发工具 Shadow Widget 非正经入门(之三:双源属性与数据驱动) 节点,或者 comment 注释节点,或者
style.display='none'
的
any, all ,strict
三种条件同步机制,与 reflux 提供的条件组合等效。比如要求 Action A
与 Action B
都发生后,才触发事件,脚本表达式用 "all:"
前缀指示即可。"Store + Controller View"
是 "VM"
或 "VM + V"
,视该 React Component 需不需在界面显示而定,若同时还用作界面元素的就是 "VM + V"
。duals.attr
属性代替,其中属性名(attr
) 与 Action 的命令字(type
)对等,属性值与 Action 的数据(Payload
)对等。各个 duals.attr
可被自身节点或其它节点侦听,当 duals.attr
取值变化时,相应的侦听函数会按事件方式自动被回调。duals.xxx
属性。遇到复杂的,不妨定义专职的数据服务,用不显示界面的 Controller View 来定义,如上所述,这是 "VM"
。但当它只处理 duals.attr
数据,没有其它功能时,"VM"
的角色将退化为 "M"
。比如 ajax 数据服务(用于从服务侧请求数据,往服务侧保存数据),完全可以用 style.display='none'
的 duals.attr1, duals.attr2
等接口对外提供数据的读、写、侦听等服务。
trigger, $for, $if
等控制指令也如此。7. 对照 Vue 的 MVVM 举个例子
idSetter['btn_todo'] = function(value,oldValue) {
if (value <= 2) {
if (value == 1) { // init process
this.setEvent( {
$onClick: function(event) {
var inputComp = this.componentOf('//input');
var text = inputComp.duals.value.trim();
if (text) {
var dataComp = this.componentOf(0);
dataComp.duals.data = ex.update(dataComp.duals.data, {
$push:[{text:text}],
});
inputComp.duals.value = '';
}
},
});
}
return;
}
};
duals.xxx
方式提供。而 Vue 集中在一处定义数据。
Shadow Widget 先考虑界面如何设计,确定界面元素后,再考虑相关数据绑捆到哪个节点更方便,所以数据服务是分散的,Vue 则提前考虑数据结构如何设计,要集中。所以,这个例子中,用 Vue 时 data.newTodo
定义到 Model,用 Shadow Widget 则视作一个过程数据,不必在对外接口体现。this.componentOf()
动态查找相关节点。Vue 集中定义数据,与数据相关的节点、动作、事件等函数也随之被锁定。这两种方式各有利弊,Vue 方式简单明了,Shadow Widget 更动态、更函数式,使用要复杂些,但应付各种变化自由些,功能更强些,比如各层节点动态增删、改换。8. 函数如何作为数据进行传递
main[widget_path]
预登记投影类定义,实现 function 的动态捆绑,还借助 idSetter[id_string]
预登记 idSetter 函数,这两者让界面可视化设计时避开了函数对象的传递,设计态下投影定义与 idSetter 函数不被捆绑。W.$onLoad
),该列表中的初始化函数在设计态也被调用,通常用它注册特定厂商的库化 UI 节点。库化 UI 供设计中引用,它自身不介入中间设计成果的保存或缓存,在 $$onLoad
初始化函数中可捆绑投影类,或传递 idSetter 函数。T.rewgt.DelayTimer
注册一个自行开发的 WTC 类,然后界面的转义标签就可以用 9. 总结