写点什么

Vue.js 数据双向绑定的实现

  • 2023-04-14
    广东
  • 本文字数:4991 字

    阅读完需:约 16 分钟

Vue.js 数据双向绑定的实现

前言

在我们使用 vue 的时候,当数据发生了改变,界面也会跟着更新,但这并不是理所当然的,我们修改数据的时候 vue 是如何监听数据的改变以及当数据发生改变的时候 vue 如何让界面刷新的?


当我们修改数据的时候 vue 是通过 es5 中的Object.defineProperty方法来监听数据的改变的,当数据发生了改变通过发布订阅模式统计订阅者界面发生了刷新,这是一种设计模式


下图,从 new Vue 开始创建 Vue 实例,会传入 el 和 data,data 会被传入一个观察者对象,利用Object.definproperty将 data 里数据转化成 getter/setter 进行数据劫持,data 里的每个属性都会创建一个 Dep 实例用来保存 watcher 实例


而 el 则传入 compile,在 compile 里进行指令的解析,当解析到 el 中使用到 data 里的数据会触发我们的 getter,从而将我们的 watcher 添加到依赖当中。当数据发生改变的时候会触发我们的 setter 发出依赖通知,通知 watcher,watcher 接受到通知后去向 view 发出通知,让 view 去更新


数据劫持

html 部分创建一个 id 为 app 的 div 标签,里面有 span 和 input 标签,span 标签使用了插值表达式,input 标签使用了 v-model


<div class="container" id="app">    <span>内容:{{content}}</span>    <input type="text" v-model="content"></div>
复制代码


js 部分引入了一个 vue.js 文件,实现数据双向绑定的代码就写在这里面,然后创建 Vue 实例 vm,把数据挂载到 div 标签上


const vm=new Vue({    el:'#app',        data:{        content:'请输入开机密码'    }})
复制代码


new 了一个 Vue 实例很明显需要用到构造函数,在 vue 的源码里定义类是使用了 function 来定义的,这里我使用 ES6 的 class 来创建这个 Vue 实例


然后设置constructor,形参设为 obj_instance,作为 new 一个 Vue 实例的时候传入的对象,并把传进来的对象里的 data 赋值给实例里的 $data 属性


在 javascript 里对象的属性发生了变化,需要告诉我们,我们就可以把更改后的属性更新到 dom 节点里,因此初始化实例的时候定义一个监听函数作为调用,调用的时候传入需要监听的数据


class Vue{//创建Vue实例  constructor(obj_instance){    this.$data=obj_instance.data    Observer(this.$data)  }}function Observer(data_instance){//监听函数  }
复制代码


打印一下这个实例 vm



实例已经创建出来了但是还需要为 $data 里的每一个属性进行监听,要实现数据监听就用到了Object.definePropertyObject.defineProperty可以修改对象的现有属性,语法格式为 Object.defineProperty(obj, prop, descriptor)


  • obj:要定义属性的对象

  • prop:要定义或修改的属性的名称

  • descriptor:要定义或修改的属性描述符


监听对象里的每一个属性,我们使用 Object.keys 和 foreach 遍历对象里的每一个属性并且对每一个属性使用 Object.defineProperty 进行数据监听


function Observer(data_instance){  Object.keys(data_instance).forEach(key=>{    Object.defineProperty(data_instance,key,{      enumerable:true,//设置为true表示属性可以枚举      configurable:true,//设置为true表示属性描述符可以被改变      get(){},//访问该属性的时候触发,get和set函数就是数据监听的核心      set(){},//修改该属性的时候触发    })  })}
复制代码


Object.defineProperty前需要将属性对应的值存起来然后在 get 函数里面返回出来,不然到了 get 函数以后属性的值已经没了,返回给属性的值就变成了 undefined


let value=data_instance[key]Object.defineProperty(data_instance,key,{  enumerable:true,  configurable:true,  get(){    console.log(key,value);    return value  },  set(){}})
复制代码


点击一下 $data 里的属性名就会触发 get 函数



然后设置 set 函数,为 set 设置形参,这个形参表示新传进来的属性值,然后将这个新的属性值赋值给变量 value,不需要 return 返回什么,只做修改,返回是在访问 get 的时候返回的,修改之后 get 也会访问最新的 value 变量值


set(newValue){    console.log(key,value,newValue);    value = newValue}
复制代码



但是当前只为 $data 的第一层属性设置了 get 和 set,如果还有更深的一层如


obj:{    a:'a',    b:'b'}
复制代码


这种的并没有设置 get 和 set,我们需要一层一层的往属性里面进行数据劫持,因此使用递归再次监听自己,并在遍历之前进行条件判断,没有子属性了或者没有检测到对象就终止递归


function Observer(data_instance){  //递归出口  if(!data_instance || typeof data_instance != 'object') return  Object.keys(data_instance).forEach(key=>{    let value=data_instance[key]    Observer(value)//递归-子属性的劫持    Object.defineProperty(data_instance,key,{      enumerable:true,      configurable:true,      get(){        console.log(key,value);        return value      },      set(newValue){        console.log(key,value,newValue);        value = newValue      }    })  })}
复制代码


还有一个细节,如果我们将 $data 的 content 属性从字符串改写成一个对象,这个新的对象并没有 get 和 set



因为我们在修改的时候根本没有设置 get 和 set,因此在 set 里要调用监听函数


set(newValue){    console.log(key,value,newValue);    value = newValue    Observer(newValue)}
复制代码


模板解析

劫持数据后就要把 Vue 实例里的数据应用带页面上,得要加一个临时内存区域,将所有数据都更新后再渲染页面以此减少 dom 操作


创建一个解析函数,设置 2 个参数,一个是 Vue 实例里挂载的元素,另一个是 Vue 实例,在函数里获取获取元素保存在实例了的 $el 里,获取元素后放入临时内存里,需要用到[createDocumentFragment]创建一个新的空白的文档片段


然后把 $el 的子节点一个一个加到 fragment 变量里,页面已经没有内容了,内容都被临时存在 fragment 里了


class Vue{  constructor(obj_instance){    this.$data=obj_instance.data    Observer(this.$data)    Compile(obj_instance.el,this)  }}function Compile(ele,vm){  vm.$el=document.querySelector(ele)  const fragment=document.createDocumentFragment()  let child;  while (child=vm.$el.firstChild){    fragment.append(child)  }  console.log(fragment);  console.log(fragment.childNodes);}
复制代码



现在直接把需要修改的内容应用到文档碎片里面,应用后重新渲染,只需修改了 fragment 的 childNodes 子节点的文本节点,文本节点的类型是 3,可以创建一个函数并调用来修改 fragment 里的内容


节点里面可能还会有节点,因此判定节点类型是否为 3,不是就递归调用这个解析函数


节点类型为 3 就进行修改操作,但也不行把整个节点的文本都修改,只需修改插值表达式的内容,因此要使用正则表达式匹配,将匹配的结果保存到变量里,匹配的结果是一个数组,而索引为 1 的元素才是我们需要提取出来的元素,这个元素就是去除了{{}}和空格得到的字符串,然后就可以直接用 Vue 实例来访问对应属性的值,修改完后 return 出去结束递归


function Compile(ele,vm){  vm.$el=document.querySelector(ele) //获取元素保存在实例了的$el里  const fragment=document.createDocumentFragment() //创建文档碎片  let child;  while (child=vm.$el.firstChild){//循环将子节点添加到文档碎片里    fragment.append(child)  }    fragment_compile(fragment)  function fragment_compile(node){ //修改文本节点内容    const pattern = /\{\{\s*(\S*)\s*\}\}/ //检索字符串中正则表达式的匹配,用于匹配插值表达式    if(node.nodeType===3){      const result = pattern.exec(node.nodeValue)      if(result){        console.log('result[1]')        const value=result[1].split('.').reduce(//split将对象里的属性分布在数组里,链式地进行排列;reduce进行累加,层层递进获取$data的值          (total,current)=>total[current],vm.$data        )        node.nodeValue=node.nodeValue.replace(pattern,value) //replace函数将插值表达式替换成$data里的属性的值      }      return     }    node.childNodes.forEach(child=>fragment_compile(child))  }  vm.$el.appendChild(fragment) //将文档碎片应用到对应的dom元素里面}
复制代码


页面的内容又出来了,插值表达式替换成了 vm 实例里的数据



订阅发布者模式

虽然进行了数据劫持,和将数据应用到页面上,但是数据发生变动还不能及时更新,还需要实现订阅发布者模式


首先创建一个类用来收集和通知订阅者,生成实例的时候需要有一个数组存放订阅者的信息,一个将订阅者添加到这个数组里的方法和一个通知订阅者的方法,调用这个方法就回去遍历订阅者的数组,让订阅者调用自身的 update 方法进行更新


class Dependency{  constructor(){    this.subscribers=[] //存放订阅者的信息  }  addSub(sub){    this.subscribers.push(sub) //将订阅者添加到这个数组里  }  notify(){    this.subscribers.forEach(sub=>sub.update()) //遍历订阅者的数组,调用自身的update函数进行更新  }}
复制代码


设置订阅者类,需要用到 Vue 实例上的属性,需要 Vue 实例和 Vue 实例对应的属性和一个回调函数作为参数,将参数都赋值给实例


然后就可以创建订阅者的 update 函数,在函数里调用传进来的回调函数


class Watcher{  constructor(vm,key,callback){//将参数都赋值给Watcher实例    this.vm=vm    this.key=key    this.callback=callback  }  update(){    this.callback()   }}
复制代码


替换文档碎片内容的时候需要告诉订阅者如何更新,所以订阅者实例在模板解析把节点值替换内容的时候创建,传入 vm 实例,exec 匹配成功后的索引值 1 和回调函数,将替换文本的执行语句复制到回调函数里,通知订阅者更新的时候就调用这个回调函数


回调函数里的 nodeValue 要提前保存,不然替换的内容就不是插值表达式而是替换过的内容



然后就要想办法将订阅者存储到 Dependency 实例的数组里,我们可以在构造 Watcher 实例的时候保存实例到订阅者数组里


Dependency.temp=this //设置一个临时属性temp
复制代码


将新的订阅者添加到订阅者数组里且还要将所有的订阅者都进行同样的操作,那么就可以在触发 get 的时候将订阅者添加到订阅者数组里,为了正确触发对应的属性 get,需要用 reduce 方法对 key 进行同样的操作




可以看到控制台打印出了 Wathcer 实例,每个实例都不同,都对应不同的属性值



Dependency 类还没创建实例,里面的订阅者数组是不存在的,所以要先创建实例再将订阅者添加到订阅者数组里




修改数据的时候通知订阅者来进行更新,在 set 里调用 dependency 的通知方法,通知方法就会去遍数组,订阅者执行自己的 update 方法进行数据更新



但是 update 调用回调函数缺少设定形参,依旧使用 split 和 reduce 方法获取属性值


update(){    const value =this.key.split('.').reduce(        (total,current)=>total[current],this.vm.$data    )    this.callback(value)}
复制代码


在控制台修改属性值都修改成功了,页面也自动更新了



完成了文本的绑定就可以绑定输入框了,在 vue 里通过 v-model 进行绑定,因此要判断哪个节点有 v-model,元素节点的类型是 1,可以使用 nodeName 来匹配 input 元素,直接在判断文本节点下面进行新的判断


if(node.nodeType===1&&node.nodeName==='INPUT'){    const attr=Array.from(node.attributes)    console.log(attr);}
复制代码


节点名字 nodeName 为 v-model,nodeValue 为 name,就是数据里的属性名



因此对这个数组进行遍历,匹配到了 v-model 根据 nodeValue 找到对应的属性值,把属性值赋值到节点上,同时为了在数据更新后订阅者知道更新自己,也要在 INPUT 节点里新增 Watcher 实例


attr.forEach(i=>{    if(i.nodeName==='v-model'){        const value=i.nodeValue.split('.').reduce(            (total,current)=>total[current],vm.$data        )        node.value=value        new Watcher(vm,i.nodeValue,newValue=>{            node.value=newValue        })    }})
复制代码


修改属性值,页面也作出修改



最后剩下用视图改变数据,在 v-model 的节点上使用 addEventListener 增加 input 监听事件就行了


node.addEventListener('input',e=>{    const arr1=i.nodeValue.split('.')    const arr2=arr1.slice(0,arr1.length - 1)    const final=arr2.reduce(        (total,current)=>total[current],vm.$data    )    final[arr1[arr1.length - 1]]=e.target.value})
复制代码



发布于: 刚刚阅读数: 4
用户头像

你若毁我天堂,我必戳你脊梁 2022-11-01 加入

还未添加个人简介

评论

发布
暂无评论
Vue.js 数据双向绑定的实现_三周年连更_格斗家不爱在外太空沉思_InfoQ写作社区