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


Posted in Javascript onSeptember 07, 2018

MVVM

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

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

关于双向绑定

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

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

代码思路图

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

两个版本:

•简单版本: 非常简单,但是因为是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就已经实现了

现在代码模型是这样

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

调度员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

执行顺序

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的实现

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

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

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

1.首先compile里面解析获取到{{ getHelloword }}'
2.执行updater[textUpdater]
3.执行_getVMVal获取计算属性的返回值
4.获取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;

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的监听

watch: function (key, cb) {
 new Watcher(this, key, cb)
},

Javascript 相关文章推荐
JS trim去空格的最佳实践
Oct 30 Javascript
jQuery Mobile页面跳转后未加载外部JS原因分析及解决
Mar 18 Javascript
打造个性化的功能强大的Jquery虚拟键盘(VirtualKeyboard)
Oct 11 Javascript
jQuery源码分析之jQuery.fn.each与jQuery.each用法
Jan 23 Javascript
jquery easyui datagrid实现增加,修改,删除方法总结
May 25 Javascript
详解jQuery中关于Ajax的几个常用的函数
Jul 17 jQuery
ES6中的rest参数与扩展运算符详解
Jul 18 Javascript
JavaScript实现随机点名器实例详解
May 07 Javascript
javascript使用substring实现的展开与收缩文字功能示例
Jun 17 Javascript
解决ele ui 表格表头太长问题的实现
Nov 13 Javascript
JS实现移动端可折叠导航菜单(现代都市风)
Jul 07 Javascript
解决vue cli4升级sass-loader(v8)后报错问题
Jul 30 Javascript
cnpm加速Angular项目创建的方法
Sep 07 #Javascript
vue.js 实现点击按钮动态添加li的方法
Sep 07 #Javascript
vue 点击按钮增加一行的方法
Sep 07 #Javascript
详解使用jest对vue项目进行单元测试
Sep 07 #Javascript
Vue 实现列表动态添加和删除的两种方法小结
Sep 07 #Javascript
koa-router源码学习小结
Sep 07 #Javascript
Vue.js实现表格渲染的方法
Sep 07 #Javascript
You might like
php中执行系统命令的方法
2015/03/21 PHP
解决更换PHP5.4以上版本后Dedecms后台登录空白问题的方法
2015/10/23 PHP
php实现倒计时效果
2015/12/19 PHP
php结合ajax实现手机发红包的案例
2016/10/13 PHP
Laravel框架中Blade模板的用法示例
2017/08/30 PHP
PHP封装的非对称加密RSA算法示例
2018/05/28 PHP
Nigma vs Alliance BO5 第五场2.14
2021/03/10 DOTA
javascript中有趣的反柯里化深入分析
2012/12/05 Javascript
jquery实现弹出层登录和全屏层注册特效
2015/08/28 Javascript
JS Array.slice 截取数组的实现方法
2016/01/02 Javascript
Google 地图叠加层实例讲解
2016/08/06 Javascript
基于JavaScript实现右键菜单和拖拽功能
2016/11/28 Javascript
AngularJS页面带参跳转及参数解析操作示例
2017/06/28 Javascript
微信小程序switch开关选择器使用详解
2018/01/31 Javascript
JavaScript数组去重算法实例小结
2018/05/07 Javascript
js计算两个日期间的天数月的实例代码
2018/09/20 Javascript
ES6知识点整理之模块化的应用详解
2019/04/15 Javascript
js console.log打印对象时属性缺失的解决方法
2019/05/23 Javascript
Vue中登录验证成功后保存token,并每次请求携带并验证token操作
2020/09/08 Javascript
JavaScript中如何调用Java方法
2020/09/16 Javascript
[01:03:50]DOTA2-DPC中国联赛 正赛 CDEC vs DLG BO3 第二场 2月7日
2021/03/11 DOTA
Python通过解析网页实现看报程序的方法
2014/08/04 Python
python进程类subprocess的一些操作方法例子
2014/11/22 Python
详解Appium+Python之生成html测试报告
2019/01/04 Python
Python通过2种方法输出带颜色字体
2020/03/02 Python
英国演唱会订票网站:Ticket Selection
2018/03/27 全球购物
英国排名第一的在线宠物用品商店:Monster Pet Supplies
2018/05/20 全球购物
开普敦通行证:Cape Town Pass
2019/07/18 全球购物
意大利中国电子产品购物网站:Geekmall.com
2019/09/30 全球购物
iostream与iostream.h的区别
2015/01/16 面试题
哪些情况下不应该使用索引
2015/07/20 面试题
计算机维护专业推荐信
2014/02/27 职场文书
高二学生评语大全
2014/04/25 职场文书
学校捐款活动总结
2015/05/09 职场文书
《悬崖边的树》读后感2篇
2019/12/02 职场文书
教你快速开启Apache SkyWalking的自监控
2021/04/25 Servers