资料
MV*设计模式演变历史
MV*设计模式的起源
起初计算机科学家在设计GUI(图形用户界面)应用程序的时候,代码是杂乱无章的,通常难以管理和维护。GUI的设计结构一般包括视图(View)、模型(Model)、逻辑(Application Logic、Business Login以及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)
MVC把GUI分成了View(视图)、Model(模型)、Controller(控制器)(可热插拔,主要进行Model和View之间的协作,包括路由、输入处理等业务逻辑)三个模块:
- View:检测用户的键盘、鼠标等行为,传递调用Controller执行应用逻辑。View更新需要重新获取Model的数据。
- Controller:View和Model之间协作的应用逻辑或业务逻辑处理
- Model:Model变更后,通过观察者模式通知View更新试图。
Model的更新通过观察着模式,可以实现多视图共享同一个Model。
传统的MVC设计对于Web前端开发者而言是一种十分有利的模式,因为View是持续性的,并且View可以对应不同的Model。
优点:
- 职责分离:模块化程度高、Controller可代替、可复用性、可扩展性强
- 多视图更新:使用观察者模式可以做到单Model通知多视图实现数据更新 缺点:
- 测试困难:View需要UI环境,因此依赖View的Controller测试相对困难(现在Web前端的很多测试框架已经解决了该问题)。
- 依赖强烈:View强依赖Model(特定业务场景),因此View无法组件化设计。
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就会自动更新视图的更新。
观察者模式和发布/订阅模式
观察者模式
观察者模式是使用一个subject目标维持一系列依赖于它的observer观察者对象,将有关状态的任何变更自动通知给这一系列观察者对象,当subject目标对象需要告诉观察者发生了什么事情时,它会向观察者对象们广播一个通知,
如上图所示,一个活多个观察者对目标对象的状态感兴趣时,可以将自己依附在目标对象上以便注册感兴趣的目标对象的状态变化,目标对象的状态发生改变就会发送一个通知消息,调用每个观察者的更新方法。如果观察者对目标对象的状态不感兴趣,也可以将自己从中分离。
发布/订阅模式
发布/订阅模式使用一个事件通道,在这个通道介于订阅者和发布者之间,该设计模式允许代码定义应用程序的特定事件,这些事件可以传递自定义参数,自定义参数包含订阅者需要的信息,采用事件通道可以避免发布者和订阅者之间产生依赖关系。
两者的区别
观察者模式:允许观察者实例对象(订阅者)执行适当的事件处理程序来注册和接收目标实例对象(发布者)发出的通知(即在观察者实例对象上注册update
方法),使订阅者和发布者之间产生了依赖关系,且没有事件通道。不存在封装约束的单一对象,目标对象和观察者对象必须合作才能维持约束。观察者对象向订阅它们的对象发布其感兴趣的时间,通信只是单向的。
发布/订阅模式:单一目标通常有很多观察者,有时一个目标的观察者是另一个观察者的目标。通信可以实现双向。该模式存在不稳定性,发布者无法感知订阅者的状态。
Vue的运行机制简述
初始化过程
- 创建Vue实例对象
init
过程会初始化生命周期,初始化事件中心,初始化渲染、执行beforeCreate
周期函数、初始化data
、props
、computed
、watcher
、执行created
周期函数等。- 初始化后,调用
$mount
方法对Vue实例进行挂载(挂载的核心过程包括模版编译、渲染以及更新三个过程)。 - 如果没有在实例上定义
render
方法而是定义了template
,那么需要经历编译过程,需要先将template
字符串编译成render function
,template
字符串编译步骤如下:parse
正则解析template
字符串形成AST(抽象语法树,是源代码的抽象语法结构的树状表现形式)optimize
标记静态节点跳过diff算法(diff算法时逐层进行对比,只有同层级的节点进行比对,因此事件复杂度只有O(n))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
方法又会调用patch
方法把虚拟DOM转换成真正的DOM节点。如果没有旧的虚拟Node,那么可以直接通过createElement
创建真实的DOM节点。这里重点分析在已有的虚拟Node节点是否和旧的Node节点相同(例如我们设置的key属性发生了变化,那么显然不同),如果节点不同那么旧节点采用新节点替换即可,如果相同且存在子节点,需要调用patchNode
方法执行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
,告知需要重新渲染视图,Watcher
就会再次通过update
方法来更新视图
Vue实现MVVM的原理
数据劫持 + 发布订阅模式
vue3之前使用的es5提供的Object.defineProperty
,vue3使用的是Proxy
为什么要做数据劫持?
- 观察对象,给对象增加
Object.defineProperty
- vue特点是不能新增不存在的属性,不存在的属性没有get和set
- 深度响应,因为每次赋予一个新对象时会给这个新对象增加defineProperty(数据劫持)
简单的MVVM的源码
演示案例的完整代码:github地址
class Vue {
constructor(opt) {
this.opt = opt
this.observe(opt.data)
let root = document.querySelector(opt.el)
this.compile(root)
}
// 为响应式对象data里的每一个key绑定一额观察者对象
observe(data) {
Object.keys(data).forEach(key => {
let obv = new Observer()
data['_' + key] = data[key]
Object.defineProperty(data, key, {
get() {
Observer.target && obv.addSubNode(Observer.target);
return data['_' + key]
},
set(newVal) {
obv.update(newVal)
data['_' + key] = newVal
}
})
})
}
complile(node) {
[].forEach.call(node.childNodes, child => {
if(!child.firstElementChild && /\{\{(.*)\}\}/.test(child.innerHtml)) {
let key = RegExp.$1.trim()
child.innerHTML = child.innerHTML.replace(new RegExp('\\{\\{\\s*'+ key +'\\s*\\}\\}', 'gm'), this.opt.data[key])
Observer.target = child
this.opt.data[key]
Observer.target = null
}
else if (child.firstElementChild) {
this.compile(child)
}
})
}
}
class Observer {
constructor() {
this.subNode = []
}
addSubNode(node) {
this.subNode.push(node)
}
update(newVal) {
this.subNode.forEach(node => {
node.innerHtml = newVal
})
}
}
- observe 函数:首先我们会对需要响应时的
data
对象进行for循环遍历,为data
的每一个key
映射一个观察者对象- 在ES6中,for循环每次执行,都可以形成闭包,因此这个观察者对象就存放在闭包中
- 闭包形成的本质是内层作用域中堆地址暴露,这里巧妙的用getter/setter函数暴露了for循环里的观察者
- compile函数:从根节点向下遍历DOM,遇到mustache形式的文本,则映射成data.key对应的值,同时记录到观察者中。
- 当遍历到{{XXXX}}形式的文本,我们正则匹配出其中的变量,将它替换成data中的值
- 为了满足后续响应式的更新,将该节点存储在key对应的观察着对象中,我们用getter函数巧妙的操作了闭包。
- 在页面初次渲染之后,后续的
eventLoop
中,如果修改了key
的值,实际会通过setter触发观察者的update,完成响应式更新