如何实现双向绑定mvvm的原理实现


Posted in Javascript onMay 28, 2019

本文能帮你做什么?

1、了解vue的双向数据绑定原理以及核心代码模块
2、缓解好奇心的同时了解如何实现双向绑定

为了便于说明原理与实现,本文相关代码主要摘自vue源码, 并进行了简化改造,相对较简陋,并未考虑到数组的处理、数据的循环依赖等,也难免存在一些问题,欢迎大家指正。不过这些并不会影响大家的阅读和理解,相信看完本文后对大家

在阅读vue源码的时候会更有帮助

本文所有相关代码均在github上面可找到 github.com/DMQ/mvvm

相信大家对mvvm双向绑定应该都不陌生了,一言不合上代码,下面先看一个本文最终实现的效果吧,和vue一样的语法,如果还不了解双向绑定,猛戳Google

<div id="mvvm-app">
  <input type="text" v-model="word">
  <p>{{word}}</p>
  <button v-on:click="sayHi">change model</button>
</div>

<script src="./js/observer.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compile.js"></script>
<script src="./js/mvvm.js"></script>
<script>
var vm = new MVVM({
  el: '#mvvm-app',
    data: {
      word: 'Hello World!'
    },
    methods: {
      sayHi: function() {
        this.word = 'Hi, everybody!';
      }
    }
  });
</script>

几种实现双向绑定的做法

目前几种主流的mvc(vm)框架都实现了单向数据绑定,而我所理解的双向数据绑定无非就是在单向绑定的基础上给可输入元素(input、textare等)添加了change(input)事件,来动态修改model和 view,并没有多高深。所以无需太过介怀是实现的单向或双向绑定。

实现数据绑定的做法有大致如下几种:

  • 发布者-订阅者模式(backbone.js)
  • 脏值检查(angular.js)
  • 数据劫持(vue.js)

发布者-订阅者模式: 一般通过sub, pub的方式实现数据和视图的绑定监听,更新数据方式通常做法是 vm.set('property', value),这里有篇文章讲的比较详细,有兴趣可点这里

这种方式现在毕竟太low了,我们更希望通过 vm.property = value这种方式更新数据,同时自动更新视图,于是有了下面两种方式

脏值检查: angular.js 是通过脏值检测的方式比对数据是否有变更,来决定是否更新视图,最简单的方式就是通过 setInterval() 定时轮询检测数据变动,当然Google不会这么low,angular只有在指定的事件触发时进入脏值检测,大致如下:

  • DOM事件,譬如用户输入文本,点击按钮等。( ng-click )
  • XHR响应事件 ( $http )
  • 浏览器Location变更事件 ( $location )
  • Timer事件( timeout ,interval )
  • 执行 digest() 或apply()

数据劫持: vue.js 则是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。

思路整理

已经了解到vue是通过数据劫持的方式来做数据绑定的,其中最核心的方法便是通过Object.defineProperty()来实现对属性的劫持,达到监听数据变动的目的,无疑这个方法是本文中最重要、最基础的内容之一,如果不熟悉defineProperty,猛戳这里 整理了一下,要实现mvvm的双向绑定,就必须要实现以下几点: 1、实现一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者 2、实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数 3、实现一个Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图 4、mvvm入口函数,整合以上三者

1、实现Observer

ok, 思路已经整理完毕,也已经比较明确相关逻辑和模块功能了,let's do it 我们知道可以利用Obeject.defineProperty()来监听属性变动 那么将需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter和getter 这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化。。相关代码可以是这样:

var data = {name: 'kindeng'};
observe(data);
data.name = 'dmq'; // 哈哈哈,监听到值变化了 kindeng --> dmq

function observe(data) {
  if (!data || typeof data !== 'object') {
    return;
  }
  // 取出所有属性遍历
  Object.keys(data).forEach(function(key) {
   defineReactive(data, key, data[key]);
 });
};

function defineReactive(data, key, val) {
  observe(val); // 监听子属性
  Object.defineProperty(data, key, {
    enumerable: true, // 可枚举
    configurable: false, // 不能再define
    get: function() {
      return val;
    },
    set: function(newVal) {
      console.log('哈哈哈,监听到值变化了 ', val, ' --> ', newVal);
      val = newVal;
    }
  });
}

这样我们已经可以监听每个数据的变化了,那么监听到变化之后就是怎么通知订阅者了,所以接下来我们需要实现一个消息订阅器,很简单,维护一个数组,用来收集订阅者,数据变动触发notify,再调用订阅者的update方法,代码改善之后是这样:

// ... 省略
function defineReactive(data, key, val) {
 var dep = new Dep();
  observe(val); // 监听子属性

  Object.defineProperty(data, key, {
    // ... 省略
    set: function(newVal) {
     if (val === newVal) return;
      console.log('哈哈哈,监听到值变化了 ', val, ' --> ', newVal);
      val = newVal;
      dep.notify(); // 通知所有订阅者
    }
  });
}

function Dep() {
  this.subs = [];
}
Dep.prototype = {
  addSub: function(sub) {
    this.subs.push(sub);
  },
  notify: function() {
    this.subs.forEach(function(sub) {
      sub.update();
    });
  }
};

那么问题来了,谁是订阅者?怎么往订阅器添加订阅者? 没错,上面的思路整理中我们已经明确订阅者应该是Watcher, 而且var dep = new Dep();是在 defineReactive方法内部定义的,所以想通过dep添加订阅者,就必须要在闭包内操作,所以我们可以在 getter里面动手脚:

// Observer.js
// ...省略
Object.defineProperty(data, key, {
 get: function() {
 // 由于需要在闭包内添加watcher,所以通过Dep定义一个全局target属性,暂存watcher, 添加完移除
 Dep.target && dep.addDep(Dep.target);
 return val;
 }
  // ... 省略
});

// Watcher.js
Watcher.prototype = {
 get: function(key) {
 Dep.target = this;
 this.value = data[key]; // 这里会触发属性的getter,从而添加订阅者
 Dep.target = null;
 }
}

这里已经实现了一个Observer了,已经具备了监听数据和数据变化通知订阅者的功能,完整代码。那么接下来就是实现Compile了

2、实现Compile

compile主要做的事情是解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图,如图所示: ![img3][img3]

因为遍历解析的过程有多次操作dom节点,为提高性能和效率,会先将vue实例根节点的el转换成文档碎片fragment进行解析编译操作,解析完成,再将fragment添加回原来的真实dom节点中

function Compile(el) {
  this.$el = this.isElementNode(el) ? el : document.querySelector(el);
  if (this.$el) {
    this.$fragment = this.node2Fragment(this.$el);
    this.init();
    this.$el.appendChild(this.$fragment);
  }
}
Compile.prototype = {
 init: function() { this.compileElement(this.$fragment); },
  node2Fragment: function(el) {
    var fragment = document.createDocumentFragment(), child;
    // 将原生节点拷贝到fragment
    while (child = el.firstChild) {
      fragment.appendChild(child);
    }
    return fragment;
  }
};

compileElement方法将遍历所有节点及其子节点,进行扫描解析编译,调用对应的指令渲染函数进行数据渲染,并调用对应的指令更新函数进行绑定,详看代码及注释说明:

Compile.prototype = {
 // ... 省略
 compileElement: function(el) {
    var childNodes = el.childNodes, me = this;
    [].slice.call(childNodes).forEach(function(node) {
      var text = node.textContent;
      var reg = /\{\{(.*)\}\}/; // 表达式文本
      // 按元素节点方式编译
      if (me.isElementNode(node)) {
        me.compile(node);
      } else if (me.isTextNode(node) && reg.test(text)) {
        me.compileText(node, RegExp.$1);
      }
      // 遍历编译子节点
      if (node.childNodes && node.childNodes.length) {
        me.compileElement(node);
      }
    });
  },

  compile: function(node) {
    var nodeAttrs = node.attributes, me = this;
    [].slice.call(nodeAttrs).forEach(function(attr) {
      // 规定:指令以 v-xxx 命名
      // 如 <span v-text="content"></span> 中指令为 v-text
      var attrName = attr.name; // v-text
      if (me.isDirective(attrName)) {
        var exp = attr.value; // content
        var dir = attrName.substring(2); // text
        if (me.isEventDirective(dir)) {
         // 事件指令, 如 v-on:click
          compileUtil.eventHandler(node, me.$vm, exp, dir);
        } else {
         // 普通指令
          compileUtil[dir] && compileUtil[dir](node, me.$vm, exp);
        }
      }
    });
  }
};

// 指令处理集合
var compileUtil = {
  text: function(node, vm, exp) {
    this.bind(node, vm, exp, 'text');
  },
  // ...省略
  bind: function(node, vm, exp, dir) {
    var updaterFn = updater[dir + 'Updater'];
    // 第一次初始化视图
    updaterFn && updaterFn(node, vm[exp]);
    // 实例化订阅者,此操作会在对应的属性消息订阅器中添加了该订阅者watcher
    new Watcher(vm, exp, function(value, oldValue) {
     // 一旦属性值有变化,会收到通知执行此更新函数,更新视图
      updaterFn && updaterFn(node, value, oldValue);
    });
  }
};

// 更新函数
var updater = {
  textUpdater: function(node, value) {
    node.textContent = typeof value == 'undefined' ? '' : value;
  }
  // ...省略
};

这里通过递归遍历保证了每个节点及子节点都会解析编译到,包括了{{}}表达式声明的文本节点。指令的声明规定是通过特定前缀的节点属性来标记,如<span v-text="content" other-attr中v-text便是指令,而other-attr不是指令,只是普通的属性。 监听数据、绑定更新函数的处理是在compileUtil.bind()这个方法中,通过new Watcher()添加回调来接收数据变化的通知

至此,一个简单的Compile就完成了,完整代码。接下来要看看Watcher这个订阅者的具体实现了

3、实现Watcher

Watcher订阅者作为Observer和Compile之间通信的桥梁,主要做的事情是: 1、在自身实例化时往属性订阅器(dep)里面添加自己 2、自身必须有一个update()方法 3、待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调,则功成身退。 如果有点乱,可以回顾下前面的思路整理

function Watcher(vm, exp, cb) {
  this.cb = cb;
  this.vm = vm;
  this.exp = exp;
  // 此处为了触发属性的getter,从而在dep添加自己,结合Observer更易理解
  this.value = this.get(); 
}
Watcher.prototype = {
  update: function() {
    this.run(); // 属性值变化收到通知
  },
  run: function() {
    var value = this.get(); // 取到最新值
    var oldVal = this.value;
    if (value !== oldVal) {
      this.value = value;
      this.cb.call(this.vm, value, oldVal); // 执行Compile中绑定的回调,更新视图
    }
  },
  get: function() {
    Dep.target = this; // 将当前订阅者指向自己
    var value = this.vm[exp]; // 触发getter,添加自己到属性订阅器中
    Dep.target = null; // 添加完毕,重置
    return value;
  }
};
// 这里再次列出Observer和Dep,方便理解
Object.defineProperty(data, key, {
 get: function() {
 // 由于需要在闭包内添加watcher,所以可以在Dep定义一个全局target属性,暂存watcher, 添加完移除
 Dep.target && dep.addDep(Dep.target);
 return val;
 }
  // ... 省略
});
Dep.prototype = {
  notify: function() {
    this.subs.forEach(function(sub) {
      sub.update(); // 调用订阅者的update方法,通知变化
    });
  }
};

实例化Watcher的时候,调用get()方法,通过Dep.target = watcherInstance标记订阅者是当前watcher实例,强行触发属性定义的getter方法,getter方法执行的时候,就会在属性的订阅器dep添加当前watcher实例,从而在属性值有变化的时候,watcherInstance就能收到更新通知。

ok, Watcher也已经实现了,完整代码。 基本上vue中数据绑定相关比较核心的几个模块也是这几个,猛戳这里 , 在src 目录可找到vue源码。

最后来讲讲MVVM入口文件的相关逻辑和实现吧,相对就比较简单了~

4、实现MVVM

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

一个简单的MVVM构造器是这样子:

function MVVM(options) {
  this.$options = options;
  var data = this._data = this.$options.data;
  observe(data, this);
  this.$compile = new Compile(options.el || document.body, this)
}

但是这里有个问题,从代码中可看出监听的数据对象是options.data,每次需要更新视图,则必须通过var vm = new MVVM({data:{name: 'kindeng'}}); vm._data.name = 'dmq';这样的方式来改变数据。

显然不符合我们一开始的期望,我们所期望的调用方式应该是这样的: var vm = new MVVM({data: {name: 'kindeng'}}); vm.name = 'dmq';

所以这里需要给MVVM实例添加一个属性代理的方法,使访问vm的属性代理为访问vm._data的属性,改造后的代码如下:

function MVVM(options) {
  this.$options = options;
  var data = this._data = this.$options.data, me = this;
  // 属性代理,实现 vm.xxx -> vm._data.xxx
  Object.keys(data).forEach(function(key) {
    me._proxy(key);
  });
  observe(data, this);
  this.$compile = new Compile(options.el || document.body, this)
}

MVVM.prototype = {
 _proxy: function(key) {
 var me = this;
    Object.defineProperty(me, key, {
      configurable: false,
      enumerable: true,
      get: function proxyGetter() {
        return me._data[key];
      },
      set: function proxySetter(newVal) {
        me._data[key] = newVal;
      }
    });
 }
};

这里主要还是利用了Object.defineProperty()这个方法来劫持了vm实例对象的属性的读写权,使读写vm实例的属性转成读写了vm._data的属性值,达到鱼目混珠的效果,哈哈

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
在Z-Blog中运行代码[html][/html](纯JS版)
Mar 25 Javascript
神奇的代码 通杀各种网站-可随意修改复制页面内容
Jul 17 Javascript
使用jQuery简化Ajax开发 Ajax开发入门
Oct 14 Javascript
IE不支持getElementsByClassName最终完美解决方案
Dec 17 Javascript
js switch case default 的用法示例介绍
Oct 23 Javascript
js中定义一个变量并判断其是否为空的方法
May 13 Javascript
JavaScript通过this变量快速找出用户选中radio按钮的方法
Mar 23 Javascript
Bootstrap树形菜单插件TreeView.js使用方法详解
Nov 01 Javascript
JS获取字符对应的ASCII码实例
Sep 10 Javascript
创建Vue项目以及引入Iview的方法示例
Dec 03 Javascript
vue中img src 动态加载本地json的图片路径写法
Apr 25 Javascript
如何构建 vue-ssr 项目的方法步骤
Aug 04 Javascript
vue实现带复选框的树形菜单
May 27 #Javascript
vue实现按需加载组件及异步组件功能
May 27 #Javascript
Vue实现导航栏点击当前标签变色功能
Aug 19 #Javascript
原生js实现each方法实例代码详解
May 27 #Javascript
深入学习JavaScript中的bom
May 27 #Javascript
Vue实现根据hash高亮选项卡
May 27 #Javascript
vue使用localStorage保存登录信息 适用于移动端、PC端
May 27 #Javascript
You might like
Apache下禁止php文件被直接访问的解决方案
2013/04/25 PHP
PHP 解决session死锁的方法
2013/06/20 PHP
php检查字符串中是否有外链的方法
2015/07/29 PHP
改版了网上的一个js操作userdata
2007/04/27 Javascript
jQuery插件 tabBox实现代码
2010/02/09 Javascript
检测input每次的输入是否合法遇到汉字输入就有问题
2012/05/23 Javascript
Javascript中的包装类型介绍
2015/04/02 Javascript
javascript制作的滑动图片菜单
2015/05/15 Javascript
浅谈jQuery hover(over, out)事件函数
2016/12/03 Javascript
JS简单封装的图片无缝滚动效果示例【测试可用】
2017/03/22 Javascript
微信小程序tabBar模板用法实例分析【附demo源码下载】
2017/11/28 Javascript
elementUI select组件value值注意事项详解
2019/05/29 Javascript
Vue全局使用less样式,组件使用全局样式文件中定义的变量操作
2020/10/21 Javascript
JavaScript实现点击切换验证码及校验
2021/01/10 Javascript
[30:51]DOTA2上海特级锦标赛主赛事日 - 3 胜者组第二轮#1Liquid VS MVP.Phx第一局
2016/03/04 DOTA
Python中使用select模块实现非阻塞的IO
2015/02/03 Python
win10 64bit下python NLTK安装教程
2018/09/19 Python
解决py2exe打包后,总是多显示一个DOS黑色窗口的问题
2019/06/21 Python
python多进程下实现日志记录按时间分割
2019/07/22 Python
Python如何把多个PDF文件合并代码实例
2020/02/13 Python
python实现微信打飞机游戏
2020/03/24 Python
浅析python 动态库m.so.1.0错误问题
2020/05/09 Python
keras训练曲线,混淆矩阵,CNN层输出可视化实例
2020/06/15 Python
浅析数据存储的三种方式 cookie sessionstorage localstorage 的异同
2020/06/04 HTML / CSS
亚洲独特体验旅游专家:eOasia
2018/08/15 全球购物
韩国演唱会订票网站:StubHub韩国
2019/01/17 全球购物
Java里面如何把一个Array数组转换成Collection, List
2013/07/26 面试题
九年级化学教学反思
2014/01/28 职场文书
个人授权委托书
2014/04/03 职场文书
二年级学生评语大全
2014/04/23 职场文书
工商管理专业自荐信
2014/06/03 职场文书
2014年党员自我评议(5篇)
2014/09/12 职场文书
导游词之新疆尼雅遗址
2019/10/16 职场文书
浅谈MySQL 亿级数据分页的优化
2021/06/15 MySQL
如何利用opencv判断两张图片是否相同详解
2021/07/07 Python
Python使用MapReduce进行简单的销售统计
2022/04/22 Python