Vue之MVVM

128 阅读9分钟

资料

  1. 50行代码的MVVM,感受闭包的艺术
  2. 不好意思!耽误你的十分钟,让MVVM原理还给你
  3. 基于Vue实现一个简易MVVM

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)

image.png 如上图所示: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目标对象需要告诉观察者发生了什么事情时,它会向观察者对象们广播一个通知, image.png 如上图所示,一个活多个观察者对目标对象的状态感兴趣时,可以将自己依附在目标对象上以便注册感兴趣的目标对象的状态变化,目标对象的状态发生改变就会发送一个通知消息,调用每个观察者的更新方法。如果观察者对目标对象的状态不感兴趣,也可以将自己从中分离。

发布/订阅模式

发布/订阅模式使用一个事件通道,在这个通道介于订阅者和发布者之间,该设计模式允许代码定义应用程序的特定事件,这些事件可以传递自定义参数,自定义参数包含订阅者需要的信息,采用事件通道可以避免发布者和订阅者之间产生依赖关系。 image.png

两者的区别

观察者模式:允许观察者实例对象(订阅者)执行适当的事件处理程序来注册和接收目标实例对象(发布者)发出的通知(即在观察者实例对象上注册update方法),使订阅者和发布者之间产生了依赖关系,且没有事件通道。不存在封装约束的单一对象,目标对象和观察者对象必须合作才能维持约束。观察者对象向订阅它们的对象发布其感兴趣的时间,通信只是单向的。
发布/订阅模式:单一目标通常有很多观察者,有时一个目标的观察者是另一个观察者的目标。通信可以实现双向。该模式存在不稳定性,发布者无法感知订阅者的状态。

Vue的运行机制简述

初始化过程

  • 创建Vue实例对象
  • init过程会初始化生命周期,初始化事件中心,初始化渲染、执行beforeCreate周期函数、初始化datapropscomputedwatcher、执行created周期函数等。
  • 初始化后,调用$mount方法对Vue实例进行挂载(挂载的核心过程包括模版编译、渲染以及更新三个过程)。
  • 如果没有在实例上定义render方法而是定义了template,那么需要经历编译过程,需要先将template字符串编译成render functiontemplate字符串编译步骤如下:
    • parse正则解析template字符串形成AST(抽象语法树,是源代码的抽象语法结构的树状表现形式)
    • optimize标记静态节点跳过diff算法(diff算法时逐层进行对比,只有同层级的节点进行比对,因此事件复杂度只有O(n))
    • generate将AST转化成render function
  • 编译成render function后,调用$mountmountComponent方法,先执行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对象的访问器getset,在Vue3中使用了ES6的Proxy)。在初始化流程中的编译阶段,当render function 被渲染的时候,会读取Vue实例中和视图相关的响应式数据,此时触发getter函数进行依赖收集(将观察者Watcher对象存放到当前闭包的订阅者Depsubs中),此时的数据劫持功能和观察者模式就实现了一个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,完成响应式更新