likes
comments
collection
share

vue的数据响应式原理和双向数据绑定的原理是什么?二者有区别吗?如何实现双向绑定?

作者站长头像
站长
· 阅读数 29

先说结论,数据响应式是指通过数据驱动DOM视图的变化,是单向的过程;而双向数据绑定的数据和DOM是一个双向的关系。

Vue的数据响应式

Vue响应式指的是:组件中的data发生变化,立刻触发视图的更新。

Vue响应式的实现主要是基于数据劫持发布-订阅者模式,依赖于Object.defineProperty(vue2)和Proxy(vue3)对象,把data的数据对象转换为getter和sertter。当数据被访问时,触发getter函数进行依赖收集当数据属性被修改时,触发setter函数来通知依赖于此数据的Watcher实例重新计算,从而触发视图的更新

Vue的双向数据绑定

Vue的双向数据绑定是:基于mvvm思想,数据变化更新视图,视图变化更新数据,使数据在视图和组件之间进行双向的数据流动。

双向数据绑定的效果可以使用的v-model指令来体现,它是Vue的一个特性,也可以说是一个input事件和value的语法糖。

v-model

v-model本质上是v-bindv-on的语法糖。

  • 作用在表单元素上
    <input v-model="data" />
    //等价于
    <input v-bind:value="data" v-on:input="data=$event.target.value"/>
    // 动态绑定了 input 的 value 指向了 data 变量,并且在触发 input 事件的时候去动态把 data 设置为当前dom的value值
    
  • 作用在表单元素上
    //父组件中定义子组件
    <child :value="data"  @input="function(e){data = e}"></child>
    
    //子组件中
    <input v-bind:value="value" v-on:input="inputChange"></aa-input>
    props:{value},
    methods:{
        inputChange(e){
            this.$emit('input',e.target.value)
        }
    }
    // 一个组件上的 v-model 会把传入的变量用 prop 当作 value 接收
    // 子组件中,js 监听 input 输入框输入数据,触发 input 事件把数据 $emit 出去
    // 父组件中,通过同名的 input 事件接收数据
    

实现双向数据绑定

建议大家在阅读代码之前可以观看配套视频传送门,这个系列的学习视频讲的很清楚,对于初学者难度会大大降,接下来的代码总结仅供有基础的同学食用。

前置技术点(可跳过)

使用reduce链式获取对象属性的值

const obj = {
  name: 'zs',
  info: {
    address: {
      location: '北京顺义',
    },
  },
}
// 需求:通过以下字符串在obj中获取相应的属性值
const attrStr = 'info.address.location'

// const location = attrs.reduce((newObj, k) => { return newObj[k] }, obj)
// 第一次 reduce,
//        初始值是 obj 这个对象,
//        当前的 k 项是   info
//        第一次 reduce 的结果是 obj.info 属性对应的对象

// 第二次 reduce,
//        初始值是 obj.info 这个对象,
//        当前的 k 项是   address
//        第二次 reduce 的结果是 obj.info.address 属性对应的对象

// 第三次 reduce,
//        初始值是 obj.info.address 这个对象,
//        当前的 k 项是   location
//        第三次 reduce 的结果是 obj.info.address.location 属性的值

const location = attrStr.split('.').reduce((newObj, k) => newObj[k], obj)
console.log(location) //北京顺义

发布-订阅者模式

// 创建Dep类:作用是收集依赖/收集订阅者,并且触发订阅者的回调
class Dep {
  constructor() {
    // 这个 subs 数组,用来存放所有订阅者的信息
    this.subs = []
  }

  // 向 subs 数组中,添加订阅者的信息
  addSub(watcher) {
    this.subs.push(watcher)
  }

  // 发布通知的方法
  notify() {
    this.subs.forEach((watcher) => watcher.update())
  }
}

// 创建Watcher类:订阅者的类,每一个订阅者都要有update方法,用于收到通知后作出一些行为
class Watcher {
  constructor(cb) {
    this.cb = cb
  }

  // 触发回调的方法
  update() {
    this.cb()
  }
}

const w1 = new Watcher(() => {
  console.log('我是第1个订阅者')
})

const w2 = new Watcher(() => {
  console.log('我是第2个订阅者')
})

const dep = new Dep()
dep.addSub(w1)
dep.addSub(w2)

dep.notify()
// 我是第1个订阅者
// 我是第2个订阅者

使用Object.defineproperty进行数据劫持

const obj = {
  name: 'zs',
  age: 20,
}

Object.defineProperty(obj, 'name', {
  enumerable: true, // 当前属性,允许被循环
  configurable: true, // 当前属性,允许被配置 
  get() {
    // getter
    console.log("name属性被访问")
    return obj.name
  },
  set(newVal) {
    // setter
    console.log("name属性被修改")
    obj.name = newVal
  },
})

const name = obj.name //name属性被访问

obj.name = 'ls' //name属性被修改

双向数据绑定的流程(代码篇)

  1. new Vue()首先执行初始化,对data执行响应化处理,这个过程发生Observe中;
  2. 数据初始化完成后,对模板执行编译,这个过程发生Compile中;
  3. 进行模板编译的最后阶段,给每一种渲染方式创建watcher的实例,把渲染dom的关键操作封装为watcher的更新函数,这个过程发生在replaceNode函数中;
  4. new出来的每一个watcher的实例,都会被Dep依次收集起来;
  5. 将来data中数据⼀旦发生变化,会进入setter中,首先找到该属性对应的Dep,执行notify方法通知所有watcher执行更新函数。

实现一个简单的双向数据绑定代码如下,可以复制到本地运行,了解Vue是如何将数据劫持与发布订阅者模式结合起来的,其中有很多巧妙的细节,可以搭配视频仔细体会。

<body>
  <div id="app">
    <h3>姓名是:{{name}}</h3>
    <h3>年龄是:{{age}}</h3>
    <h3>info.a 是:{{info.a}}</h3>
    <h3>姓名:<input v-model="name" /></h3>
    <h3>年龄:<input v-model="age" /></h3>
    <h3>info.a :<input v-model="info.a" /></h3>
  </div>

  <script src="./vue.js"></script>
  <script>
    const vm = new Vue({
      el: '#app',
      data: {
        name: 'zs',
        age: 20,
        info: {
          a: 'a1',
          c: 'c1'
        }
      }
    })

    console.log(vm)
  </script>
</body>
//vue.js

class Vue{
  constructor(options){
    this.$data = options.data
    //调用数据劫持的方法
    Observe(this.$data)

    // 把this.$data上的属性代理到vm实例上 vm.$data.name => vm.name
    Object.keys(this.$data).forEach(key=>{
      Object.defineProperty(this,key,{
          enumerable: true,
          configurable: true,
          get() {
              return this.$data[key]
          },
          set(newValue) {
              this.$data[key] = newValue
          }
      })
    })

    //数据初始化,调用模板编译的函数渲染页面
    Compile(options.el,this)
  }
}

function Observe(obj){
  // 递归终止条件
  if(!obj || typeof obj !== 'object') return;
  const dep = new Dep()
  Object.keys(obj).forEach(key => {
    let value = obj[key]
    // 进行递归,给对象类型的数据的子属性也添加getter/setter
    Observe(value)
    // 把data中的所有数据转换为getter/setter
    Object.defineProperty(obj,key,{
      enumerable:true,
      configurable:true,
      get(){
        // Dep.target指向watcher实例,如果key属性有对应的订阅者,就把该订阅者收集起来
        Dep.target && dep.addSubs(Dep.target)
        return value
      },
      set(newValue){
        value = newValue
        Observe(value)
        // 属性被修改时,向每一个订阅者发出通知,触发渲染dom的回调
        dep.notify()
      }
    })
  });
}

function Compile(el,vm){
  //获取el对应的DOM元素
  vm.$el = document.querySelector(el)
  //创建文档碎片,提高DOM操作的性能
  const fragment = document.createDocumentFragment()
  while ((childNode = vm.$el.firstChild)) {
      fragment.appendChild(childNode)
  }
  //进行模板编译
  replaceNode(fragment)
  //模板编译结束后,渲染页面
  vm.$el.appendChild(fragment)

  function replaceNode(node){
    const regMustache = /\{\{\s*(\S+)\s*\}\}/
    // 对文本子节点进行正则的匹配与替换
    if(node.nodeType === 3){
      const text = node.textContent
      const execResult = regMustache.exec(text)
      if (execResult){
        const value = execResult[1].split('.').reduce((newObj,k) => newObj[k],vm)
        node.textContent = text.replace(regMustache,value)
        // 在这里,创建watcher的实例
        new Watcher(vm, execResult[1],(newValue)=>{
            node.textContent = text.replace(regMustache, newValue)
        })
      }
      return 
    }
    //如果是DMO节点 并且是输入框
    if (node.nodeType === 1 && node.tagName.toUpperCase() === 'INPUT'){
      //获取节点的所有属性
      const attrs = Array.from(node.attributes)
      const findResult = attrs.find(attr=>attr.name === 'v-model')
      if (findResult){
        const expStr = findResult.value  
        const value = expStr.split('.').reduce((newObj, k) => newObj[k], vm)
        node.value = value
        // 在这里,创建watcher的实例
        new Watcher(vm, expStr, (newValue) => {
            node.value = newValue
        })
        //实现双向绑定的关键步骤
        // 监听文本框的input输入事件,拿到文本框最新的值,把最新的值,更新到vm上即可
        node.addEventListener('input',(e)=>{
          const keyArr = expStr.split('.')
          const obj = keyArr.slice(0, keyArr.length - 1).reduce((newObj, k) => newObj[k], vm)
          console.log(obj);
          obj[keyArr[keyArr.length - 1]] = e.target.value
        })
      }
    }
    // 证明不是文本节点,可能是一个DOM元素,需要进行递归处理 递归获取所有纯文本节点
    node.childNodes.forEach(child => replaceNode(child))
  }
}

class Dep {
  constructor() {
    this.subs = []
  }

  addSubs(watcher) {
    this.subs.push(watcher)
  }

  notify() {
    this.subs.forEach(watcher => watcher.update())
  }
}


// 订阅者的类
class Watcher {
  constructor(vm,key,cb) {
    this.vm = vm
    this.key = key
    this.cb = cb
    // 下面三行负责把创建的Watcher 实例存到Dep实例的subs数组中
    // 这个步骤通过第二行代码访问ledata中的属性,触发了getter,巧妙的将watcher实例收集到dep中
    Dep.target = this
    key.split('.').reduce((newObj,k)=>newObj[k],vm)
    Dep.target = null
  }
  // 触发回调函数的方法 发布者通知我们更新
  update() {
    const value = this.key.split('.').reduce((newObj, k) => newObj[k], this.vm)
    this.cb(value)
  }
}

总结

双向数据绑定 = (数据劫持 + 发布订阅者模式) + 通过js监听dom事件反向给vm属性赋值