在不断发展的JavaScript编程领域,响应式编程技术正变得愈加流行。这一系列文章试图向大家介绍该方法目前的进展,介绍各种可用技术,以及该领域产生的变化。从Elm等新语言到Angular 2对RxJS的支持,无论从事什么工作的开发者均有相关新技术可供使用。
InfoQ的这篇文章已包含在“响应式JavaScript”系列文章中。你可以订阅RSS并在内容更新后获得通知。
主要结论
前端架构师正在快速向着函数响应式(Functional reactive)的模式跃进。
函数式HTML、单向数据流或单态树(Single state tree)是该模式的重要元素。
RxJS和不可变性的作用被高估了。
SAM模式的不同之处在于,它主要专注于恰当地实现“应用程序状态突变”。
我们可以使用自己惯用的Web框架实现SAM。
现代化用户体验要求所用架构不仅要能持续“响应”用户输入,而且要能对不同类型的环境(好友和玩家、物理传感器、优步司机抵达乘客上车位置的方式…)做出响应。GUI通过进化已成为广泛、动态的分布式系统中的一个节点,因此会受到从并发到组件失败等各种系统复杂性的影响。
这种情况下,前端架构师正在以为函数响应式模式为目标经历一次重大的转型。各种框架、库,甚至语言层出不穷,它们试图通过积极主动的竞争领导此次转型:
React/Redux
Elm
Cycle.js
Angular 2
Vue.js
Om
MobX
Inferno
DART
Jean-Jacques今年二月发布的一篇文章中描述了一种受到React.js和TLA+启发的全新函数响应式模式:SAM模式。
SAM建议将图形用户界面底层的业务逻辑分为三个概念:操作(Action)、模型(Model)和状态(State)。操作向模型供值,仅模型可以接受这样的值。一旦接受,将通过状态验证所有订阅方,尤其是视图(视图可看作“状态的具体呈现”)已经获得通知。每个事件可作为“步骤”进行处理,步骤可由提议/接受/学习流所组成。这种概念为事件排序和效果(如后端API调用)的处理提供了一个坚实的基础。
SAM的使用不依赖具体框架,共同打造这一模式的很多社区成员1也陆续开发了一系列开发者工具,并通过不同框架编写了大量范例代码,所用框架涵盖了从Vanilla JavaScript到AWS Lambda等诸多类型。
本文将介绍我们在实现SAM模式的过程中学到的经验。本文的目标并不在于比较或评价各种框架或库的优劣,这种类型的比较已经太多了(例如Matt Raible的《Comparing Hot JavaScript Frameworks》一书)。
本文将主要专注于在前端架构中,能对最终交付成果与现代化最终用户应用程序的可维护性产生直接影响的,实践层面的内容:
编程模型(基于组件的视图、一致性、副作用…)
连接(RxJS)
架构(通用JavaScript)
我们打算解决哪些问题?
前端编程模型完全基于事件和回调,传统上这些内容是通过观察者模式(Observer pattern)联系在一起的。
例如当我们需要通过鼠标拖拽手势绘制一个矩形时,与鼠标事件有关的句柄应该是这样的:
function onMouseDown(event) { rectangle = { from: event.position, to: event.position } isMouseDown = true }
function onMouseMove(event) {
if (isMouseDown) {
rectangle.to = event.position;
draw(rectangle);
}
}
function onMouseUp(event) {
isMouseDown = false
}
然而在状态管理方面依然留下了一个核心问题:
回调驱动的代码会显得迟钝,因为事件句柄只能通过状态与系统中的其他东西通信,[因此一般来说]会遇到并发错误,包括丢弃事件、数据争用与缺乏。
...这会对代码质量产生切实的影响。Sean Parent在2007年的一份报告中提到,Adobe:
桌面应用程序中有1/3的代码被用于事件处理逻辑。
而产品生命周期内上报的所有Bug中,有1/2位于这些代码中。
来自EPFL的Ingo Maier和Martin Odersky提供了一份更详细的问题清单:
副作用
封装
可组合性(Composability)
资源管理
关注点分离(Separation of Concern)
数据一致性
均匀性
抽象
语义距离(Semantic Distance)
他们得出的结论是:
用户界面编程领域的很多范例[...]很难用观察者的方式实现,例如一组项目的选择,在一系列对话框之间的顺序操作,文本的编辑和标记 – 基本上这涵盖了用户执行一系列步骤过程中的每一步操作。
函数响应式编程方法如何解决这一问题?
大体方向是为UI构造应用一种纯粹的函数响应式方法,其中:
然而显而易见的是:在函数响应式编程模式中,该如何绕过副作用(例如查询和更新)?诸如Cycle.js、Elm,以及某种程度上的Redux等框架主要专注于将作用与业务逻辑隔离。Richard Feldman解释了作用是如何以数据的方式呈现的,以及从功能测试的角度来看,这种方式可提供的收益。SAM虽然并不强制要求,但也可以支持这样的分隔。然而单元测试(Unit test)的价值还远未得到证实,决定使用单元测试的方法前需要权衡利弊并考虑可能造成的开销。视图与模型之间的函数关系(即函数式HTML)在这方面是如此的强大,可以肯定的是,MVC模式已经时日无多了。鉴于模板和数据绑定(Data binding)已经成为新的Flash,业内领先的框架将很快接受这种全新范式,自然而然地实现视图与无状态组件的解耦。
大家必须意识到,函数响应式框架依然在开发过程中,随着努力发现更有效的业务逻辑因子,还可能进行较大规模的重构。仅考虑Redux社区本身,他们为了解决特定问题所创建的库数量就已大幅增加:redux-sagas、redux-gen、redux-loop、redux-effects、redux-side-effects、redux-thunks、rx-redux、redux-rx... 而这甚至并未算上React本身、GraphQL或Relay。
我们从SAM中学到了什么经验?
1. SAM可以通过你自己惯用的框架来实现
SAM这种模式本身可以使用大部分流行的前端框架来实现,例如Angular和React。
David Fall提供了一种卓越的双客户端React/Redux井字棋(Tic-Tac-Toe)实现;就职于Orange的Bruno Darrigues将SAM与Angular 1.5配合使用提供了一种TypeScript实现;Fred Daoud提供了一种Cycle.js实现;Troy Ingram提供了一种Knockout.js实现,还有Michael Solovyov的一种Vue.js实现。
更重要的是,我们可以在Vanilla JavaScript的基础上实现SAM模式。当然,此时需要对跨站点脚本(XSS)付诸更多关注。然而就算React这样默认Escape所有值的框架也可能显得很脆弱。
2. 语义很重要
SAM在意图(Intent)和实际的改变(Mutation)之间进行了十分清晰的区分。操作(Action)将值提供给模型(Model),但操作绝对无法控制模型是否产生改变以及如何改变,因为这要求操作必须对整个系统具备全面了解。
例如用户点击了一个按钮。点击操作仅仅代表了用户希望做某事的意图。是否允许执行该操作,以及执行后会发生什么事,这些因素是由模型负责考虑的。
因此SAM完全符合软件架构的重要设计原则:
关注点分离(Separation of concern):将应用程序拆分为不同的功能,并确保不同部分在功能上的重叠范围尽可能小。此时最重要的因素在于确保交互点(Interaction point)数量保持最小,以实现高内聚和弱耦合。
单一职责(Single Responsibility):每个组件或模块只应承担某一特定功能或特性,或内聚功能的聚合。
最少知识(Least Knowledge):一个组件或对象不应了解其他组件或对象的内部细节。
不要重复自己(Don’t repeat yourself,DRY):只需要在一个位置指定意图。例如在应用程序设计过程中,一个具体的功能只要在一个组件中实现即可,该功能不应重复出现在任何其他组件中。
当然,就算不遵守这些规则也可以写出Web应用,然而如果你追求的目标是可维护性、可扩展性,以及可复用性,此时SAM将会是一种非常有前景的备选方案。
3. 时间旅行2.0
Redux因其时间旅行和实时代码编辑功能而知名。其实SAM也足够灵活,可以实现类似的功能,并在此基础上进一步提供更多功能。
随着将模型恢复为早先的时点,其状态也会做出响应。如果使用VirtualDOM库(如React),视图也会对新的状态做出响应。nap()函数还提供了额外的层。输入新的状态即可触发nap()函数并可能抛出自己的操作。
因此我们可以使用时间旅行功能测试模型、状态和nap()。SAM DevTools还提供了一种实时概念证实的功能。虽然可以返回至某一时点,但无法维持counter == 10的状态,因为nap()会立刻触发启动(hasLaunched = true)。
实时代码编辑可通过webpack实现。如果使用React,也可以将Dan Abramov的react-hot-loader与SAM配合使用。该工具还提供了一个服务器端的版本,并已包含在SAM的SAFE中间件中。
4. 视图可与模型全面解耦
这也许是使用SAM所能获得的最大价值。SAM的一个独特之处在于,不同于MVx模式,SAM模式中的视图是与模型严格隔离的,而这种隔离通常可通过操作和状态函数实现。
V = S(M)
在SAM中,状态就是一种纯粹的函数。
Thomas J. Buhr解释说:
足够好的前端架构应该能让你用尽可能解耦的方式将模块化的函数固定(Pin)至UI组件。借此即可按需更换为组件提供支撑的技术,而无须担心影响所有业务逻辑(在已经迟到的下一代框架支配下)
SAM的模型通常被称之为“应用程序状态”,在Flux/Redux中则仅仅被称之为“状态”,模型通常由一组属性值组成。状态函数负责通过模型的属性值构建状态的具体呈现(State Representation)。应用程序的控制状态通常也源自属性值,但并不需要将与视图有关的各类属性也放入模型中。
David Fall在为自己的井字棋(Tic-Tac-Toe)范例实现“两玩家”对战的过程中解释了这一问题:
可以取消对‘showJoinSessionForm’模型属性的依赖,转为从封装了表单组件的容器组件中推导出可见性。例如可以用一个名为JoinSession的状态,如果gameType === 'Join Game',并且session === undefined,则该状态为True。组成状态的这两种条件已经足以确定所要显示的表单组件。一旦模型接受了有效的‘session’键,JoinSession状态将不再为True,因此不会渲染表单组件。
此外视图也可分解至无状态组件中,这些无状态组件对于要在哪里渲染,以及相关事件如何连接至应用程序的操作全不知情。
SAM支持(但非强制要求)使用Virtual-dom库。这个概念最初是由React发扬光大的,随后人们据此开发了很多库,例如virtual-dom、mithril以及snabbdom。Jose Pedro Dias[1]提供了一种使用Sabbdom的SAM实现。
5. 通用JavaScript亦可毫不费力地实现
SAM的实现从本质上来说是通用的。该模式的任何元素均可部署在客户端或服务器端,并可按需迁移:
作用可通过操作和模型产生。“博客”范例展示了相同代码(操作、模型、状态)如何以Node.js形式部署到客户端,甚至以无服务器架构形式部署到AWS Lambda。
对于诸如Elm、React/Redux以及Cycle.js(也受到了Elm所用方法的影响)等框架,人们在副作用方面进行了大量的研究。对于这些问题,redux-side-effect的作者Greg Weber解释说:
需要注意的是,redux受到了Elm的启发。在最新版Elm中,Reducer可返回新状态以及作用。[redux-side-effect]库会在Javascript和Redux的约束下尽可能模拟Elm中的作用处理方式。
redux-effects库针对下列类型的作用提供了驱动:
setTimeout/setInterval/requestAnimationFrame
HTTP请求
Cookie get/set
位置(window.location)绑定和设置
生成随机数
抛出操作,作为对window/document事件(如滚动/调整大小/弹出状态等)的回应
localStorage作用驱动
如果URL与某一模式相匹配,自动使用状态中存储的凭据对Fetch请求进行补充。
另一方面,SAM并不强制要求进行如此清晰的分隔,而是专注于实现更可靠的应用程序状态变化。SAM的语义(继承自TLA+符合Paxos协议的要求:
操作提供的值由模型接受(或拒绝),状态使得系统了解这些变化。
对于SAM来说,状态的变化仅受到模型的控制,对操作和状态本身是不可见的。这意味着需要由操作对允许发起HTTP请求的用户意图进行充实(Enrich)和验证,并仅在返回HTTP请求的情况下将结果呈现给模型。同理,并没有什么特别的理由需要我们对模型之外的持久层进行更新(例如通过专门的Elm任务),因为应用程序状态通常取决于更新结果是否成功或失败,而中间状态的体现(“更新”)可能对用户是无关的,用户只关心最终结果。
当然,在此类代码的可测试性方面还有一些争议,但是,举例来说,我们可以使用诸如MounteBank等API虚拟化工具(而非构建Stub)创建受控的可测试环境。
Redux社区目前已经取得了唯一的压倒性优势,建议使用(有状态)“Sagas”以处理副作用。在某种程度上,这个选择让人有些吃惊,毕竟Sagas并未遵守Redux和现代化函数响应式前端架构的第一个基本原则,即并没有基于单一状态书树。SAM的“next-action-predicate(下一步操作预测)”(nap()函数)提供了类似的能力,尽管它使用了一种函数式(例如无状态)的方法。换句话说,nap()函数需要依赖应用程序的当前状态(模型的属性值)来决定是否需要触发某个自动化操作。它并不像Sagas那样会维持自己的状态。当然,“下一步操作”可能需要运行较长时间,并有可能产生副作用,但最终依然能将数据提供给模型。
7. SAM独一无二的“步骤”概念
Lamport博士曾解释说:
编程语言未能给程序的步骤提供精确定义的概念。
由于以TLA+为基础,SAM支持用于对状态的变化进行封装所用的“步骤(Step)”这一概念。SAM的步骤流始终包含三个阶段:提议(操作)、接受(模型),以及学习(状态/视图)。作为对比,在Elm的任务/命令或Redux Sagas中,并不具备有关“步骤”的概念,甚至可在与特定状态转换(如某个操作)无关的情况下随意触发效果。
步骤这一概念使得SAM可以支持一般的操作授权和取消机制。这些概念是通过SAM的State Action Fabric Element(SAFE)实现的。
8. 将RxJs用作连接机制的做法被高估了
RxJS是一种流行的库,可用于为JavaScript实现响应式扩展。人们现在/曾经广泛认为RxJs和事件流是“连接”诸如Cycle.js等框架中不同元素的一种方法:
Cycle.js实际上就是一种构建响应式Web应用的架构:提供了一系列帮你使用RxJS确定应用构造的想法。
甚至谷歌的Angular Team也在使用“ng-rx”,Netflix的Ben Lesh最近还发布了一个redux-observable中间件。
RxJS的问题在于,连接是通过订阅的形式进行的。当我们创建一个“可观察”的变量后,程序中的部分内容需要进行订阅,而有订阅就必然需要退订。更糟的是,如果对同一个可观测变量订阅两次,实例化过程中通常将需要两个执行线程
在最近一篇文章中,André Medeiros提到:
在Cycle.js中,我们只允许[负责处理作用的]驱动内部执行subscribe()。这意味着应用程序[逻辑]对订阅完全不知情。当开发者假设来自驱动的每个可观察目标只有一个执行时,应用程序将变得难以理解和调试。
MobX的创建者Michel Weststrate补充说:
在管理这些订阅时肯定会出错,可能订阅过量(持续订阅组件中不再使用的值或存储)或订阅不足(忘记侦听更新导致产生不易察觉的老旧Bug)。
对于基于观察者的编程模型,最常见的问题在于无法理所当然地产生用于接受所提议变化的临界区段(Critical section)。它们太“响应式”了。这使得我们开始再次面对前端架构最初的问题:事件(现在已经封装为可观察目标或流)直接连接至需要通过某种方式对操作进行同步的事件处理方。
作为对比,以TLA+为基础的SAM提供了一系列侧重于决定特定时间“允许”执行哪些操作的语义。SAM的语义甚至可以在可发起的操作,和已经发起过并能用于提供数据(操作的取消)的操作之间进行明确的区分。这个判断完全基于应用程序的当前状态(始于最后一个步骤),而无须考虑这个状态是通过什么路径到达的。SAM语义可以帮助我们更容易地推理,因为其语义仅基于“当下”,而不像Rx或Saga语义那样需要了解过去(曾订阅的内容)。
结论
前端架构正在快速演化:似乎每周都会出现新的库,以及现有库的常量重构。对函数响应式基础的广泛关注似乎还会继续持续下去,不过我们可能需要对这种方式的真正含义做出更精确的定义。函数式HTML、单向数据流,以及单一状态树等概念已经带来了巨大的价值,而响应式扩展(连接)以及作用的处理似乎还需要进一步研究。在这一过渡过程中,模板和数据绑定似乎还没有什么进展。
和目前所用的方法相比,SAM提供了三个关键的语义。首先,SAM要求在提议和接受模型的变化之间进行清晰的分隔(借此简化副作用的管理)。其次,SAM鼓励开发者将系统事件转换为专门的操作(打造模块化程度更高的代码)。最后,SAM引入了状态函数这一概念,可通过解释模型的属性值推导出状态的呈现和下一步操作。总的来说,这些语义可以帮助我们控制模型变化的顺序,对于包含在更广泛的动态分布式系统中的GUI,这一点非常重要。
若想进一步了解SAM模式请访问这里,并在这里下载SAFE中间件。此外欢迎加入我们在Gitter上的讨论区。