对类Vue的MVVM前端库的实现
关于实现mvvm,网上实在是太多了,本文为个人总结,结合源码以及一些别人的实现
关于双向绑定
- vue 数据劫持 + 订阅 - 发布
- ng 脏值检查
- backbone.js 订阅-发布(这个没有使用过,并不是主流的用法)
双向绑定,从最基本的实现来说,就是在defineproperty绑定的基础上在绑定input事件,达到v-model的功能
代码思路图
两个版本:
- 简单版本: 非常简单,但是因为是es6,并且代码极度简化,所以不谈功能,思路还是很清晰的
- 标准版本: 参照了vue的部分源码,代码的功能高度向上抽取,阅读稍微有点困难,实现了基本的功能,包括计算属性,watch,核心功能都实现没问题,但是不支持数组
简单版本
简单版本的地址: 简单版本
这个mvvm也许代码逻辑上面实现的并不完美,并不是正统的mvvm, 但是代码很精简,相对于源码,要好理解很多,并且实现了v-model以及v-on methods的功能,代码非常少,就100多行
class mvvm { constructor(options) { const { el, data, methods } = options this.methods = methods this.target = null this.observer(this, data) this.instruction(document.getelementbyid(el)) // 获取挂载点 } // 数据监听器 拦截所有data数据 传给defineproperty用于数据劫持 observer(root, data) { for (const key in data) { this.definition(root, key, data[key]) } } // 将拦截的数据绑定到this上面 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 } }) } //指令解析器 instruction(dom) { const nodes = dom.childnodes; // 返回节点的子节点集合 // console.log(nodes); //查看节点属性 for (const node of nodes) { // 与for in相反 for of 获取迭代的value值 if (node.nodetype === 1) { // 元素节点返回1 const attrs = node.attributes //获取属性 for (const attr of attrs) { if (attr.name === 'v-model') { let value = attr.value //获取v-model的值 node.addeventlistener('input', e => { // 键盘事件触发 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 } } }
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="x-ua-compatible" content="ie=edge"> <title>mvvm</title> </head> <body> <div id="app"> <input type="text" v-model="text">{{ text }} <br> <button @click="update">重置</button> </div> <script src="./index.js"></script> <script> let mvvm = new mvvm({ el: 'app', data: { text: 'hello mvvm' }, methods: { update() { this.text = '' } } }) </script> </body> </html>
这个版本的mvvm因为代码比较少,并且是es6的原因,思路非常清晰
我们来看看从new mvvm开始,他都做了什么
解读简单版本
new mvvm
首先,通过解构获取所有的new mvvm传进来的对象
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的数据全部拦截下来
observer(root, data) { for (const key in data) { this.definition(root, key, data[key]) } }
在this.definition里面把data数据都劫持到this上面
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))
获取挂载点
instruction(dom) { const nodes = dom.childnodes; // 返回节点的子节点集合 // console.log(nodes); //查看节点属性 for (const node of nodes) { // 与for in相反 for of 获取迭代的value值 if (node.nodetype === 1) { // 元素节点返回1 const attrs = node.attributes //获取属性 for (const attr of attrs) { if (attr.name === 'v-model') { let value = attr.value //获取v-model的值 node.addeventlistener('input', e => { // 键盘事件触发 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 订阅,然后我们获取一下数据
this.target = new watcher(node, 'input') // 储存到订阅者 this[value] // get一下,将 this.target 给调度员
在执行this[value]的时候,触发了get事件
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节点
this.target = new watcher(node, 'text') this[value] = this[value] // get set更新一下数据
所以在订阅者就添加了该事件,然后执行set
set(newvalue) { value = newvalue dispatcher.notify(newvalue) },
notfiy执行,订阅发布者执行update更新node节点信息
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 } } }
页面初始化完毕
更新数据
node.addeventlistener('input', e => { // 键盘事件触发 this[value] = e.target.value })
this[value]也就是data数据发生变化,触发set事件,既然触发notfiy事件,notfiy遍历所有节点,在遍历的节点里面根据页面初始化的时候订阅的触发类型.进行页面的刷新
现在可以完成的看看new mvvm的实现过程了
最简单版本的mvvm完成
标准版本
标准版本额外实现了component,watch,因为模块化代码很碎的关系,看起来还是有难度的
从理念上来说,实现的思想基本是一样的,可以参照上面的图示,都是开始的时候都是拦截属性,解析指令
代码有将近300行,所以就贴一个地址标准版本mvvm
执行顺序
- new mvvm
- 获取$options = 所以参数
- 获取data,便于后面劫持
- 因为是es5,后面foreach内部指向window,这不是我们想要的,所以存储当前this 为me
- _proxydata劫持所有data数据
- 初始化计算属性
- 通过object.key()获取计算属性的属性名
- 初始化计算属性将计算属性挂载到vm上
- 开始observer监听数据
- 判断data是否存在
- 存在就new observer(创建监听器)
- 数据全部进行进行defineproperty存取监听处理,让后面的数据变动都触发这个的get/set
- 开始获取挂载点
- 使用queryselector对象解析el
- 创建一个虚拟节点,并存储当前的dom
- 解析虚拟dom
- 使用
childnodes
解析对象 - 因为是es5,所以使用
[].slice.call
将对象转数组 - 获取到后进行
{{ }}匹配
指令的匹配
以及递归子节点
- 指令的匹配: 匹配到指令因为不知道多少个指令名称,所以这里还是使用
[].slice.call
循环遍历 - 解析到有
v-
的指令使用substring(2)
截取后面的属性名称 - 再判断是不是指令
v-on
这里就是匹配on
关键字,匹配到了就是事件指令,匹配不到就是普通指令 - 普通指令解析{{ data }}
_getvmval
get会触发mvvm的_proxydata事件 在_proxydata事件里面触发data的get事件 - 这时候到了observer的definereactive的get里面获取到了数据,因为没有dispatcher.target,所以不进行会触发调度员
- 至此
_getvmval
获取到了数据 -
modelupdater
进行dom上面的数据更新 - 数据开始进行订阅,在订阅里面留一个回调函数用于更新dom
- 在watcher(订阅者)获取
this
,订阅的属性
,回调
- 在this.getter这个属性上面返回一个匿名函数,用于获取data的值
- 触发get事件,将当前watcher的this存储到dispatcher.garget上面
- 给this.getters,callvm的的this,执行匿名函数,获取劫持下来的data,又触发了mvvm的_proxydata的get事件,继而有触发了observer的definereactive的get事件,不过这一次dispatcher.target有值,执行了depend事件
- 在
depend
里面执行了自己的adddep事件,并且将observer自己的this传进去 -
adddep
里面执行了dispatcher
的addsub
事件, - 在
addusb
事件里面将订阅存储到dispatcher
里面的this.watchers
里面的 - 订阅完成,后面将这些自定义的指令进行移除
- 重复操作,解析所有指令,v-on:click = "data"直接执行methods[data].bind(vm)
更新数据:
- 触发input事件
- 触发_setvmval事件
- 触发mvvm的set事件
- 触发observer的set事件
- 触发dep.notify()
- 触发watcher的run方法
- 触发new watcher的回调 this.cb
- 触发compile里面的updaterfn 事件
- 更新视图
component的实现
计算属性的触发 查看这个例子
computed: { gethelloword: function () { return this.somestr + this.child.somestr; } },
其实计算属性就是defineproperty的一个延伸
- 首先compile里面解析获取到{{ gethelloword }}'
- 执行updater[textupdater]
- 执行
_getvmval
获取计算属性的返回值 - 获取
vm[component]
就会执行下面的get事件
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;
- 依次获取data,触发mvvm的get 以及observer的get,
初始化完成,到这里还没有绑定数据,仅仅是初始化完成了
- 开始订阅该事件
new watcher()
- component不是函数所以不是function 执行
this.parsegetter(exporfn);
- 返回一个覆盖exporrn的匿名函数
- 开始初始化 执行get()
- 存储当前this,开始获取
vm[gethelloword]
- 触发
component[gethelloword]
- 开始执行mvvm的get
this.somestr
- 到mvvm的get 到 observer的get 因为
dispatcher.target
存着 gethelloword 的this.depend ()
所以执行 - dispatcher的
depend()
,执行watcher的adddep(),执行 dispatcher的addsub()
将当前的watcher存储到监听器 - 开始get第二个数据 this.child.somestr,同理也将gethelloword的this存入了当前的dispatcher
- 开始get第三个数据 this.child,同理也将gethelloword的this存入了当前的dispatcher
这个执行顺序有点迷,第二第三方反来了
this.parsegetter(exporfn);
就执行完毕了
目前来看为什么component会实时属性数据?
因为component的依赖属性一旦发生变化都会更新 gethelloword 的 watcher ,随之执行回调更新dom
watch的实现
watch的实现相对来说要简单很多
- 我们只要将watch监听的数据告诉订阅者就可以了
- 这样,wacth更新了
- 触发set,set触发notify
- notify更新watcher
- watcher执行run
- run方法去执行watch的回调
- 即完成了watch的监听
watch: function (key, cb) { new watcher(this, key, cb) },
上一篇: 前端静态资源请求和加载优化总结
下一篇: 为什么谷歌要执行严格的代码编写规范