关注 前端瓶子君,回复“交流”
加入我们一起学习,天天进步
作者:子奕,原文链接:https://juejin.im/post/5cd8a7c1f265da037a3d0992
如果你对于MVVM的形成不是特别清晰,则可以先阅读以下部分。
-
了解MV*的演变历史以及特性(可跳过) -
了解观察者模式(可跳过)
本文可以帮助你了解什么?
-
了解Vue的运行机制 -
参考Vue的运行机制,将观察者模式改用中介者模式实现一个简易的MVVM -
MVVM的实现演示 -
MVVM的流程设计 -
中介者模式的实现 -
数据劫持的实现 -
数据双向绑定的实现 -
简易视图指令的编译过程的实现 -
ViewModel的实现 -
MVVM的实现
MV*设计模式的演变历史
我们先来花点时间想想,如果你是一个前端框架(Vue、React或者Angular)的开发者,你是有多么频繁的听到“MVVM”这个词,但你真正明白它的含义吗?
MV*设计模式的起源
起初「计算机科学家(现在的我们是小菜鸡)「在设计GUI(图形用户界面)应用程序的时候,代码是杂乱无章的,通常难以管理和维护。GUI的设计结构一般包括」视图」(View)、「模型」(Model)、「逻辑」(Application Logic、Business Logic以及Sync Logic),例如:
-
用户在 「视图」(View)上的键盘、鼠标等行为执行 「应用逻辑」(Application Logic), 「应用逻辑」会触发 「业务逻辑」(Business Logic),从而变更 「模型」(Model) -
「模型」(Model)变更后需要 「同步逻辑」(Sync Logic)将变化反馈到 「视图」(View)上供用户感知
可以发现在GUI中「视图」和「模型」是天然可以进行分层的,杂乱无章的部分主要是「逻辑」。于是我们的程序员们不断的绞尽脑汁在想办法优化GUI设计的「逻辑」,然后就出现了MVC、MVP以及MVVM等设计模式。
MV*设计模式在B/S架构中的思考
在B/S架构的应用开发中,MV*设计模式概述并封装了应用程序及其环境中需要关注的地方,尽管JavaScript已经变成一门同构语言,但是在浏览器和服务器之间这些关注点可能不一样:
-
视图能否跨案例或场景使用? -
业务逻辑应该放在哪里处理?(在 「Model」中还是 「Controller」中) -
应用的状态应该如何持久化和访问?
MVC(Model-View-Controller)
早在上个世纪70年代,美国的施乐公司(Xerox)的工程师研发了Smalltalk编程语言,并且开始用它编写GUI。而在Smalltalk-80版本的时候,一位叫Trygve Reenskaug的工程师设计了MVC的架构模式,极大地降低了GUI的管理难度。

如图所示,MVC把GUI分成「View」(视图)、「Model」(模型)、「Controller」(控制 器)(可热插拔,主要进行「Model」和「View」之间的协作,包括路由、输入预处理等业务逻辑)三个模块:
-
「View」:检测用户的键盘、鼠标等行为,传递调用 「Controller」执行应用逻辑。 「View」更新需要重新获取 「Model」的数据。 -
「Controller」: 「View」和 「Model」之间协作的应用逻辑或业务逻辑处理。 -
「Model」: 「Model」变更后,通过观察者模式通知 「View」更新视图。
「Model」的更新通过观察者模式,可以实现多视图共享同一个「Model」。
传统的MVC设计对于Web前端开发而言是一种十分有利的模式,因为「View」是持续性的,并且「View」可以对应不同的「Model」。Backbone.js就是一种稍微变种的MVC模式实现(和经典MVC较大的区别在于「View」可以直接操作「Model」,因此这个模式不能同构)。这里总结一下MVC设计模式可能带来的好处以及不够完美的地方:
优点:
-
职责分离:模块化程度高、 「Controller」可替换、可复用性、可扩展性强。 -
多视图更新:使用观察者模式可以做到单 「Model」通知多视图实现数据更新。
缺点:
-
测试困难: 「View」需要UI环境,因此依赖 「View」的 「Controller」测试相对比较困难(现在Web前端的很多测试框架都已经解决了该问题)。 -
依赖强烈: 「View」强依赖 「Model」(特定业务场景),因此 「View」无法组件化设计。
####服务端MVC
经典MVC只用于解决GUI问题,但是随着B/S架构的不断发展,Web服务端也衍生出了MVC设计模式。
JSP Model1和JSP Model2的演变过程
JSP Model1是早期的Java动态Web应用技术,它的结构如下所示:

在Model1中,「JSP」同时包含了「Controller」和「View」,而「JavaBean」包含了「Controller」和「Model」,模块的职责相对混乱。在JSP Model1的基础上,Govind Seshadri借鉴了MVC设计模式提出了JSP Model2模式(具体可查看文章Understanding JavaServer Pages Model 2 architecture),它的结构如下所示:

在JSP Model2中,「Controller」、「View」和「Model」分工明确,「Model」的数据变更,通常通过「JavaBean」修改「View」然后进行前端实时渲染,这样从Web前端发起请求到数据回显路线非常明确。不过这里专门询问了相应的后端开发人员,也可能通过「JavaBean」到「Controller」(「Controller」主要识别当前数据对应的JSP)再到「JSP」,因此在服务端MVC中,也可能产生这样的流程「View」 -> 「Controller」 -> 「Model」 -> 「Controller」 -> 「View」。
在JSP Model2模式中,没有做到前后端分离,前端的开发大大受到了限制。
Model2的衍生

对于Web前端开发而言,最直观的感受就是在Node服务中衍生Model2模式(例如结合Express以及EJS模板引擎等)。
服务端MVC和经典MVC的区别
在服务端的MVC模式设计中采用了HTTP协议通信(HTTP是单工无状态协议),因此「View」在不同的请求中都不保持状态(状态的保持需要额外通过Cookie存储),并且经典MVC中「Model」通过观察者模式告知「View」的环节被破坏(例如难以实现服务端推送)。当然在经典MVC中,「Controller」需要监听「View」并对输入做出反应,逻辑会变得很繁重,而在Model2中, 「Controller」只关注路由处理等,而「Model」则更多的处理业务逻辑。
MVP(Model-View-Presenter)
在上个世纪90年代,IBM旗下的子公司Taligent在用C/C++开发一个叫CommonPoint的图形界面应用系统的时候提出了MVP的概念。

如上图所示,MVP是MVC的模式的一种改良,打破了「View」对于「Model」的依赖,其余的依赖关系和MVC保持不变。
-
「Passive View」: 「View」不再处理同步逻辑,对 「Presenter」提供接口调用。由于不再依赖 「Model」,可以让 「View」从特定的业务场景中抽离,完全可以做到组件化。 -
「Presenter」( 「Supervising Controller」):和经典MVC的 「Controller」相比,任务更加繁重,不仅要处理应用业务逻辑,还要处理同步逻辑(高层次复杂的UI操作)。 -
「Model」: 「Model」变更后,通过观察者模式通知 「Presenter」,如果有视图更新, 「Presenter」又可能调用 「View」的接口更新视图。
MVP模式可能产生的优缺点如下:
-
「Presenter」便于测试、 「View」可组件化设计 -
「Presenter」厚、维护困难
MVVM(Model-View-ViewModel)

如上图所示:MVVM模式是在MVP模式的基础上进行了改良,将「Presenter」改良成「ViewModel」(抽象视图):
-
「ViewModel」:内部集成了 「Binder」(Data-binding Engine,数据绑定引擎),在MVP中派发器 「View」或 「Model」的更新都需要通过 「Presenter」手动设置,而 「Binder」则会实现 「View」和 「Model」的双向绑定,从而实现 「View」或 「Model」的自动更新。 -
「View」:可组件化,例如目前各种流行的UI组件框架, 「View」的变化会通过 「Binder」自动更新相应的 「Model」。 -
「Model」: 「Model」的变化会被 「Binder」监听(仍然是通过观察者模式),一旦监听到变化, 「Binder」就会自动实现视图的更新。
可以发现,MVVM在MVP的基础上带来了大量的好处,例如:
-
提升了可维护性,解决了MVP大量的手动同步的问题,提供双向绑定机制。 -
简化了测试,同步逻辑是交由 「Binder」处理, 「View」跟着 「Model」同时变更,所以只需要保证 「Model」的正确性, 「View」就正确。
当然也带来了一些额外的问题:
-
产生性能问题,对于简单的应用会造成额外的性能消耗。 -
对于复杂的应用,视图状态较多,视图状态的维护成本增加, 「ViewModel」构建和维护成本高。
对前端开发而言MVVM是非常好的一种设计模式。在浏览器中,路由层可以将控制权交由适当的「ViewModel」,后者又可以更新并响应持续的View,并且通过一些小修改MVVM模式可以很好的运行在服务器端,其中的原因就在于「Model」与「View」已经完全没有了依赖关系(通过View与Model的去耦合,可以允许短暂「View」与持续「View」的并存),这允许「View」经由给定的「ViewModel」进行渲染。
目前流行的框架Vue、React以及Angular都是MVVM设计模式的一种实现,并且都可以实现服务端渲染。需要注意目前的Web前端开发和传统Model2需要模板引擎渲染的方式不同,通过Node启动服务进行页面渲染,并且通过代理的方式转发请求后端数据,完全可以从后端的苦海中脱离,这样一来也可以大大的解放Web前端的生产力。
观察者模式和发布/订阅模式
观察者模式
观察者模式是使用一个subject目标对象维持一系列依赖于它的observer观察者对象,将有关状态的任何变更自动通知给这一系列观察者对象。当subject目标对象需要告诉观察者发生了什么事情时,它会向观察者对象们广播一个通知。

如上图所示:一个或多个观察者对目标对象的状态感兴趣时,可以将自己依附在目标对象上以便注册感兴趣的目标对象的状态变化,目标对象的状态发生改变就会发送一个通知消息,调用每个观察者的更新方法。如果观察者对目标对象的状态不感兴趣,也可以将自己从中分离。
发布/订阅模式
发布/订阅模式使用一个事件通道,这个通道介于订阅者和发布者之间,该设计模式允许代码定义应用程序的特定事件,这些事件可以传递自定义参数,自定义参数包含订阅者需要的信息,采用事件通道可以避免发布者和订阅者之间产生依赖关系。

学生时期很长一段时间内用过Redis的发布/订阅机制,具体可查看zigbee-door/zigbee-tcp,但是惭愧的是没有好好阅读过这一块的源码。
两者的区别
观察者模式:允许观察者实例对象(订阅者)执行适当的事件处理程序来注册和接收目标实例对象(发布者)发出的通知(即在观察者实例对象上注册update
方法),使订阅者和发布者之间产生了依赖关系,且没有事件通道。不存在封装约束的单一对象,目标对象和观察者对象必须合作才能维持约束。观察者对象向订阅它们的对象发布其感兴趣的事件。通信只能是单向的。
发布/订阅模式:单一目标通常有很多观察者,有时一个目标的观察者是另一个观察者的目标。通信可以实现双向。该模式存在不稳定性,发布者无法感知订阅者的状态。
Vue的运行机制简述

这里简单的描述一下Vue的运行机制(需要注意分析的是 Runtime + Compiler 的 Vue.js)。
初始化流程
-
创建Vue实例对象 -
init
过程会初始化生命周期,初始化事件中心,初始化渲染、执行beforeCreate
周期函数、初始化data
、props
、computed
、watcher
、执行created
周期函数等。 -
初始化后,调用 $mount
方法对Vue实例进行挂载(挂载的核心过程包括 「模板编译」、 「渲染」以及 「更新」三个过程)。 -
如果没有在Vue实例上定义 render
方法而是定义了template
,那么需要经历编译阶段。需要先将template
字符串编译成render function
,template
字符串编译步骤如下 : -
parse
正则解析template
字符串形成AST(抽象语法树,是源代码的抽象语法结构的树状表现形式) -
optimize
标记静态节点跳过diff算法(diff算法是逐层进行比对,只有同层级的节点进行比对,因此时间的复杂度只有O(n)。如果对于时间复杂度不是很清晰的,可以查看我写的文章ziyi2/algorithms-javascript/渐进记号) -
generate
将AST转化成render function
字符串 -
编译成 render function
后,调用$mount
的mountComponent
方法,先执行beforeMount
钩子函数,然后核心是实例化一个渲染Watcher
,在它的回调函数(初始化的时候执行,以及组件实例中监测到数据发生变化时执行)中调用updateComponent
方法(此方法调用render
方法生成虚拟Node,最终调用update
方法更新DOM)。 -
调用 render
方法将render function
渲染成虚拟的Node(真正的 DOM 元素是非常庞大的,因为浏览器的标准就把 DOM 设计的非常复杂。如果频繁的去做 DOM 更新,会产生一定的性能问题,而 Virtual DOM 就是用一个原生的 JavaScript 对象去描述一个 DOM 节点,所以它比创建一个 DOM 的代价要小很多,而且修改属性也很轻松,还可以做到跨平台兼容),render
方法的第一个参数是createElement
(或者说是h
函数),这个在官方文档也有说明。 -
生成虚拟DOM树后,需要将虚拟DOM树转化成真实的DOM节点,此时需要调用 update
方法,update
方法又会调用pacth
方法把虚拟DOM转换成真正的DOM节点。需要注意在图中忽略了新建真实DOM的情况(如果没有旧的虚拟Node,那么可以直接通过createElm
创建真实DOM节点),这里重点分析在已有虚拟Node的情况下,会通过sameVnode
判断当前需要更新的Node节点是否和旧的Node节点相同(例如我们设置的key
属性发生了变化,那么节点显然不同),如果节点不同那么将旧节点采用新节点替换即可,如果相同且存在子节点,需要调用patchVNode
方法执行diff算法更新DOM,从而提升DOM操作的性能。
需要注意在初始化阶段,没有详细描述数据的响应式过程,这个在响应式流程里做说明。
响应式流程
-
在 init
的时候会利用Object.defineProperty
方法(不兼容IE8)监听Vue实例的响应式数据的变化从而实现数据劫持能力(利用了JavaScript对象的访问器属性get
和set
,在未来的Vue3中会使用ES6的Proxy
来优化响应式原理)。在初始化流程中的编译阶段,当render function
被渲染的时候,会读取Vue实例中和视图相关的响应式数据,此时会触发getter
函数进行 「依赖收集」(将观察者Watcher
对象存放到当前闭包的订阅者Dep
的subs
中),此时的数据劫持功能和观察者模式就实现了一个MVVM模式中的 「Binder」,之后就是正常的渲染和更新流程。 -
当数据发生变化或者视图导致的数据发生了变化时,会触发数据劫持的 setter
函数,setter
会通知初始化 「依赖收集」中的Dep
中的和视图相应的Watcher
,告知需要重新渲染视图,Wather
就会再次通过update
方法来更新视图。
可以发现只要视图中添加监听事件,自动变更对应的数据变化时,就可以实现数据和视图的双向绑定了。
了解了MV*设计模式、观察者模式以及Vue运行机制之后,可能对于整个MVVM模式有了一个感性的认知,因此可以来手动实现一下,这里实现过程包括如下几个步骤:
-
MVVM的实现演示 -
MVVM的流程设计 -
中介者模式的实现 -
数据劫持的实现 -
数据双向绑定的实现 -
简易视图指令的编译过程的实现 -
ViewModel的实现 -
MVVM的实现
MVVM的实现演示
MVVM示例的使用如下所示,包括browser.js
(View视图的更新)、mediator.js
(中介者)、binder.js
(MVVM的数据绑定引擎)、view.js
(视图)、hijack.js
(数据劫持)以及mvvm.js
(MVVM实例)。本示例相关的代码可查看github的ziyi2/mvvm:
"app">
<input type="text" b-value="input.message" b-on-input="handlerInput">
<div>{{ input.message }}div>
<div b-text="text">div>
<div>{{ text }}div>
<div b-html="htmlMessage">div>
div>
<script src="./browser.js">script>
<script src="./mediator.js">script>
<script src="./binder.js">script>
<script src="./view.js">script>
<script src="./hijack.js">script>
<script src="./mvvm.js">script>
<script>
let vm = new Mvvm({
el: '#app',
data: {
input: {
message: 'Hello Input!'
},
text: 'ziyi2',
htmlMessage: ``
},
methods: {
handlerInput(e) {
this.text = e.target.value
}
}
})
script>