前言:

学习前端也有半年多了,个人的学习欲望还比较强烈,很喜欢那种新知识在自己的演练下一点点实现的过程。最近一直在学vue框架,像网上大佬说的,入门容易深究难。不管是跟着开发文档学还是视频教程,按步骤操作总是最肤浅,想要把这门功课做好毕竟得下足功夫。因此,特意花了好几天时间阅读相关技术博客和源码,简单实现了一个数据双向绑定的vue框架,希望能让各位有点启发...

1.什么是MVVM

MVVM即modle-view-viewmole,MVVM最早由微软提出来,在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。

2.数据的双向绑定

在vue框架中,通过控制台或者Vue Devtools修改data里的一些属性时会看到页面也会更新,而在页面修改数据时,data里的属性值也会发生改变。我们就把这种model和view同步显示称为是双向绑定。其实单向绑定原理也差不多,视图改变data更新通过事件监听就能轻松实现了,重点都在希望data改变视图也发生改变,而这就是我们下面要讲的原理。

3.vue数据双向绑定原理

3.1 Object.defineProperty()方法

首先要知道的是vue的数据绑定通过数据劫持配合发布订阅者模式实现的,那么什么是数据劫持呢?我们可以在控制台看一下它的初始化对象是什么样的:

  1. let vm = new Vue({
  2. el:"#app",
  3. data:{
  4. obj:{
  5. a:
  6. }
  7. },
  8. created() {
  9. console.log(this.obj)
  10. },
  11. })

可以看到属性a分别对应着一个get 和set方法,这里引申出Object.defineProperty()方法,传递三个参数,obj(要在其上定义属性的对象)、prop(要定义或修改的属性的名称)、descriptor(将被定义或修改的属性描述符)。该方法更多信息参考:参考更多用法,着重强调一下get和set这两个属性描述键值。

  • get 存取描述符的可选键值,一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。当访问该属性时,该方法会被执行,方法执行时没有参数传入,但是会传入this对象(由于继承关系,这里的this并不一定是定义该属性的对象)。
  • set 存取描述符的可选键值,一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。当属性值修改时,触发执行该方法。该方法将接受唯一参数,即该属性新的参数值。

平常我们在打印一个对象属性时会这样做:

  1. var obj = {
  2. name:"tnagj"
  3. }
  4. console.log(obj.name) //tangj

如果我们想要在输出的同时监听obj的属性值,并且输出的是tangjSir呢?这时候我们的set和get属性就起到了很好的作用

  1. var obj ={};
  2. var name = '';
  3. Object.defineProperty(obj,'name',{
  4. set:function(value){
  5. name = value
  6. console.log('我叫:' + name)
  7. },
  8. get:function(){
  9. console.log(name + 'Sir')
  10. }
  11. })
  12. obj.name = 'tangj'; //我叫tangj
  13. obj.name; //tangjSir

首先我们定义了一个obj空对象以及name空属性,再用一个Object.defineProperty()方法来判断obj.name的访问情况,如果是读值则调用get函数,如果是赋值则调用set函数。在这两个函数里面我们分别对输出的内容作了更改,因此在get方法调用时打印tangjSir,在set方法调用时打印我叫tangj。

其实这就是vue数据绑定的监听原理,我们能通过这个简单实现MVVM双向绑定。

3.2 MVVM双向绑定分析

view的变化,比如input值改变我们很容易就能知道通过input事件反应到data中,数据绑定的关键在于怎样让data更新view。首先我们要知道数据什么时候变的,上文提过可以用Object.defineProperty()的set属性描述键值来监听这个变化,当数据改变时就调用set方法。

那么我们可以设置一个监听器Observe,用来监听所有的属性,当属性变化的时候就需要告诉订阅者Watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理的。当然我们还需要有一个指令解析器Compile,对每个节点元素进行扫描和解析,将相关指令对应初始化成一个订阅者Watcher,并替换模板数据或者绑定相应的函数,此时当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。所以,我们大致可以把整个过程拆分成五个部分,MVVM.html,MVVM.js,compile.js,observer.js,watcher.js,我们在MVVM.js中创建所需要的实例,在.html文件中引入这些js文件,这样拆分更容易理解也更好维护。

4.分步拆分

4.1 MVVM.JS

为了和Vue保持一致,我们向MVVM.js传入一个空对象options,并让vm.$el = options.el,vm.$data = options.data,如果能取到vm.$el再进行编译和监听

  1. class MVVM {
  2. constructor(options){
  3. this.$el = options.el, //把东西挂载在实例上
  4. this.$data = options.data
  5. if(this.$el){ // 如果有要编译的就开始编译
  6. new Observer(this.$data); //数据劫持,就是把对象的所有属性改成get和set方法
  7. new Compile(this.$el,this);//用数据和元素进行编译
  8. }
  9. }
  10. }

4.2  Compile.js

编译的时候有一个问题需要注意,如果直接操作DOM元素会特别消耗性能,所以我们希望先把DOM元素都放在内存中即文档碎片,待编译完成再把文档碎片放进真实的元素中

  1. class Complie{
  2. constructor(el,vm){
  3. this.el = this.isElementNode(el)?el:document.querySelector(el);
  4. this.vm = vm;
  5. if(this.el){//如果这个元素能获取到,我们才开始编译
  6. let fragment = this.nodeToFragment(this.el); //1.先把真实的DOM移入到内存中,fragment
  7. this.compile(fragment); //2.编译=>提取想要的元素节点v-modle 和文本节点{{}}
  8. this.el.appendChild(fragment) //3.把编译好的fragment塞回页面
  9. }
  10. nodeToFragment(el){ //需要el元素放到内存中
  11. let fragment = document.createDocumentFragment();
  12. let Child;
  13. while(Child = el.firstChild){
  14. fragment.appendChild(Child);
  15. }
  16. return fragment;
  17. }
  18. }
    }

接下来我们要判断需要编译的是元素节点还是文档节点,还记得Vue中有很多很有用的指令吗?比如"v-modle"、"v-for"等,所以我们还要判断元素节点内是否包含指令,如果是指令,它应该包含一些特殊的方法

  1. /* 省略.... */
  2. isElementNode(node){ //是不是元素节点
  3. return node.nodeType === 1;
  4. }
  5. isDirective(name){ //是不是指令
  6. return name.includes('v-')
  7. }
  8. compileElement(node){
  9. //带v-modle
  10. let attrs = node.attributes;
  11. Array.from(attrs).forEach(
  12. attr =>{
  13. let attrName = attr.name;
  14. if(this.isDirective(attrName)){
  15. // 取到对应的值放到节点中
  16. let expr = attr.value;
  17. // node vm.$data expr
  18. let [,type] = attrName.split('-') //解构赋值
  19. CompileUtil[type](node,this.vm,expr)
  20. }
  21. }
  22. )
  23. }
  24. compileText(node){
  25. // 带{{}}
  26. let expr = node.textContent; //取文本的内容
  27. let reg = /\{\{([^}]+)\}\}/g //全局匹配
  28. if(reg.test(expr)){
  29. // node this.vm.$data expr
  30. CompileUtil['text'](node,this.vm,expr)
  31. }
  32. }
  33. compile(fragment){ //需要递归,拿到的childNodes只是第一层
  34. let childNodes = fragment.childNodes;
  35. Array.from(childNodes).forEach(
  36. node=>{
  37. if(this.isElementNode(node)){ //是元素节点,还需要递归检查
  38. this.compileElement(node) //编译元素
  39. this.compile(node) //箭头函数this指向上一层的实例
  40. }else{ //文本节点
  41. this.compileText(node) //编译文本
  42. }
  43. }
  44. )
  45. }

根据获取的节点类型不同,执行不同的方法,我们可把这些方法统一都放到一个对象里面去

  1. CompileUtil = {
  2. getVal(vm,expr){ //获取实例上的数据
  3. expr = expr.split('.'); //如果遇到vm.$data[a.a],希望先拿到vm.$data[a]
  4. return expr.reduce((prev,next)=>{
  5. return prev[next]
  6. },vm.$data)
  7. },
  8. getTextVal(vm,expr){ //获取编译文本以后的结果
  9. return expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
  10. return this.getVal(vm,arguments[1]);
  11. })
  12. },
  13. text(node,vm,expr){ // 文本处理
  14. let updateFn = this.updater['textUpdater'];
  15. let value = this.getTextVal(vm,expr);
  16. updateFn && updateFn(node,value);
  17. },
  18. modle(node,vm,expr){ // 输入框处理
  19. let updateFn = this.updater['modleUpdater']
  20. updateFn && updateFn(node,this.getVal(vm,expr))
  21. },
  22. updater:{
  23. textUpdater(ndoe,value){
  24. ndoe.textContent = value //文本更新
  25. },
  26. modleUpdater(node,value){
  27. node.value = value
  28. }
  29. }
  30. }

4.3 Oberver.js

编译的时候我们还需要一个监听者,当数据变化调用get和set方法

  1. class Observer{
  2. constructor(data){
  3. this.observer(data)
  4. }
  5. observer(data){
  6. if(!data || typeof data !== 'object') return;
  7. Object.keys(data).forEach(key =>{
  8. this.defineReactive(data,key,data[key]);
  9. this.observer(data[key])
  10. })
  11. }
  12. defineReactive(obj,key,value){
  13. let that = this;
  14. Object.defineProperty(obj,key,{
  15. enumerable:true,
  16. configurable:true,
  17. get(){
  18. Dep.target && dep.addSub(Dep.target);
  19. return value;
  20. },
  21. set(newvalue){
  22. if(value === newvalue) return;
  23. that.observer(newvalue); //如果新值是对象,继续劫持
  24. value = newvalue;
  25. },
  26. })
  27. }
  28. }

4.4 Watcher订阅者和Dep监听器

前面已经实现了监听和编译,但是怎么样才能让它们之间进行通信呢,也就是当监听到变化了怎么通知呢?这里就用到了发布订阅模式。默认观察者watcher有一个update方法,它会更新数据。Dep里面创建一个数组,把观察者都放在这个数组里面,当监听到变化,一个个调用监听者update方法。

  1. // Watcher.js
  2. //观察者的目的就是给需要变化的那个元素增加一个观察者,当数据变化后执行对应的方法
  3. class Watcher{
  4. constructor(vm,expr,cb){
  5. this.vm = vm;
  6. this.expr = expr;
  7. this.cb = cb;
  8. //先获取老的值
  9. this.value = this.get()
  10. }
  11. getVal(vm,expr){ //获取实例上的数据
  12. expr = expr.split('.'); //如果遇到vm.$data[a.a],希望先拿到vm.$data[a]
  13. // console.log(expr)
  14. return expr.reduce((prev,next)=>{
  15. return prev[next]
  16. },vm.$data)
  17. }
  18. get(){
  19. Dep.target = this; //缓存自己
  20. let value = this.getVal(this.vm,this.expr);
  21. Dep.target = null; //释放自己
  22. return value;
  23. }
  24. update(){
  25. let newValue = this.getVal(this.vm,this.expr);
  26. let oldValue = this.value;
  27. if(newValue != oldValue){
  28. this.cb(newValue);
  29. }
  30. }
  31. }
  32. //Dep.js
  33. class Dep{
  34. constructor(){
  35. //订阅的数组
  36. this.subs = []
  37. }
  38. addSub(watcher){
  39. this.subs.push(watcher)
  40. }
  41. notify(){
  42. this.subs.forEach(watcher =>{
  43. watcher.update()
  44. })
  45. }
  46. }

watcher逻辑: 当创建watcher实例的时候,先拿到这个值,数据变化又拿到一个新值,如果新值和老值不一样,那么调用callback,实现更新;

dep逻辑:创建数组把观察者放在这个数组里,当监听到变化,执行watcher.update()

我们再它们分别添加到Observer和compile中

  1. // complie.js
  2. // 省略....
  3. text(node,vm,expr){ // 文本处理
  4. let updateFn = this.updater['textUpdater'];
  5. //{{message.a}} => tangj
  6. let value = this.getTextVal(vm,expr);
  7. expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
  8. new Watcher(vm,arguments[1],(newVaule)=>{
  9. // 如果数据变化,文本节点需要重新获取依赖的属性更新文本的的内容
  10. updateFn && updateFn(node,this.getTextVal(vm,expr));
  11. })
  12. })
  13. updateFn && updateFn(node,value);
  14. },
  15. modle(node,vm,expr){ // 输入框处理
  16. let updateFn = this.updater['modleUpdater']
  17. // 'message.a' => [message.a] vm.$data['message'].a
  18. // 这里应该加一个监控,数据变化,调用这个watch的cb
  19. new Watcher(vm,expr,(newVaule)=>{
  20. //当值变化后将调用cb,将新的值传递过来
  21. updateFn && updateFn(node,this.getVal(vm,expr))
  22. });
  23. node.addEventListener('input',(e)=>{
  24. let newVaule = e.target.value;
  25. this.setVal(vm,expr,newVaule)
  26. })
  27. updateFn && updateFn(node,this.getVal(vm,expr))
  28. }
  29. // 省略...
  1. // observer.js
    class Observer{
  2. constructor(data){
  3. this.observer(data)
  4. }
  5. observer(data){
  6. //要对这个data数据原有属性改成set和get的形式
  7. if(!data || typeof data !== 'object') return;
  8. Object.keys(data).forEach(key =>{
  9. this.defineReactive(data,key,data[key]);
  10. this.observer(data[key])
  11. })
  12. }
  13. defineReactive(obj,key,value){
  14. let that = this;
  15. let dep = new Dep(); //每个变化的数据都会对应一个数组,这个数据存放了所有数据的更新
  16. Object.defineProperty(obj,key,{
  17. enumerable:true,
  18. configurable:true,
  19. get(){
  20. Dep.target && dep.addSub(Dep.target);
  21. return value;
  22. },
  23. set(newvalue){
  24. if(value === newvalue) return;
  25. that.observer(newvalue); //如果新值是对象,继续劫持
  26. value = newvalue;
  27. dep.notify(); //通知所有人数据更新
  28. },
  29. })
  30. }
  31. }
  32. class Dep{
  33. constructor(){
  34. //订阅的数组
  35. this.subs = []
  36. }
  37. addSub(watcher){
  38. this.subs.push(watcher)
  39. }
  40. notify(){
  41. this.subs.forEach(watcher =>{
  42. watcher.update()
  43. })
  44. }
  45. }

到这里我们就实现了数据的双向绑定,MVVM作为数据绑定的入口,整合Observer、Compile和Watcher三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更的双向绑定效果。

当然我们还需要数据代理,用vm代理vm.$data,也是通过Object.defineProperty()实现

  1.  
 this.proxyData(this.$data);
  1. proxyData(data){
  2. Object.keys(data).forEach(key =>{
  3. let val = data[key]
  4. Object.defineProperty(this,key,{
  5. enumerable:true,
  6. configurable:true,
  7. get(){
  8. return val
  9. },
  10. set(newval){
  11. if(val == newval){
  12. return;
  13. }
  14. val = newval
  15. }
  16. })
  17. })
  18. }

5.最终效果

本次学习源码已上传github:https://github.com/Tangjj1996/MVVM,喜欢的朋友可以stars

参考博客:基于vue实现一个简单的MVVM框架(源码分析)

PS:MVVM是学习框架非常重要的一步,掌握了这些原理才能更好地运用,知其然更要知其所以然,水平有限有错误的地方烦请多多指教

【学习笔记】剖析MVVM框架,简单实现Vue数据双向绑定的更多相关文章

  1. Vue数据双向绑定原理及简单实现

    嘿,Goodgirl and GoodBoy,点进来了就看完点个赞再go. Vue这个框架就不简单介绍了,它最大的特性就是数据的双向绑定以及虚拟dom.核心就是用数据来驱动视图层的改变.先看一段代码. ...

  2. 西安电话面试:谈谈Vue数据双向绑定原理,看看你的回答能打几分

    最近我参加了一次来自西安的电话面试(第二轮,技术面),是大厂还是小作坊我在这里按下不表,先来说说这次电面给我留下印象较深的几道面试题,这次先来谈谈Vue的数据双向绑定原理. 情景再现: 当我手机铃声响 ...

  3. vue数据双向绑定原理

    vue的数据双向绑定的小例子: .html <!DOCTYPE html> <html> <head> <meta charset=utf-> < ...

  4. vue数据双向绑定的原理、虚拟dom的原理

    vue数据双向绑定的原理https://www.cnblogs.com/libin-1/p/6893712.html 虚拟dom的原理https://blog.csdn.net/u010692018/ ...

  5. Vue数据双向绑定探究

    前面的啰嗦话,写一点吧,或许就有点用呢 使用过vue的小伙伴都会感觉,哇,这个框架对开发者这么友好,简直都要笑出声了. 确实,使用过vue的框架做开发的人都会感觉到,以前写一大堆操作dom,bom的东 ...

  6. 深入理解Proxy 及 使用Proxy实现vue数据双向绑定

    阅读目录 1.什么是Proxy?它的作用是? 2.get(target, propKey, receiver) 3.set(target, propKey, value, receiver) 4.ha ...

  7. Vue数据双向绑定(面试必备) 极简版

    我又来吹牛逼了,这次我们简单说一下vue的数据双向绑定,我们这次不背题,而是要你理解这个流程,保证读完就懂,逢人能讲,面试必过,如果没做到,请再来看一遍,走起: 介绍双向数据之前,我们先解释几个名词: ...

  8. Vue数据双向绑定原理(vue2向vue3的过渡)

    众所周知,Vue的两大重要概念: 数据驱动 组件系统 1 2 接下来我们浅析数据双向绑定的原理 一.vue2 1.认识defineProperty vue2中的双向绑定是基于definePropert ...

  9. vue数据双向绑定

    Vue的双向绑定是通过数据劫持结合发布-订阅者模式实现的,即通过Object.defineProperty监听各个属性的setter,然后通知订阅者属性发生变化,触发相应的回调. 整个过程分为以下几步 ...

随机推荐

  1. Python:每日一题005

    题目: 输入三个整数x,y,z,请把这三个数由小到大输出. 程序分析: 我们想办法把最小的数放到x上,先将x与y进行比较,如果x>y则将x与y的值进行交换,然后再用x与z进行比较,如果x> ...

  2. ScrollView嵌套Linearlayout显示不全的解决办法

    以为ScrollView只能嵌套一个元素,所以把几个控件都包裹在了一个LinearLayout中了.但是发现底部显示不全,滑动不到最底下. 代码: <ScrollView android:id= ...

  3. Android SDK Manager 无法打开

    环境变量已经设置(安装JDK8后 其实无需设置,之前记得Win7有个巧妙的地方是创建了3个快捷方式到某文件夹,现在Win10上直接将java.exe等放到System32目录下). 但是依然不行,网上 ...

  4. Python从入门到精通之First!

    Python的种类 Cpython Python的官方版本,使用C语言实现,使用最为广泛,CPython实现会将源文件(py文件)转换成字节码文件(pyc文件),然后运行在Python虚拟机上. Jy ...

  5. spring中的aop演示

    一.步骤(XML配置) 1.导包4+2+2+2 2.准备目标对象 3.准备通知 4.配置进行织入,将通知织入目标对象中 <! -- 3.配置将通知织入目标对象> 5.测试 二.步骤(注解配 ...

  6. html 2

    一.列表 信息资源的一种展示形式 二.列表的分类 1.有序列表 <ol> <li>列表项1</li> <li>列表项2</li> </ ...

  7. VS从数据库表生成Model代码

    1.工具——扩展和更新——安装下列插件 2.如图所示,在项目或者MODEL文件夹下添加 3.如图所示,生成了一个datanase.11 4.打开该文件后,将数据库连接字符串改为你自己项目中WebCof ...

  8. 6. ASP.NET MVC 5.0 中的HTML Helper【HTML 帮助类】

    这篇文章,我将带领大家学习HTML Helper.[PS:上一篇-->5.ASP.NET MVC 中的Area[区域]是什么] HTML Helpers是用来创建HTML标签进而创建HTML控件 ...

  9. 9.7 翻译系列:EF数据注解特性之--InverseProperty【EF 6 Code-First系列】

    原文链接:https://www.entityframeworktutorial.net/code-first/inverseproperty-dataannotations-attribute-in ...

  10. 背水一战 Windows 10 (86) - 文件系统: 获取文件夹的属性, 获取文件夹的缩略图

    [源码下载] 背水一战 Windows 10 (86) - 文件系统: 获取文件夹的属性, 获取文件夹的缩略图 作者:webabcd 介绍背水一战 Windows 10 之 文件系统 获取文件夹的属性 ...