对类Vue的MVVM前端库的实现代码

MVVM

(ModelView viewmodel)是一种基于MVC的设计,开发人员在HTML上写一些Bindings,利用一些指令绑定,就能在Model和viewmodel保持不变的情况下,很方便的将UI设计与业务逻辑分离,从而大大的减少繁琐的DOM操作。

关于实现MVVM,网上实在是太多了,本文为个人总结,结合源码以及一些别人的实现

关于双向绑定

•vue 数据劫持 + 订阅 - 发布 •ng 脏值检查 •backbone.js 订阅-发布(这个没有使用过,并不是主流的用法)

双向绑定,从最基本的实现来说,就是在defineProperty绑定的基础上在绑定input事件,达到v-model的功能

代码思路图

两个版本:

•简单版本: 非常简单,但是因为是es6,并且代码极度简化,所以不谈功能,思路还是很清晰的 •标准版本: 参照了Vue的部分源码,代码功能高度向上抽取,阅读稍微有点困难,实现了基本的功能包括计算属性,watch,核心功能都实现没问题,但是不支持数组

简单版本

简单版本的地址: 简单版本

​ 这个MVVM也许代码逻辑上面实现的并不完美,并不是正统的MVVM, 但是代码很精简,相对于源码,要好理解很多,并且实现了v-model以及v-on methods的功能代码非常少,就100多行

{ // 键盘事件触发 this[value] = e.target.value }) this.target = new Watcher(node, 'input') // 储存到订阅者 this[value] // get一下,将 this.target 给调度员 } if (attr.name == "@click") { let value = attr.value // 获取点击事件名 node.addEventListener('click', this.methods[value].bind(this) ) } } } if (node.nodeType === 3) { // 文本节点返回3 let reg = /\{\{(.*)\}\}/; //匹配 {{ }} let match = node.nodeValue.match(reg) if (match) { // 匹配都就获取{{}}里面的变量 const value = match[1].trim() this.target = new Watcher(node, 'text') this[value] = this[value] // get set更新一下数据 } } } } } //调度员 > 调度订阅发布 class dispatcher { constructor() { this.watchers = [] } add(watcher) { this.watchers.push(watcher) // 将指令解析器解析的数据节点的订阅者存储进来,便于订阅 } notify(newValue) { this.watchers.map(watcher => watcher.update(newValue)) // 有数据发生,也就是触发set事件,notify事件就会将新的data交给订阅者,订阅者负责更新 } } //订阅发布者 MVVM核心 class Watcher { constructor(node, type) { this.node = node this.type = type } update(value) { if (this.type === 'input') { this.node.value = value // 更新的数据通过订阅者发布到dom } if (this.type === 'text') { this.node.nodeValue = value } } } <Meta charset="UTF-8"> <Meta name="viewport" content="width=device-width, initial-scale=1.0"> <Meta http-equiv="X-UA-Compatible" content="ie=edge"> MVVM

这个版本的MVVM因为代码比较少,并且是ES6的原因,思路非常清晰

我们来看看从new MVVM开始,他都做了什么

解读简单版本

new MVVM

首先,通过解构获取所有的new MVVM传进来的对象

rush:js;"> class MVVM { constructor(options) { const { el, data, methods } = options this.methods = methods // 提取methods,便于后面将this给methods this.target = null // 后面有用 this.observer(this, data) this.instruction(document.getElementById(el)) // 获取挂载点 }

属性劫持

开始执行this.observer observer是一个数据监听器,将data的数据全部拦截下来

rush:js;"> observer(root, data) { for (const key in data) { this.deFinition(root, key, data[key]) } }

在this.deFinition里面把data数据都劫持到this上面

rush:js;"> deFinition(root, key, value) { if (typeof value === 'object') { // 假如value是对象则接着递归 return this.observer(value, value) } let dispatcher = new dispatcher() // 调度员

Object.defineProperty(root, key, {
set(newValue) {
value = newValue
dispatcher.notify(newValue)
},
get() {
dispatcher.add(this.target)
return value
}
})
}

此时data的数据变化我们已经可以监听到了,但是我们监听到后还要与页面进行实时相应,所以这里我们使用调度员,在页面初始化的时候get(),这样this.target,也就是后面的指令解析器解析出来的v-model这样的指令储存到调度员里面,主要请看后面的解析器的代码

指令解析器

指令解析器通过执行 this.instruction(document.getElementById(el)) 获取挂载点

{ // 键盘事件触发 this[value] = e.target.value }) this.target = new Watcher(node, 'input') // 储存到订阅者 this[value] // get一下,将 this.target 给调度员 } if (attr.name == "@click") { let value = attr.value // 获取点击事件名 node.addEventListener('click', this.methods[value].bind(this) ) } } } if (node.nodeType === 3) { // 文本节点返回3 let reg = /\{\{(.*)\}\}/; //匹配 {{ }} let match = node.nodeValue.match(reg) if (match) { // 匹配都就获取{{}}里面的变量 const value = match[1].trim() this.target = new Watcher(node, 'text') this[value] = this[value] // get set更新一下数据 } } } }

这里代码首先解析出来我们自定义属性然后,我们将@click的事件直接指向methods,methds就已经实现了

现在代码模型是这样

调度员dispatcher与订阅者Watcher

我们需要将dispatcher和Watcher联系起来

于是我们之前创建的变量this.target开始发挥他的作用了

正执行解析器里面使用this.target将node节点,以及触发关键词存储到当前的watcher 订阅,然后我们获取一下数据

rush:js;"> this.target = new Watcher(node, 'input') // 储存到订阅者 this[value] // get一下,将 this.target 给调度员

在执行this[value]的时候,触发了get事件

rush:js;"> get() { dispatcher.add(this.target) return value }

这get事件里面,我们将watcher订阅者告知到调度员,调度员将订阅事件存储起来

调度订阅发布 class dispatcher { constructor() { this.watchers = [] } add(watcher) { this.watchers.push(watcher) // 将指令解析器解析的数据节点的订阅者存储进来,便于订阅 } notify(newValue) { this.watchers.map(watcher => watcher.update(newValue)) // 有数据发生,也就是触发set事件,notify事件就会将新的data交给订阅者,订阅者负责更新 } }

与input不太一样的是文本节点不仅需要获取,还需要set一下,因为要让订阅者更新node节点

rush:js;"> this.target = new Watcher(node, 'text') this[value] = this[value] // get set更新一下数据

所以在订阅者就添加了该事件,然后执行set

rush:js;"> set(newValue) { value = newValue dispatcher.notify(newValue) },

notfiy执行,订阅发布者执行update更新node节点信息

rush:js;"> class Watcher { constructor(node, type) { this.node = node this.type = type } update(value) { if (this.type === 'input') { this.node.value = value // 更新的数据通过订阅者发布到dom } if (this.type === 'text') { this.node.nodeValue = value } } }

页面初始化完毕

更新数据

{ // 键盘事件触发 this[value] = e.target.value })

this[value]也就是data数据发生变化,触发set事件,既然触发notfiy事件,notfiy遍历所有节点,在遍历的节点里面根据页面初始化的时候订阅的触发类型.进行页面的刷新

现在可以完成的看看new MVVM的实现过程了

最简单版本的MVVM完成

标准版本

标准版本额外实现了component,watch,因为模块化代码很碎的关系,看起来还是有难度的

从理念上来说,实现的思想基本是一样的,可以参照上面的图示,都是开始的时候都是拦截属性,解析指令

代码有将近300行,所以就贴一个地址标准版本MVVM

执行顺序

1.new MVVM 2.获取$options = 所以参数 3.获取data,便于后面劫持 4.因为是es5,后面forEach内部指向window,这不是我们想要的,所以存储当前this 为me 5._proxyData劫持所有data数据 6.初始化计算属性 7.通过Object.key()获取计算属性属性名 8.初始化计算属性将计算属性挂载到vm上 9.开始observer监听数据 10.判断data是否存在 11.存在就new Observer(创建监听器) 12.数据全部进行进行defineProperty存取监听处理,让后面的数据变动都触发这个的get/set 13.开始获取挂载点 14.使用querySelector对象解析el 15.创建一个虚拟节点,并存储当前的dom 16.解析虚拟dom 17.使用childNodes解析对象 18.因为是es5,所以使用[].slice.call将对象转数组 19.获取到后进行 {{ }}匹配 指令的匹配 以及递归子节点 20.指令的匹配: 匹配到指令因为不知道多少个指令名称,所以这里还是使用[].slice.call循环遍历 21.解析到有 v-的指令使用substring(2)截取后面的属性名称 22.再判断是不是指令v-on 这里就是匹配on关键字,匹配到了就是事件指令,匹配不到就是普通指令 23.普通指令解析{{ data }} _getVMValget会触发MVVM的_proxyData事件 在_proxyData事件里面触发data的get事件 24.这时候到了observer的defineReactive的get里面获取到了数据,因为没有dispatcher.target,所以不进行会触发调度员 25.至此_getVMVal获取到了数据 26.modelUpdater进行Dom上面的数据更新 27.数据开始进行订阅,在订阅里面留一个回调函数用于更新dom 28.在watcher(订阅者)获取this,订阅属性,回调 29.在this.getter这个属性上面返回一个匿名函数,用于获取data的值 30.触发get事件,将当前watcher的this存储到dispatcher.garget上面 31.给this.getters,callvm的的this,执行匿名函数获取劫持下来的data,又触发了MVVM的_proxyData的get事件,继而有触发了observer的defineReactive的get事件,不过这一次dispatcher.target有值,执行了depend事件 32.在depend里面执行了自己的addDep事件,并且将Observer自己的this传进去 33.addDep里面执行了dispatcher的addSub事件, 34.在addUsb事件里面将订阅存储到dispatcher里面的this.watchers里面的 35.订阅完成,后面将这些自定义的指令进行移除 36.重复操作,解析所有指令,v-on:click = "data"直接执行methods[data].bind(vm)

更新数据:

1.触发input事件 2.触发_setVMVal事件 3.触发MVVM的set事件 4.触发observer的set事件 5.触发dep.notify() 6.触发watcher的run方法 7.触发new Watcher的回调 this.cb 8.触发compile里面的updaterFn 事件 9.更新视图

component的实现

计算属性的触发 查看这个例子

rush:js;"> computed: { getHelloWord: function () { return this.someStr + this.child.someStr; } },

其实计算属性就是defineproperty的一个延伸

1.首先compile里面解析获取到{{ getHelloword }}' 2.执行updater[textUpdater] 3.执行_getVMVal获取计算属性的返回值 4.获取vm[component]就会执行下面的get事件

rush:js;"> Object.defineProperty(me, key, { get: typeof computed[key] === 'function' ? computed[key] : computed[key].get, set: function () {} })

是function执行computed[getHelloword],也就是return 的 函数

this.someStr + this.child.someStr;

1.依次获取data,触发mvvm的get 以及observer的get,

初始化完成,到这里还没有绑定数据,仅仅是初始化完成了

1.开始订阅该事件 new Watcher() 2.component不是函数所以不是function 执行this.parseGetter(expOrFn); 3.返回一个覆盖expOrrn的匿名函数 4.开始初始化 执行get() 5.存储当前this,开始获取vm[getHelloword] 6.触发component[getHelloword] 7.开始执行MVVM的get this.someStr 8.到MVVM的get 到 observer的get 因为 dispatcher.target存着 getHelloWord 的 this.depend ()所以执行 9.dispatcher的depend(),执行watcher的addDep(),执行 dispatcher的addSub() 将当前的watcher存储到监听器 10.开始get第二个数据 this.child.someStr,同理也将getHelloWord的this存入了当前的dispatcher 11.开始get第三个数据 this.child,同理也将getHelloWord的this存入了当前的dispatcher

这个执行顺序有点迷,第二第三方反来了

this.parseGetter(expOrFn);就执行完毕了

目前来看为什么component会实时属性数据?

因为component的依赖属性一旦发生变化都会更新 getHelloword 的 watcher ,随之执行回调更新dom

watch的实现

watch的实现相对来说要简单很多

1.我们只要将watch监听的数据告诉订阅者就可以了 2.这样,wacth更新了 3.触发set,set触发notify 4.notify更新watcher 5.watcher执行run 6.run方法去执行watch的回调 7.即完成了watch的监听

rush:js;"> watch: function (key, cb) { new Watcher(this, key, cb) },

相关文章

可以通过min-width属性来设置el-table-column的最小宽度。以...
yarn dev,当文件变动后,会自动重启。 yanr start不会自动重...
ref 用于创建一个对值的响应式引用。这个值可以是原始值(如...
通过修改 getWK005 函数来实现这一点。这里的 query 参数就是...
&lt;el-form-item label=&quot;入库类型&quot; ...
API 变动 样式类名变化: 一些组件的样式类名有所变动,可能需...