JavaScript进阶之深入理解数据双向绑定

前言

谈起当前前端最热门的 js 框架,必少不了
Vue、React、Angular,对于大多数人来说,我们更多的是在使用框架,对于框架解决痛点背后使用的基本原理往往关注不多,近期在研读
Vue.js
源码,也在写源码解读的系列文章。和多数源码解读的文章不同的是,我会尝试从一个初级前端的角度入手,由浅入深去讲解源码实现思路和基本的语法知识,通过一些基础事例一步步去实现一些小功能。

本场 Chat 是系列 Chat
的开篇,我会首先讲解一下数据双向绑定的基本原理,介绍对比一下三大框架的不同实现方式,同时会一步步完成一个简单的mvvm示例。读源码不是目的,只是一种学习的方式,目的是在读源码的过程中提升自己,学习基本原理,拓展编码的思维方式。

模板引擎实现原理

对于页面渲染,一般分为服务器端渲染和浏览器端渲染。一般来说服务器端吐html页面的方式渲染速度更快、更利于SEO,但是浏览器端渲染更利于提高开发效率和减少维护成本,是一种相关舒服的前后端协作模式,后端提供接口,前端做视图和交互逻辑。前端通过Ajax请求数据然后拼接html字符串或者使用js模板引擎、数据驱动的框架如Vue进行页面渲染。

在ES6和Vue这类框架出现以前,前端绑定数据的方式是动态拼接html字符串和js模板引擎。模板引擎起到数据和视图分离的作用,模板对应视图,关注如何展示数据,在模板外头准备的数据,
关注那些数据可以被展示。模板引擎的工作原理可以简单地分成两个步骤:模板解析 / 编译(Parse /
Compile)和数据渲染(Render)两部分组成,当今主流的前端模板有三种方式:

  • String-based templating (基于字符串的parse和compile过程)
  • Dom-based templating (基于Dom的link或compile过程)
  • Living templating (基于字符串的parse 和 基于dom的compile过程)

String-based templating

基于字符串的模板引擎,本质上依然是字符串拼接的形式,只是一般的库做了封装和优化,提供了更多方便的语法简化了我们的工作。基本原理如下:

典型的库:

  • art-template
  • mustache.js
  • doT

之前的一篇文章中我介绍了js模板引擎的实现思路,感兴趣的朋友可以看看这里:JavaScript进阶学习(一)——
基于正则表达式的简单js模板引擎实现。这篇文章中我们利用正则表达式实现了一个简单的js模板引擎,利用正则匹配查找出模板中{{}}之间的内容,然后替换为模型中的数据,从而实现视图的渲染。


  1. var template = function(tpl, data) { 
  2.  
  3.   var re = /{{(.+?)}}/g, 
  4.  
  5.     cursor = 0, 
  6.  
  7.     reExp = /(^( )?(var|if|for|else|switch|case|break|{|}|;))(.*)?/g,     
  8.  
  9.     code = 'var r=[];\n'; 
  10.  
  11.   
  12.  
  13.   // 解析html 
  14.  
  15.   function parsehtml(line) { 
  16.  
  17.     // 单双引号转义,换行符替换为空格,去掉前后的空格 
  18.  
  19.     line = line.replace(/('|")/g, '\\$1').replace(/\n/g, ' ').replace(/(^\s+)|(\s+$)/g,""); 
  20.  
  21.     code +='r.push("' + line + '");\n'; 
  22.  
  23.   } 
  24.  
  25.    
  26.  
  27.   // 解析js代码         
  28.  
  29.   function parsejs(line) {   
  30.  
  31.     // 去掉前后的空格 
  32.  
  33.     line = line.replace(/(^\s+)|(\s+$)/g,""); 
  34.  
  35.     code += line.match(reExp)? line + '\n' : 'r.push(' + 'this.' + line + ');\n'; 
  36.  
  37.   }     
  38.  
  39.      
  40.  
  41.   // 编译模板 
  42.  
  43.   while((match = re.exec(tpl))!== null) { 
  44.  
  45.     // 开始标签  {{ 前的内容和结束标签 }} 后的内容 
  46.  
  47.     parsehtml(tpl.slice(cursor, match.index)); 
  48.  
  49.     // 开始标签  {{ 和 结束标签 }} 之间的内容 
  50.  
  51.     parsejs(match[1]); 
  52.  
  53.     // 每一次匹配完成移动指针 
  54.  
  55.     cursor = match.index + match[0].length; 
  56.  
  57.   } 
  58.  
  59.   // 最后一次匹配完的内容 
  60.  
  61.   parsehtml(tpl.substr(cursor, tpl.length - cursor)); 
  62.  
  63.   code += 'return r.join("");'; 
  64.  
  65.   return new Function(code.replace(/[\r\t\n]/g, '')).apply(data); 
  66.  
  67. }  

源代码:http://jsfiddle.net/zhaomenghuan/bw468orv/embedded/

现在ES6支持了模板字符串,我们可以用比较简单的代码就可以实现类似的功能:


  1. const template = data => ` 
  2.  
  3.   <p>name: ${data.name}</p> 
  4.  
  5.   <p>age: ${data.profile.age}</p> 
  6.  
  7.   <ul> 
  8.  
  9.     ${data.skills.map(skill => ` 
  10.  
  11.       <li>${skill}</li> 
  12.  
  13.     `).join('')} 
  14.  
  15.   </ul>` 
  16.  
  17.   
  18.  
  19. const data = { 
  20.  
  21.   name: 'zhaomenghuan', 
  22.  
  23.   profile: { age: 24 }, 
  24.  
  25.   skills: ['html5', 'javascript', 'android'] 
  26.  
  27.  
  28.   
  29.  
  30. document.body.innerHTML = template(data)  

Dom-based templating

Dom-based templating 则是从DOM的角度去实现数据的渲染,我们通过遍历DOM树,提取属性与DOM内容,然后将数据写入到DOM树中,从而实现页面渲染。一个简单的例子如下:


  1. function MVVM(opt) { 
  2.  
  3.   this.dom = document.querySelector(opt.el); 
  4.  
  5.   this.data = opt.data || {}; 
  6.  
  7.   this.renderDom(this.dom); 
  8.  
  9.  
  10.   
  11.  
  12. MVVM.prototype = { 
  13.  
  14.   init: { 
  15.  
  16.     sTag: '{{', 
  17.  
  18.     eTag: '}}' 
  19.  
  20.   }, 
  21.  
  22.   render: function (node) { 
  23.  
  24.     var self = this; 
  25.  
  26.     var sTag = self.init.sTag; 
  27.  
  28.     var eTag = self.init.eTag; 
  29.  
  30.   
  31.  
  32.     var matchs = node.textContent.split(sTag); 
  33.  
  34.     if (matchs.length){ 
  35.  
  36.       var ret = ''; 
  37.  
  38.       for (var i = 0; i < matchs.length; i++) { 
  39.  
  40.         var match = matchs[i].split(eTag); 
  41.  
  42.         if (match.length == 1) { 
  43.  
  44.             ret += matchs[i]; 
  45.  
  46.         } else { 
  47.  
  48.             ret = self.data[match[0]]; 
  49.  
  50.         } 
  51.  
  52.         node.textContent = ret; 
  53.  
  54.       } 
  55.  
  56.     } 
  57.  
  58.   }, 
  59.  
  60.   renderDom: function(dom) { 
  61.  
  62.     var self = this; 
  63.  
  64.   
  65.  
  66.     var attrs = dom.attributes; 
  67.  
  68.     var nodes = dom.childNodes; 
  69.  
  70.   
  71.  
  72.     Array.prototype.forEach.call(attrs, function(item) { 
  73.  
  74.       self.render(item); 
  75.  
  76.     }); 
  77.  
  78.   
  79.  
  80.     Array.prototype.forEach.call(nodes, function(item) { 
  81.  
  82.       if (item.nodeType === 1) { 
  83.  
  84.         return self.renderDom(item); 
  85.  
  86.       } 
  87.  
  88.       self.render(item); 
  89.  
  90.     }); 
  91.  
  92.   } 
  93.  
  94.  
  95.   
  96.  
  97. var app = new MVVM({ 
  98.  
  99.   el: '#app', 
  100.  
  101.   data: { 
  102.  
  103.     name: 'zhaomenghuan', 
  104.  
  105.     age: '24', 
  106.  
  107.     color: 'red' 
  108.  
  109.   } 
  110.  
  111. });  

源代码:http://jsfiddle.net/zhaomenghuan/6e3yg6Lq/embedded/

页面渲染的函数 renderDom
是直接遍历DOM树,而不是遍历html字符串。遍历DOM树节点属性(attributes)和子节点(childNodes),然后调用渲染函数render。当DOM树子节点的类型是元素时,递归调用遍历DOM树的方法。根据DOM树节点类型一直遍历子节点,直到文本节点。

render的函数作用是提取{{}}中的关键词,然后使用数据模型中的数据进行替换。我们通过textContent获取Node节点的nodeValue,然后使用字符串的split方法对nodeValue进行分割,提取{{}}中的关键词然后替换为数据模型中的值。

DOM 的相关基础

注:元素类型对应NodeType

元素类型 NodeType
元素 1
属性 2
文本 3
注释 8
文档 9

childNodes 属性返回包含被选节点的子节点的
NodeList。childNodes包含的不仅仅只有html节点,所有属性,文本、注释等节点都包含在childNodes里面。children只返回元素如input,
span, script, div等,不会返回TextNode,注释。

数据双向绑定实现原理

js模板引擎可以认为是一个基于MVC的结构,我们通过建立模板作为视图,然后通过引擎函数作为控制器实现数据和视图的绑定,从而实现实现数据在页面渲染,但是当数据模型发生变化时,视图不能自动更新;当视图数据发生变化时,模型数据不能实现更新,这个时候双向数据绑定应运而生。检测视图数据更新实现数据绑定的方法有很多种,目前主要分为三个流派,Angular使用的是脏检查,只在特定的事件下才会触发视图刷新,Vue使用的是Getter/Setter机制,而React则是通过
Virtual DOM 算法检查DOM的变动的刷新机制。

本文限于篇幅和内容在此只探讨一下 Vue.js 数据绑定的实现,对于 angular 和 react
后续再做说明,读者也可以自行阅读源码。Vue 监听数据变化的机制是把一个普通 JavaScript 对象传给 Vue 实例的 data
选项,Vue 将遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为
getter/setter。Vue 2.x 对 Virtual DOM 进行了支持,这部分内容后续我们再做探讨。

引子

为了更好的理解Vue中视图和数据更新的机制,我们先看一个简单的例子:


  1. var o = { 
  2.  
  3.   a: 0 
  4.  
  5.  
  6. Object.defineProperty(o, "b", { 
  7.  
  8.   get: function () { 
  9.  
  10.     return this.a + 1; 
  11.  
  12.   }, 
  13.  
  14.   set: function (value) { 
  15.  
  16.     this.a = value / 2; 
  17.  
  18.   } 
  19.  
  20. }); 
  21.  
  22. console.log(o.a); // "0" 
  23.  
  24. console.log(o.b); // "1" 
  25.  
  26.   
  27.  
  28. // 更新o.a 
  29.  
  30. o.a = 5; 
  31.  
  32. console.log(o.a); // "5" 
  33.  
  34. console.log(o.b); // "6" 
  35.  
  36.   
  37.  
  38. // 更新o.b 
  39.  
  40. o.b = 10; 
  41.  
  42. console.log(o.a); // "5" 
  43.  
  44. console.log(o.b); // "6"  

这里我们可以看出对象o的b属性的值依赖于a属性的值,同时b属性值的变化又可以改变a属性的值,这个过程相关的属性值的变化都会影响其他相关的值进行更新。反过来我们看看如果不使用Object.defineProperty()方法,上述的问题通过直接给对象属性赋值的方法实现,代码如下


  1. var o = { 
  2.  
  3.   a: 0 
  4.  
  5. }     
  6.  
  7. o.b = o.a + 1; 
  8.  
  9. console.log(o.a); // "0" 
  10.  
  11. console.log(o.b); // "1" 
  12.  
  13.   
  14.  
  15. // 更新o.a 
  16.  
  17. o.a = 5; 
  18.  
  19. o.b = o.a + 1; 
  20.  
  21. console.log(o.a); // "5" 
  22.  
  23. console.log(o.b); // "6" 
  24.  
  25.   
  26.  
  27. // 更新o.b 
  28.  
  29. o.b = 10; 
  30.  
  31. o.a = o.b / 2; 
  32.  
  33. o.b = o.a + 1; 
  34.  
  35. console.log(o.a); // "5" 
  36.  
  37. console.log(o.b); // "6"  

很显然使用Object.defineProperty()方法可以更方便的监听一个对象的变化。当我们的视图和数据任何一方发生变化的时候,我们希望能够通知对方也更新,这就是所谓的数据双向绑定。既然明白这个道理我们就可以看看Vue源码中相关的处理细节。

Object.defineProperty()

Object.defineProperty()方法可以直接在一个对象上定义一个新属性,或者修改一个已经存在的属性, 并返回这个对象。

语法:Object.defineProperty(obj, prop, descriptor)

参数:

  • obj:需要定义属性的对象。
  • prop:需被定义或修改的属性名。
  • descriptor:需被定义或修改的属性的描述符。

返回值:返回传入函数的对象,即第一个参数obj

该方法重点是描述,对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符是一个拥有可写或不可写值的属性。存取描述符是由一对 getter-setter 函数功能来描述的属性。描述符必须是两种形式之一;不能同时是两者。

数据描述符和存取描述符均具有以下可选键值:

  • configurable:当且仅当该属性的 configurable 为 true 时,该属性才能够被改变,也能够被删除。默认为 false。
  • enumerable:当且仅当该属性的 enumerable 为 true 时,该属性才能够出现在对象的枚举属性中。默认为 false。

数据描述符同时具有以下可选键值:

  • value:该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined。
  • writable:当且仅当仅当该属性的writable为 true 时,该属性才能被赋值运算符改变。默认为 false。

存取描述符同时具有以下可选键值:

  • get:一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。该方法返回值被用作属性值。默认为undefined。
  • set:一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。该方法将接受唯一参数,并将该参数的新值分配给该属性。默认为undefined。

我们可以通过Object.defineProperty()方法精确添加或修改对象的属性。比如,直接赋值创建的属性默认情况是可以枚举的,但是我们可以通过Object.defineProperty()方法设置enumerable属性为false为不可枚举。


  1. var obj = { 
  2.  
  3.   a: 0, 
  4.  
  5.   b: 1 
  6.  
  7.  
  8. for (var prop in obj) { 
  9.  
  10.   console.log(`obj.${prop} = ${obj[prop]}`); 
  11.  
  12. }  

结果:


  1. "obj.a = 0" 
  2.  
  3. "obj.b = 1" 

我们通过Object.defineProperty()修改如下:


  1. var obj = { 
  2.  
  3.   a: 0, 
  4.  
  5.   b: 1 
  6.  
  7.  
  8. Object.defineProperty(obj, 'b', { 
  9.  
  10.   enumerable: false 
  11.  
  12. }) 
  13.  
  14. for (var prop in obj) { 
  15.  
  16.   console.log(`obj.${prop} = ${obj[prop]}`); 
  17.  
  18. }  

结果:


  1. "obj.a = 0" 

这里需要说明的是我们使用Object.defineProperty()默认情况下是enumerable属性为false,例如:


  1. var obj = { 
  2.  
  3.   a: 0 
  4.  
  5.  
  6. Object.defineProperty(obj, 'b', { 
  7.  
  8.   value: 1 
  9.  
  10. }) 
  11.  
  12. for (var prop in obj) { 
  13.  
  14.   console.log(`obj.${prop} = ${obj[prop]}`); 
  15.  
  16. }  

结果:


  1. "obj.a = 0" 

其他描述属性使用方法类似,不做赘述。Vue源码core/util/lang.jsS中定义了这样一个方法:


  1. /** 
  2.  
  3. * Define a property. 
  4.  
  5. */ 
  6.  
  7. export function def (obj: Object, key: string, val: any, enumerable?: boolean) { 
  8.  
  9.   Object.defineProperty(obj, key, { 
  10.  
  11.     value: val, 
  12.  
  13.     enumerable: !!enumerable, 
  14.  
  15.     writable: true, 
  16.  
  17.     configurable: true 
  18.  
  19.   }) 
  20.  
  21. }  

Object.getOwnPropertyDescriptor()

Object.getOwnPropertyDescriptor() 返回指定对象上一个自有属性对应的属性描述符。(自有属性指的是直接赋予该对象的属性,不需要从原型链上进行查找的属性)

语法:Object.getOwnPropertyDescriptor(obj, prop)

参数:

  • obj:在该对象上查看属性
  • prop:一个属性名称,该属性的属性描述符将被返回

返回值:如果指定的属性存在于对象上,则返回其属性描述符(property descriptor),否则返回 undefined。可以访问“属性描述符”内容,例如前面的例子:


  1. var o = { 
  2.  
  3.   a: 0 
  4.  
  5.  
  6.              
  7.  
  8. Object.defineProperty(o, "b", { 
  9.  
  10.   get: function () { 
  11.  
  12.     return this.a + 1; 
  13.  
  14.   }, 
  15.  
  16.   set: function (value) { 
  17.  
  18.     this.a = value / 2; 
  19.  
  20.   } 
  21.  
  22. }); 
  23.  
  24.              
  25.  
  26. var des = Object.getOwnPropertyDescriptor(o,'b'); 
  27.  
  28. console.log(des); 
  29.  
  30. console.log(des.get);  

Vue源码分析

本次我们主要分析一下Vue 数据绑定的源码,这里我直接将 Vue.js 1.0.28 版本的代码稍作删减拿过来进行,2.x 的代码基于
flow 静态类型检查器书写的,代码除了编码风格在整体结构上基本没有太大改动,所以依然基于 1.x 进行分析,对于存在差异的部分加以说明。

监听对象变动


  1. // 观察者构造函数 
  2.  
  3. function Observer (value) { 
  4.  
  5.   this.value = value 
  6.  
  7.   this.walk(value) 
  8.  
  9.  
  10.   
  11.  
  12. // 递归调用,为对象绑定getter/setter 
  13.  
  14. Observer.prototype.walk = function (obj) { 
  15.  
  16.   var keys = Object.keys(obj) 
  17.  
  18.   for (var i = 0, l = keys.length; i < l; i++) { 
  19.  
  20.     this.convert(keys[i], obj[keys[i]]) 
  21.  
  22.   } 
  23.  
  24.  
  25.   
  26.  
  27. // 将属性转换为getter/setter 
  28.  
  29. Observer.prototype.convert = function (key, val) { 
  30.  
  31.   defineReactive(this.value, key, val) 
  32.  
  33.  
  34.   
  35.  
  36. // 创建数据观察者实例 
  37.  
  38. function observe (value) { 
  39.  
  40.   // 当值不存在或者不是对象类型时,不需要继续深入监听 
  41.  
  42.   if (!value || typeof value !== 'object') { 
  43.  
  44.     return 
  45.  
  46.   } 
  47.  
  48.   return new Observer(value) 
  49.  
  50.  
  51.   
  52.  
  53. // 定义对象属性的getter/setter 
  54.  
  55. function defineReactive (obj, key, val) { 
  56.  
  57.   var property = Object.getOwnPropertyDescriptor(obj, key) 
  58.  
  59.   if (property && property.configurable === false) { 
  60.  
  61.     return 
  62.  
  63.   } 
  64.  
  65.   
  66.  
  67.   // 保存对象属性预先定义的getter/setter 
  68.  
  69.   var getter = property && property.get 
  70.  
  71.   var setter = property && property.set 
  72.  
  73.   
  74.  
  75.   var childOb = observe(val) 
  76.  
  77.   Object.defineProperty(obj, key, { 
  78.  
  79.     enumerable: true, 
  80.  
  81.     configurable: true, 
  82.  
  83.     get: function reactiveGetter () { 
  84.  
  85.       var value = getter ? getter.call(obj) : val 
  86.  
  87.       console.log("访问:"+key) 
  88.  
  89.       return value 
  90.  
  91.     }, 
  92.  
  93.     set: function reactiveSetter (newVal) { 
  94.  
  95.       var value = getter ? getter.call(obj) : val 
  96.  
  97.       if (newVal === value) { 
  98.  
  99.         return 
  100.  
  101.       } 
  102.  
  103.       if (setter) { 
  104.  
  105.         setter.call(obj, newVal) 
  106.  
  107.       } else { 
  108.  
  109.         val = newVal 
  110.  
  111.       } 
  112.  
  113.       // 对新值进行监听 
  114.  
  115.       childOb = observe(newVal) 
  116.  
  117.       console.log('更新:' + key + ' = ' + newVal) 
  118.  
  119.     } 
  120.  
  121.   }) 
  122.  
  123. }  

定义一个对象作为数据模型,并监听这个对象。


  1. let data = { 
  2.  
  3.   user: { 
  4.  
  5.     name: 'zhaomenghuan', 
  6.  
  7.     age: '24' 
  8.  
  9.   }, 
  10.  
  11.   address: { 
  12.  
  13.     city: 'beijing' 
  14.  
  15.   } 
  16.  
  17.  
  18. observe(data) 
  19.  
  20.   
  21.  
  22. console.log(data.user.name) 
  23.  
  24. // 访问:user 
  25.  
  26. // 访问:name 
  27.  
  28.   
  29.  
  30. data.user.name = 'ZHAO MENGHUAN' 
  31.  
  32. // 访问:user 
  33.  
  34. // 更新:name = ZHAO MENGHUAN  

效果如下:

监听数组变动

上面我们通过Object.defineProperty把对象的属性全部转为 getter/setter
从而实现监听对象的变动,但是对于数组对象无法通过Object.defineProperty实现监听。Vue
包含一组观察数组的变异方法,所以它们也将会触发视图更新。


  1. const arrayProto = Array.prototype 
  2.  
  3. const arrayMethods = Object.create(arrayProto) 
  4.  
  5.   
  6.  
  7. function def(obj, key, val, enumerable) { 
  8.  
  9.   Object.defineProperty(obj, key, { 
  10.  
  11.     value: val, 
  12.  
  13.     enumerable: !!enumerable, 
  14.  
  15.     writable: true, 
  16.  
  17.     configurable: true 
  18.  
  19.   }) 
  20.  
  21.  
  22.   
  23.  
  24. // 数组的变异方法 
  25.  
  26. ;[ 
  27.  
  28.   'push', 
  29.  
  30.   'pop', 
  31.  
  32.   'shift', 
  33.  
  34.   'unshift', 
  35.  
  36.   'splice', 
  37.  
  38.   'sort', 
  39.  
  40.   'reverse' 
  41.  
  42.  
  43. .forEach(function (method) { 
  44.  
  45.   // 缓存数组原始方法 
  46.  
  47.   var original = arrayProto[method] 
  48.  
  49.   def(arrayMethods, method, function mutator () { 
  50.  
  51.     var i = arguments.length 
  52.  
  53.     var args = new Array(i) 
  54.  
  55.     while (i--) { 
  56.  
  57.       args[i] = arguments[i] 
  58.  
  59.     } 
  60.  
  61.     console.log('数组变动') 
  62.  
  63.     return original.apply(this, args) 
  64.  
  65.   }) 
  66.  
  67. })  

Vue.js 1.x 在Array.prototype原型对象上添加了$set 和 $remove方法,在2.X后移除了,使用全局 API Vue.set 和 Vue.delete代替了,后续我们再分析。

定义一个数组作为数据模型,并对这个数组调用变异的七个方法实现监听。


  1. let skills = ['JavaScript', 'Node.js', 'html5'] 
  2.  
  3. // 原型指针指向具有变异方法的数组对象 
  4.  
  5. skills.__proto__ = arrayMethods 
  6.  
  7.   
  8.  
  9. skills.push('java') 
  10.  
  11. // 数组变动 
  12.  
  13. skills.pop() 
  14.  
  15. // 数组变动  

效果如下:

我们将需要监听的数组的原型指针指向我们定义的数组对象,这样我们的数组在调用上面七个数组的变异方法时,能够监听到变动从而实现对数组进行跟踪。

对于__proto__属性,在ES2015中正式被加入到规范中,标准明确规定,只有浏览器必须部署这个属性,其他运行环境不一定需要部署,所以
Vue
是先进行了判断,当__proto__属性存在时将原型指针__proto__指向具有变异方法的数组对象,不存在时直接将具有变异方法挂在需要追踪的对象上。

我们可以在上面Observer观察者构造函数中添加对数组的监听,源码如下:


  1. const hasProto = '__proto__' in {} 
  2.  
  3. const arrayKeys = Object.getOwnPropertyNames(arrayMethods) 
  4.  
  5.   
  6.  
  7. // 观察者构造函数 
  8.  
  9. function Observer (value) { 
  10.  
  11.   this.value = value 
  12.  
  13.   if (Array.isArray(value)) { 
  14.  
  15.     var augment = hasProto 
  16.  
  17.       ? protoAugment 
  18.  
  19.       : copyAugment 
  20.  
  21.     augment(value, arrayMethods, arrayKeys) 
  22.  
  23.     this.observeArray(value) 
  24.  
  25.   } else { 
  26.  
  27.     this.walk(value) 
  28.  
  29.   } 
  30.  
  31.  
  32.   
  33.  
  34. // 观察数组的每一项 
  35.  
  36. Observer.prototype.observeArray = function (items) { 
  37.  
  38.   for (var i = 0, l = items.length; i < l; i++) { 
  39.  
  40.     observe(items[i]) 
  41.  
  42.   } 
  43.  
  44.  
  45.   
  46.  
  47. // 将目标对象/数组的原型指针__proto__指向src 
  48.  
  49. function protoAugment (target, src) { 
  50.  
  51.   target.__proto__ = src 
  52.  
  53.  
  54.   
  55.  
  56. // 将具有变异方法挂在需要追踪的对象上 
  57.  
  58. function copyAugment (target, src, keys) { 
  59.  
  60.   for (var i = 0, l = keys.length; i < l; i++) { 
  61.  
  62.     var key = keys[i] 
  63.  
  64.     def(target, key, src[key]) 
  65.  
  66.   } 
  67.  
  68. }  

原型链

对于不了解原型链的朋友可以看一下我这里画的一个基本关系图:

  • 原型对象是构造函数的prototype属性,是所有实例化对象共享属性和方法的原型对象;
  • 实例化对象通过new构造函数得到,都继承了原型对象的属性和方法;
  • 原型对象中有个隐式的constructor,指向了构造函数本身。

Object.create

Object.create 使用指定的原型对象和其属性创建了一个新的对象。


  1. const arrayProto = Array.prototype 
  2.  
  3. const arrayMethods = Object.create(arrayProto)  

这一步是通过 Object.create
创建了一个原型对象为Array.prototype的空对象。然后通过Object.defineProperty方法对这个对象定义几个变异的数组方法。有些新手可能会直接修改
Array.prototype 上的方法,这是很危险的行为,这样在引入的时候会全局影响Array
对象的方法,而使用Object.create实质上是完全了一份拷贝,新生成的arrayMethods对象的原型指针__proto__指向了Array.prototype,修改arrayMethods
对象不会影响Array.prototype。

基于这种原理,我们通常会使用Object.create 实现类式继承。


  1. // 实现继承 
  2.  
  3. var extend = function(Child, Parent) { 
  4.  
  5.     // 拷贝Parent原型对象 
  6.  
  7.     Child.prototype = Object.create(Parent.prototype); 
  8.  
  9.     // 将Child构造函数赋值给Child的原型对象 
  10.  
  11.     Child.prototype.constructor = Child; 
  12.  
  13.  
  14.   
  15.  
  16. // 实例 
  17.  
  18. var Parent = function () { 
  19.  
  20.     this.name = 'Parent'; 
  21.  
  22.  
  23. Parent.prototype.getName = function () { 
  24.  
  25.     return this.name; 
  26.  
  27.  
  28. var Child = function () { 
  29.  
  30.     this.name = 'Child'; 
  31.  
  32.  
  33. extend(Child, Parent); 
  34.  
  35. var child = new Child(); 
  36.  
  37. console.log(child.getName())  

发布-订阅模式

在上面一部分我们通过Object.defineProperty把对象的属性全部转为 getter/setter 以及
数组变异方法实现了对数据模型变动的监听,在数据变动的时候,我们通过console.log打印出来提示了,但是对于框架而言,我们相关的逻辑如果直接写在那些地方,自然是不够优雅和灵活的,这个时候就需要引入常用的设计模式去实现,vue.js采用了发布-订阅模式。发布-订阅模式主要是为了达到一种“高内聚、低耦合”的效果。

Vue的Watcher订阅者作为Observer和Compile之间通信的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图。


  1. /** 
  2.  
  3. * 观察者对象 
  4.  
  5. */ 
  6.  
  7. function Watcher(vm, expOrFn, cb) { 
  8.  
  9.     this.vm = vm 
  10.  
  11.     this.cb = cb 
  12.  
  13.     this.depIds = {} 
  14.  
  15.     if (typeof expOrFn === 'function') { 
  16.  
  17.         this.getter = expOrFn 
  18.  
  19.     } else { 
  20.  
  21.         this.getter = this.parseExpression(expOrFn) 
  22.  
  23.     } 
  24.  
  25.     this.value = this.get() 
  26.  
  27.  
  28.   
  29.  
  30. /** 
  31.  
  32. * 收集依赖 
  33.  
  34. */ 
  35.  
  36. Watcher.prototype.get = function () { 
  37.  
  38.     // 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者 
  39.  
  40.     Dep.target = this 
  41.  
  42.     // 触发getter,将自身添加到dep中 
  43.  
  44.     const value = this.getter.call(this.vm, this.vm) 
  45.  
  46.     // 依赖收集完成,置空,用于下一个Watcher使用 
  47.  
  48.     Dep.target = null 
  49.  
  50.     return value 
  51.  
  52.  
  53.   
  54.  
  55. Watcher.prototype.addDep = function (dep) { 
  56.  
  57.     if (!this.depIds.hasOwnProperty(dep.id)) { 
  58.  
  59.         dep.addSub(this) 
  60.  
  61.         this.depIds[dep.id] = dep 
  62.  
  63.     } 
  64.  
  65.  
  66.   
  67.  
  68. /** 
  69.  
  70. * 依赖变动更新 
  71.  
  72.  
  73. * @param {Boolean} shallow 
  74.  
  75. */ 
  76.  
  77. Watcher.prototype.update = function () { 
  78.  
  79.     this.run() 
  80.  
  81.  
  82.   
  83.  
  84. Watcher.prototype.run = function () { 
  85.  
  86.     var value = this.get() 
  87.  
  88.     if (value !== this.value) { 
  89.  
  90.         var oldValue = this.value 
  91.  
  92.         this.value = value 
  93.  
  94.         // 将newVal, oldVal挂载到MVVM实例上 
  95.  
  96.         this.cb.call(this.vm, value, oldValue) 
  97.  
  98.     } 
  99.  
  100.  
  101.   
  102.  
  103. Watcher.prototype.parseExpression = function (exp) { 
  104.  
  105.     if (/[^\w.$]/.test(exp)) { 
  106.  
  107.         return 
  108.  
  109.     } 
  110.  
  111.     var exps = exp.split('.') 
  112.  
  113.      
  114.  
  115.     return function(obj) { 
  116.  
  117.         for (var i = 0, len = exps.length; i < len; i++) { 
  118.  
  119.             if (!obj) return 
  120.  
  121.             obj = obj[exps[i]] 
  122.  
  123.         } 
  124.  
  125.         return obj 
  126.  
  127.     } 
  128.  
  129. }  

Dep 是一个数据结构,其本质是维护了一个watcher队列,负责添加watcher,更新watcher,移除watcher,通知watcher更新。


  1. let uid = 0 
  2.  
  3.   
  4.  
  5. function Dep() { 
  6.  
  7.     this.id = uid++ 
  8.  
  9.     this.subs = [] 
  10.  
  11.  
  12.   
  13.  
  14. Dep.target = null 
  15.  
  16.   
  17.  
  18. /** 
  19.  
  20. * 添加一个订阅者 
  21.  
  22.  
  23. * @param {Directive} sub 
  24.  
  25. */ 
  26.  
  27. Dep.prototype.addSub = function (sub) { 
  28.  
  29.     this.subs.push(sub) 
  30.  
  31.  
  32.   
  33.  
  34. /** 
  35.  
  36. * 移除一个订阅者 
  37.  
  38.  
  39. * @param {Directive} sub 
  40.  
  41. */ 
  42.  
  43. Dep.prototype.removeSub = function (sub) { 
  44.  
  45.     let index = this.subs.indexOf(sub); 
  46.  
  47.     if (index !== -1) { 
  48.  
  49.         this.subs.splice(index, 1); 
  50.  
  51.     } 
  52.  
  53.  
  54.   
  55.  
  56. /** 
  57.  
  58. * 将自身作为依赖添加到目标watcher 
  59.  
  60. */ 
  61.  
  62. Dep.prototype.depend = function () { 
  63.  
  64.     Dep.target.addDep(this) 
  65.  
  66.  
  67.   
  68.  
  69. /** 
  70.  
  71. * 通知数据变更 
  72.  
  73. */ 
  74.  
  75. Dep.prototype.notify = function () { 
  76.  
  77.     var subs = toArray(this.subs) 
  78.  
  79.     // stablize the subscriber list first 
  80.  
  81.     for (var i = 0, l = subs.length; i < l; i++) { 
  82.  
  83.         // 执行订阅者的update更新函数 
  84.  
  85.         subs[i].update() 
  86.  
  87.     } 
  88.  
  89. }  

模板编译

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

这种实现和我们讲到的Dom-based
templating类似,只是更加完备,具有自定义指令的功能。在遍历节点属性和文本节点的时候,可以编译具备{{}}表达式或v-xxx的属性值的节点,并且通过添加
new Watcher()及绑定事件函数,监听数据的变动从而对视图实现双向绑定。

MVVM实例

在数据绑定初始化的时候,我们需要通过new Observer()来监听数据模型变化,通过new Compile()来解析编译模板指令,并利用Watcher搭起Observer和Compile之间的通信桥梁。


  1. /** 
  2.  
  3. * @class 双向绑定类 MVVM 
  4.  
  5. * @param {[type]} options [description] 
  6.  
  7. */ 
  8.  
  9. function MVVM(options) { 
  10.  
  11.     this.$options = options || {} 
  12.  
  13.     // 简化了对data的处理 
  14.  
  15.     let data = this._data = this.$options.data 
  16.  
  17.     // 监听数据 
  18.  
  19.     observe(data) 
  20.  
  21.     new Compile(options.el || document.body, this) 
  22.  
  23.  
  24.   
  25.  
  26. MVVM.prototype.$watch = function (expOrFn, cb) { 
  27.  
  28.     new Watcher(this, expOrFn, cb) 
  29.  
  30. }  

为了能够直接通过实例化对象操作数据模型,我们需要为MVVM实例添加一个数据模型代理的方法:


  1. MVVM.prototype._proxy = function (key) { 
  2.  
  3.     Object.defineProperty(this, key, { 
  4.  
  5.         configurable: true, 
  6.  
  7.         enumerable: true, 
  8.  
  9.         get: () => this._data[key], 
  10.  
  11.         set: (val) => { 
  12.  
  13.             this._data[key] = val 
  14.  
  15.         } 
  16.  
  17.     }) 
  18.  
  19. }  

至此我们可以通过一个小例子来说明本文的内容:


  1. <div id="app"> 
  2.  
  3.     <h3>{{user.name}}</h3> 
  4.  
  5.     <input type="text" v-model="modelValue"> 
  6.  
  7.     <p>{{modelValue}}</p> 
  8.  
  9. </div> 
  10.  
  11. <script> 
  12.  
  13.     let vm = new MVVM({ 
  14.  
  15.         el: '#app', 
  16.  
  17.         data: { 
  18.  
  19.             modelValue: '', 
  20.  
  21.             user: { 
  22.  
  23.                 name: 'zhaomenghuan', 
  24.  
  25.                 age: '24' 
  26.  
  27.             }, 
  28.  
  29.             address: { 
  30.  
  31.                 city: 'beijing' 
  32.  
  33.             }, 
  34.  
  35.             skills: ['JavaScript', 'Node.js', 'html5'] 
  36.  
  37.         } 
  38.  
  39.     }) 
  40.  
  41.      
  42.  
  43.     vm.$watch('modelValue', val => console.log(`watch modelValue :${val}`)) 
  44.  
  45. </script>  

本文目的不是为了造一个轮子,而是在学习优秀框架实现的过程中去提升自己,搞清楚框架发展的前因后果,由浅及深去学习基础,本文参考了网上很多优秀博主的文章,由于时间关系,有些内容没有做深入探讨,觉得还是有些遗憾,在后续的学习中会更多的独立思考,提出更多自己的想法。

参考文档

  • 前端模板技术面面观
  • Object.defineProperty()
  • Vue.js 源码学习笔记
  • vue早期源码学习系列
  • 解析最简单的observer和watcher
  • 剖析Vue实现原理 – 如何实现双向绑定mvvm 

作者:佚名

来源:51CTO

时间: 2024-09-11 23:17:56

JavaScript进阶之深入理解数据双向绑定的相关文章

理解Angular数据双向绑定_AngularJS

AngularJS是一款优秀的前端JS框架,已经被用于Google的多款产品当中.AngularJS有着诸多特性,最为核心的是:MVVM.模块化.自动化双向数据绑定.语义化标签.依赖注入等等. 一.什么是数据双向绑定 Angular实现了双向绑定机制.所谓的双向绑定,无非是从界面的操作能实时反映到数据,数据的变更能实时展现到界面. 一个最简单的示例就是这样: <div ng-controller="CounterCtrl"> <span ng-bind="c

轻松实现javascript数据双向绑定_javascript技巧

双向数据绑定指的是当对象的属性发生变化时能够同时改变对应的UI,反之亦然.换句话说,如果我们有一个user对象,这个对象有一个name属性,无论何时你对user.name设置了一个新值,UI也会展示这个新的值.同样的,如果UI包含一个用于数据用户名字的输入框,输入一个新值也会导致user对象的name属性发生相应的改变. 许多流行的javascript框架,像Ember.js,Angular.js或者KnockoutJS都会把双向数据绑定作为其中的主要特性来宣传.这并不意味着从头开始实现它很难,

自定义Angular指令与jQuery实现的Bootstrap风格数据双向绑定的单选与多选下拉框_AngularJS

先说点闲话,熟悉Angular的猿们会喜欢这个插件的. 00.本末倒置 不得不承认我是一个喜欢本末倒置的人,学生时代就喜欢先把晚交的作业先做,留着马上就要交的作业不做,然后慢悠悠做完不重要的作业,卧槽,XX作业马上要交了,赶紧补补补.如今做这个项目,因为没找到合适的多选下拉Web插件,又不想用html自带的丑陋的<select multiple></select>,自己花了一整天时间做了一个.或许这样占用的主要功能开发的时间,开发起来会更有紧迫感吧.感觉自己是个抖M自虐倾向,并且伴

AngularJS学习笔记(三)数据双向绑定的简单实例_AngularJS

双向绑定 双向绑定是AngularJS最实用的功能,它节省了大量的代码,使我们专注于数据和视图,不用浪费大量的代码在Dom监听.数据同步上,关于双向更新,可看下图: 数据-->视图 这里我们只演示有了数据以后,如何绑定到视图上. <!DOCTYPE html> <html ng-app="App"> <head> <script type="text/javascript" src="http://sandb

Vue.js每天必学之数据双向绑定_javascript技巧

Vue.js 的模板是基于 DOM 实现的.这意味着所有的 Vue.js 模板都是可解析的有效的 HTML,且通过一些特殊的特性做了增强.Vue 模板因而从根本上不同于基于字符串的模板,请记住这点. 插值 文本 数据绑定最基础的形式是文本插值,使用 "Mustache" 语法(双大括号): <span>Message: {{ msg }}</span> Mustache 标签会被相应数据对象的 msg 属性的值替换.每当这个属性变化时它也会更新. 你也可以只处理

深入ASP.NET数据绑定(中)——数据双向绑定机理

在上一篇<深入ASP.NET数据绑定(上)>中,我们分析了在.NET中的数据绑定语法的一些内部机理. 简单说来就是ASP.NET在运行时为我们完成了页面的动态编译,并解析页面的各种服务器端代码,包括数 据绑定语法.而数据绑定的语法虽是一些<%# %>代码块,在生成的代码中,仍然使用了服务器端控 件以及在DataBinding事件调用DataBinder.Eval方法来完成数据的绑定工作.所有的数据绑定模板控件都 使用了这样的机制来进行数据的单向绑定,在.NET 2.0中新增了双向的

实例剖析AngularJS框架中数据的双向绑定运用_AngularJS

数据绑定 通过把一个文本输入框绑定到person.name属性上,就能把我们的应用变得更有趣一点.这一步建立起了文本输入框跟页面的双向绑定. 在这个语境里"双向"意味着如果view改变了属性值,model就会"看到"这个改变,而如果model改变了属性值,view也同样会"看到"这个改变.Angular.js 为你自动搭建好了这个机制.如果你好奇这具体是怎么实现的,请看我们之后推出的一篇文章,其中深入讨论了digest_loop 的运作. 要建立

使用Object.defineProperty实现简单的js双向绑定_javascript技巧

缘起 前几天在看一些流行的迷你mvvm框架(比如avalon.js. vue.js 这种较轻的框架,而非Angularjs.Emberjs这种较重的框架)的实现.现代流行的mvvm框架一般都会将数据双向绑定(two-ways data binding)做掉,作为框架自身的一个卖点( Ember.js 貌似是不支持数据双向绑定的.),而且每种框架双向数据绑定的实现方式都不太一致,比如Anguarjs内部使用的是 脏检查 ,而avalon.js内部实现方式的本质是设置 属性访问器 . 这里不打算具体

mvvm双向绑定机制的原理和实现代码(推荐)_javascript技巧

mvvm框架的双向绑定,即当对象改变时,自动改变相关的dom元素的值,反之,当dom元素改变时,能自动更新对象的值,当然dom元素一般是指可输出的input元素. 1. 首先实现单向绑定,在指定对象的属性值发生改变时触发callback函数. 2. 单向绑定可采用ES5新增的defineProperty实现(或defineProperties),用了ES5注定就不支持IE9以下了,为了防止递归死循环问题,原有属性需要剪切到一个私有属性中保存. 3. 循环调用defineProperty定义闭包时