一文带你读懂:MVVM

584 阅读15分钟

GUI 程序

图形界面应用程序的出现,让用户的体验性更好,但是同时也增加了应用程序的复杂度。

可视化的操作界面,提供了信息(数据),并对响应用户的输入行为(鼠标、键盘等)。部分输入行为会执行一些应用逻辑(application logic),而部分应用逻辑会触发特定的业务逻辑(business logic),业务逻辑会变更对程序的数据,变更的数据实时同步到操作界面上(所见即所得)。

GUI 程序遇到的问题

在开发的时候,为了更好管理程序的复杂度,我们遵循“职责分离”的思想来设计程序的结构。于是我们把应用程序分为 View(视图)和 Model(这个 model 不是 data model,是 Domain Model,是一个针对业务抽象的不包含状态的模型),View 展示并响应用户的操作,Model 提供接口进行业务操作。

那么问题来了:

  • view 怎么响应 model 的数据变更
  • view 和 model 怎么粘合到一起

基本所有的 MV*模式都以不同的方式来处理红框的内容,也就是解决上面两个问题。

Smalltalk-80 MVC

背景

Smalltalk 是一种面向对象的、动态类型的编程语言。有施乐(Xerox PARC)公司与上世纪 70 年代开发,并用于开发 GUI 程序。
当版本更新到 Smalltalk-80 时(1979)一名叫Trygve Reenskaug的工程师开发出了MVC的设计模式。这是最早也是最成功的设计模式之一。

MVC

MVC 中各模块的依赖关系如下:

Controller 和 View 相互依赖,两者都依赖 Model
这个模式在 MV 的基础上加多了一个 Controller,主要用来完成 View 和 Model 之前的协作(包括功能路由,输入预处理等),完成前面所说的应用逻辑处理。

MVC 的协同
  • 用户操作 View 时,view 会捕获这个操作,然后把操作上报给 Controller,把处理权交给 Controller。
  • Controller 会先来自 View 的数据做预处理,然后决定调用哪个 Model 的接口。
  • 由 Model 执行相关的业务操作
  • Model 的数据变更后,会通过观察者模式通知 View(观察者模式一般是通过发布/订阅(pub/sub) 或者是触发 事件(Events) 来实现的)
  • View 接收到变更消息后,向 Model 请求新的数据,然后更新界面

从现在的角度来看,这个模式没什么特别的地方,但还是有需要注意的点:

  1. View 是将权限交给 Controller(有些是 Controller 拦截 View 层的操作,但是其实都是一样的,因为用户行为都是在 View 层产生的,只是转交到 Controller 的方式不同而已)
  2. Controller 才是最终的应用逻辑的处理者,由它来操作 Model
  3. Model 负责处理业务逻辑,生成或者修改数据,但是它不会直接操作 View 层,所以它对 View 是无知的
  4. Model 修改成功后通过观察者模式来通知 View,View 通过调用 Model 的接口完成数据更新
疑问

为什么要通过观察者模式来实现更新通知呢?
因为 GUI 应用程序是可以有多个窗口的,也就是有多个 View,同时也存在多个 Controller 和 Model,整个应用程序是由一个或多个 MVC 三角组成的,通过观察者模式,可以实现一个 Model 的变更,触发多个依赖它的 View 的更新,实现多界面的实时更新。

优缺点

优点

  1. 实现逻辑拆分,高度模块化,应用逻辑变更的时候不需要修改展示逻辑和业务逻辑,只需要切换不同的 Controller 即可
  2. 实现多视图同步更新

缺点

  1. 测试难度大,特别是 Controller,因为 View 的更新是自己执行的,如果再非 UI 的环境下,无法完成 Controller 的单元测试:Model 更新时,无法断言 View 的更新。
  2. View 过分依赖 model,导致 View 的组件化成本变高。View 的复用性不高。实现观察者模式有一个前提,就是用于监控 Model 的 View 必须事先在 Model 上注册,否则 View 无法实现对 Model 的数据变化的感知。

MVC 2.0

进入到B/S的时代时,传统的 MVC 模式无法满足需求。因为传统的 MVC 只是针对客户端图形界面程序的模式,对两端分离是无效的。于是从传统的 MVC 衍生出新的 MVC 模式。

这里主要是因为HTTP 协议是单工、无状态的协议(http1),服务端无法直接推送消息给客户端。除非客户端再次发起请求,否则服务端的数据变更是无法通知到客户端的。那么在传统的 MVC 模式下,View 和 Model 是无法连接到一起的,观察者模式也是无法实现的。

下面为 MVC Model2 的客户端、服务端交互流程:

客户端发送消息给服务端,服务端通过路由规则将消息推送给指定的 Controller,由 Controller 执行应用逻辑,操作 Model,并在 Model 执行完业务逻辑后,用数据去渲染指定的模板并返回给客户端。

这个模式最早(1998年)是应用在 JSP 程序开发中,JSP Model1 的方式结合 servlet (java的服务端程序,主要用于交互式地浏览和修改数据,生成动态Web内容)技术实现,后面又演化出JSP Model2架构,目前几乎所有的 web 开发框架都沿用了这套 MVC 模式。

JSP Model 1的问题
业务逻辑和表示逻辑混合在JSP页面中没有进行抽象和分离,JSP负载太大。所以非常不利于应用系统业务的重用和改动,不便于维护。后面会导致应用功能混乱不易管理。

各模块之间的交互可以简化成这样:

总得来说,MVC的好处就是把整个系统按照最基础的责职切割开,实现“高内聚低耦合”,最大程度减少混乱的情况(例如把很多应用应用逻辑放到View中,导致View和应用逻辑混一起,这样先不说View的组件化,光是View的修改就很麻烦,同时代码的复用率会减低)。灵活地支持业务变更(特别是在敏捷迭代的产品中),避免出现牵一发动全身的情况。

MVP

背景

MVP 全称是Model-View-Presenter,它是由 MVC 演变过来的。1990 年,IBM 旗下的子公司 Taligent 在用 C/C++开发一个叫 CommonPoint 的图形界面应用系统的时候提出来的。

依赖关系

它们最大的区别就是:MVP 中 View 和 Model 是完全隔绝的,通信是通过 Presenter 来完成。除了这点,其他的依赖关系和 MVC 一致。

调用关系

和 MVC 一样,View 将操作的处理权限转交给 Presenter,Presenter 执行应用逻辑,调用 Model 的接口,Model 执行完业务逻辑后,通过观察者模式通知 Presenter。Presenter 获取到新的数据后,通过 View 提供的接口来更新界面。

需要注意的是:

  1. View 和 Presenter 之间不是直接联系的,它们的交互是通过接口来完成的,View 调用 Presenter 的接口触发应用逻辑,Presenter 调用 View 提供的接口完成界面更新。
  2. Presenter 是通过观察者模式来监听 Model 的变化的

在 MVC 中 Controller 是不会直接操作 View 的,View 自己完成更新的操作,但是在 MVP 中 Presenter 会直接操作 View,更新的操作是 Presenter 发起完成的。这是两者最大的区别。

优缺点

优点

  1. 方便测试,因为应用逻辑和界面更新都在 Presenter 完成的,所以在测试 Presenter 的时候不需要 UI 环境,只需要 mock View 的接口并注入到 Presenter 中即可。对 Presenter 的单元测试也可以很好的进行。
  2. View 可以组件化,在 MVP 中 View 不依赖 Model,也就是 View 对业务是无感知的,只要提供特定的接口供 Presenter 去更新就行了,所以针对特定场景可以提取出不同的 View 的组件。

缺点

  1. Presenter 不仅要处理大量的应用逻辑,而且还要承担 View->Mode 和 Model->View 的通讯工作。Presenter 的复杂度会很高,而且非常笨重。

Android中的MVP

在Android中很重要的一点就是对UI的操作基本上需要异步进行也就是在MainThread中才能操作UI,所以对View与Model的切断分离是合理的。

MVVM

背景

MVVM:Model–View–ViewModel,由微软的工程师开发,在 2005 年发表。用于简化 .NET 用户界面的事件驱动程序设计

对比MVC,MVVM的主要变更点是View和ViewModel,并且添加一个数据绑定器Binder,用于数据的绑定和更新。

ViewModel

ViewModel 是指“Model of View”,视图的模型,它其实是包含了领域模型(Domain Model)和视图状态(state)的抽象。在 View 中我们不仅需要展示领域模型的数据,还要展示一些不在领域模型中的视图状态(比如:是否可点击的状态、排序的规则标识等)。ViewModel 可以简单的理解为是一个展示数据的模型(页面展示数据的抽象),用来准确的描述 View。

Binder

与MVC不同的是,MVVM中的View并不会主动去监听数据变化(不监听Model的数据变化也不会从VM获取数据更新),而是通过一个数据绑定器Binder来实现。Binder负责接收Model的数据变更和更新 View 的工作。

模式

开发人员在编写View的时候,会根据特定的模版语法编写View的代码,保证View符合数据绑定的规范,然后在执行(编译)阶段会根据模版提取出View相关的数据,生成ViewModel实例,在将数据与Model的数据进行绑定,这样Binder就知道Model的数据与View展示的数据的关系了,通过监听Model数据的变化,实现View的响应式更新。

在这个基础之上,View的行为可以通过事件绑定或者命令绑定的方式交接给ViewModel。

这样就形成一个完整的链路,通过模版语法编写View,View会生成特定的ViewModel,View的行为交接给ViewModel,ViewModel执行应用逻辑修改Model的数据,Binder在数据变更后同步更新View。

这种方式将原本的事件驱动的模式变更为数据驱动模式。整个程序的行为和状态都由数据的变化来驱动,开发人员只需要关心数据的变更而不需要根据手动去更新视图。

依赖关系

依赖关系跟 MVP 是一致的

调用关系

MVVM 的调用关系跟 MVP 没太大区别。由 ViewModel 负责 View 和 Model 的通讯和交互。不同的是,在 ViewModel 中有一个数据绑定器(Binder/data binging) ,它负责接收 Model 的数据变更和更新 View 的工作。
相对 MVP,MVVM 将 View 和 Model 的同步逻辑自动化了。只需要根据模板语法,在 View 上使用声明式指令绑定 Model 的数据,Binder 就会自动将数据更新到 View 上,当 View 层的操作改变了数据,Binder 也会把数据同步给 Model。这种方式称为:数据双向绑定。

优缺点

优点

  1. 简化了 MVP 的 View 和 Model 手动同步的问题
  2. 方便测试,测试用例可以针对 ViewModel 来做,View 和 Model 是同步更新的,测试换简化很多
  3. 降低耦合度

缺点

  1. 简单的 UI 和界面使用 MVVM 的话,会过度设计。
  2. 大型的应用使用 MVVM,视图的状态会非常多,ViewModel 会变得庞大和复杂,而且大量的数据绑定会消耗大量的内存。
  3. 声明式的绑定无法打断点 debug。

前端框架&MVVM

其实从MVVM的架构逻辑就可以看出为什么angular、vue、react都会选择使用,主要的思路还是为了解决DOM操作带来的复杂度的问题。传统的前端开发模式(例如JQuery),都是事件驱动的模式,View层相关的逻辑都通过事件来触发,通过监听和响应事件来实现交互和状态控制。事件驱动本身没什么问题,但是如果涉及到大量的频繁的DOM操作的话,逻辑就会变得非常复杂,相当于MVC中的VC混合成一个,因为开发者在开发过程中需要将大部分精力(或者说实现的重点)放在操作更新DOM上,这样就会忽略很多其他内容,所以如果你维护够JQ的老项目就可能会遇到一个业务模块js文件有几千行代码,而里面充斥着大量DOM操作。这并不是什么好事,应用逻辑和视图操作耦合在一起,导致过度的混乱。

MVVM数据驱动的思路可以很好的解决这种问题,使用VM来自动完成View层的更新,方式转变,开发者从关注视图变化转为关注数据变化,那么就可以将实现的重点放在处理业务逻辑和数据上,减少心智负担。而且通过这种方式可以很好的将页面、DOM操作、逻辑和数据进行较好的解耦,将这些内容分离后可以更好的做项目管理。

Vue&MVVM

一般的说法都是说Vue是MVVM的前端框架,但是Vue的重点其实是在实现ViewModel上,或者说Vue本身其实就是实现了个巨大的ViewModel。

image.png

上图是Vue官方给出的示图,很清晰的说明了Vue对于MVVM三部分的定义,其中View部分就是真实DOM,Model则是一堆纯粹的JS对象(数据对象)。而Vue主要提供的就是中间的ViewModel的能力,通过模版和指令来实现数据绑定,监听DOM事件来完成行为响应,通过虚拟DOM来实现View层的更新。

在Vue中,每一个Vue实例,都是一个VM,每个VM里面包含了View的实例(DOM对象)vm.$el和需要用到的Model中的数据vm.$data,这里面包含所有在.Vue文件被用作数据的数据。举个例子:我们基于Vue搭建一个待办列表的项目,Model提供了业务数据:待办列表,里面包含了多条待办的各种数据,但是在todo-list.vue文件中,展示只需要用到待办的title、到期时间,应用逻辑需要id,那么vm.$data就是

{ id, title, date }这些来自Model,但是页面展示还需要序号、到期状态展示这些,这些是应用逻辑自己产生的数据,也是需要监听的。所以说是被用作数据(小集合)的数据(大集合)。

至于Binder,Vue2通过Object.defineProperty()实现,Vue3则是通过Proxy实现。

个人的思考

从整个项目架构的角度来看,我个人更愿意把Vue看做MVVM的一部分,即View和ViewModel。不管是采用什么模式(web、混合应用),在架构层面,Vue只是提供了页面展示的能力和触发应用逻辑的入口,而实际的应用逻辑和业务逻辑都属于后面的层级,以一个web项目为例,Vue提供了View和ViewModel。应用逻辑会分为两部分,一部分服务端提供的应用层的接口,另一部分则是在前端写的用于ViewModel中处理View的事件和通过ajax请求应用层的接口来执行应用逻辑,大致的交互逻辑是是这样:

在代码结构的设计上,如果是比较复杂的项目,还是建议将前端的应用逻辑也做一层抽离(按前端的业务抽离成多个模块),有很多开发会将这部分也写在.vue文件中,直接写在生命周期和事件回调函数中,这样会导致应用逻辑与View模版过度耦合,大幅度降低应用逻辑的复用(类似于MVC中将M层的逻辑写在C里面一样),而且在修改到某个应用逻辑的时候会因为耦合的问题可能会大幅度修改UI相关的内容。另外按照原则,应用/业务逻辑不应该与框架相关的过度耦合,灰度后续的业务升级、框架升级等事情造成困扰。

总结

本文是对MVVM的一些解析,个人的理解都是属于在工作中的一些思考的总结,不一定对,也不一定错。如果有补充或者不同的看法,欢迎一起讨论。