可收藏的 VUE双向数据绑定原理

104 阅读6分钟

在 JavaScript 中一共有两种方法实现数据的响应式,分别对应

  • vue2.x中的:对象属性拦截(Object.defineProperty)官方文档
  • vue3.x中的:对象整体代理(Proxy)官方文档

本文通过JavaScript源码分析 vue2.x对象属性拦截 的方法

语法:

Object.defineProperty(obj, prop, descriptor)

参数:

  1. obj:要定义属性的对象
  2. prop:要定义或修改的属性的名称或 Symbol
  3. descriptor:要定义或修改的属性描述符 详情参考官方文档!

1 数据响应式

简易的对象属性拦截:

1.定义一个字面量对象

let data = {
    name:'大熊猫'
}

2.定义Object.defineProperty对象

let data = {}
Object.defineProperty(data,'name',{
    // 当访问name属性时就会执行 get 中的方法 返回值就是获取到的值
    get(){
       return '大熊猫'
    },
    // 设置新值后就会执行 set 中的方法 newVal 就是设置了的新值
    set(newVal){
       console.log(newVal)
    }
})

Snipaste_2022-06-16_16-08-19.jpg

以上方法只是获取到了最新的值,但是 set函数 中拿到的新值没有做任何事情,当我们再次访问数据的时候还是原来的值

Snipaste_2022-06-16_16-09-32.jpg

优化1:使 get 和 set 联动起来

通过一个中间变量 _name 来中转化 get函数 和 set函数 之间的联动

let data = {}
let _name = '大熊猫'
Object.defineProperty(data,'name',{
    // 当访问name属性时就会执行 get 中的方法 返回值就是获取到的值
    get(){
       return _name
    },
    // 设置新值后就会执行 set 中的方法 newVal 就是设置了的新值
    set(newVal){
       console.log(newVal)
       _name = newVal
    }
})

Snipaste_2022-06-16_16-16-44.jpg

以上方法只能处理单个对象属性的响应数据,但是在实际开发中需要处理的对象属性不止一个

所以就需要使用到遍历方法(劫持方案的实现)

优化2:通用的数据劫持方案

let data = {
    name: '大熊猫',
    age: 6,
    height:100
}

// 遍历每一个属性 (ES6方法 Object.keys() 得到的是一个数组)
Object.keys(data).forEach((key)=>{
    // key 属性名 // data[key] 属性值 // data 原对象
    defineReactive(data,key,data[key])
})
// 响应式转化方法
function defineReactive(data,key,value){
    Object.defineProperty(data,key,{
        get(){
           return value
        },
        set(newVal){
          value = newVal 
        }
    })
}

Snipaste_2022-06-16_16-29-06.jpg 在每一次的defineReactive函数执行的时候,都会形成一块独立的函数作用域,传入的value 因为闭包的关系会常驻内存,这样一来,每个defineReactive函数中的value 会作为各自set和get函数操作的局部变量,实际上使用了闭包的特性

2 数据变化反应带视图

要想将数据反映到视图中,本质上是离不开 DOM 操作的

1.命令式操作视图

<div id="app">
  <p></p>
</div>
<script>
   let data = {
        name: '大熊猫',
        age: 6,
        height:100
    }
    // 遍历每一个属性
    Object.keys(data).forEach((key)=>{
        // key 属性名 // data[key] 属性值 // data 原对象
        defineReactive(data,key,data[key])
    })
    function defineReactive(data,key,value){
        Object.defineProperty(data,key,{
            get(){
               return value
            },
            set(newVal){
              // set函数的执行 不会自动判断俩次修改的值是否相等
              // 显然如果相等 不应该执行变化的逻辑
              if (newVal === value) {
                return
              }
              value = newVal 
              // 数据发生变化,操作dom进行更新
              document.querySelector('#app p').innerHTML = newVal
            }
        })
    }
  // 首次渲染
  document.querySelector('#app p').innerHTML = data.name
</script>

核心:通过获取到的最新的值,操作dom api 将最新的值设置上去,只要修改了对象属性值,就会触发 set函数并执行,然后将最新的值通过 dom 操作反应到视图中区

Snipaste_2022-06-16_16-56-18.jpg

修改后的视图变化:

Snipaste_2022-06-16_16-57-36.jpg

Snipaste_2022-06-16_16-58-07.jpg

3 视图的变化反应到数据

将data中的name属性对应的值渲染到input上面,同时input值发生修改之后,可以反向修改name的值

(在vue系统中,v-model指令就是干这个事情的)

通过监听事件变化,在事件触发的回调函数中拿到当前最新输入框的值,赋值到绑定的数据上

<div id="app">
  <input v-model="name" />
</div>
<script>
  let data = {
    name: '大熊猫',
    age: 6,
    height: 100
  }
  // 遍历每一个属性
  Object.keys(data).forEach((key) => {
    // key 属性名 // data[key] 属性值 // data 原对象
    defineReactive(data, key, data[key])
  })
  function defineReactive(data, key, value) {
    Object.defineProperty(data, key, {
      get() {
        return value
      },
      set(newVal) {
        // 数据发生变化,操作dom进行更新
        if (newVal === value) {
          return
        }
        value = newVal
        compile()
      }
    })
  }
  // 函数编译
  function compile() {
    let app = document.getElementById('app')
    // 1.拿到app下所有的子元素
    const nodes = app.childNodes   //  [text, input, text]
    //2.遍历所有的子元素
    nodes.forEach(node => {
      // nodeType为1为元素节点
      if (node.nodeType === 1) {
        const attrs = node.attributes
        // 遍历所有的attrubites找到 v-model
        Array.from(attrs).forEach(attr => {
          const dirName = attr.nodeName
          const dataProp = attr.nodeValue
          if (dirName === 'v-model') {
            node.value = data[dataProp]
            // 视图变化反应到数据 无非是事件监听反向修改
            node.addEventListener('input', (e) => {
              data[dataProp] = e.target.value
            })
          }
        })
      }
    })
  }
  // 首次渲染
  compile()
</script>

当跟新视图层数据的时候,再次访问数据时,已经更新了

Snipaste_2022-06-16_17-09-42.jpg

4 发布订阅模式

因为目前数据的跟新都太过于简单粗暴,不管你修改了哪个属性,其它属性也会一起跟着进行更新 哪怕你根本就没有动他,十分消耗计算性能

我们希望的是当只修改了name的时候 就应该只有name相关的更新操作才进行 而不是粗暴的吧所有的都更新一遍(这才是正常逻辑)

只有哪个属性进行了实质性的修改 哪个属性对应的‘编译’部分才得到执行

1.优化思路:

数据更新后实际需要执行的代码是

node.innerText = data[dataProp]

为了保存当前的node和dataProp,需要再次设计一个函数执行利用闭包函数将每一次编译函数执行时候的node和dataProp都缓存下来,所以每一次数据变化之后执行的是这样的一个更新函数

() => {
  node.innerText = data[dataProp]
}

一个响应式数据可能会有多个视图部分都需要依赖,也就是响应式数据变化之后,需要执行的更新函数可能不止一个,如下面的代码所示,name属性有俩个div元素都使用了它,所以当name变化之后,俩个div节点都需要得到更新,那属性和更新函数之间应该是一个一对多的关系

<div id="app">
   <div v-text="name"></div>
   <div v-text="name"></div>
   <p v-text="age"></p>
   <p v-text="age"></p>
</div>

<script>
  let data = {
     name: '大熊猫',
     age: 6
  }
</script>

经过分析我们可以得到下面的存储架构图,每一个响应式属性都绑定了相对应的更新函数,是一个一对多的关系,数据发生变化之后,只会再次执行和自己绑定的更新函数

Snipaste_2022-06-16_17-22-02.jpg

2.理解发布订阅模式(自定义事件)

就是 一对多 的关系

实现简单的发布订阅

// 增加dep对象 用来收集依赖和触发依赖(消息订阅器Dep)
const dep = {
    map: Object.create(null),
    // 收集
    collect(dataProp, updateFn) {
      if (!this.map[dataProp]) {
        this.map[dataProp] = []
      }
      this.map[dataProp].push(updateFn)
    },
    // 触发事件的方法
    trigger(dataProp) {
      this.map[dataProp] && this.map[dataProp].forEach(updateFn => {
        updateFn()
      })
    }
}

收集更新函数

// 函数编译
  function compile() {
    let app = document.getElementById('app')
    // 1.拿到app下所有的子元素
    const nodes = app.childNodes   //  [text, input, text]
    //2.遍历所有的子元素
    nodes.forEach(node => {
      // nodeType为1为元素节点
      if (node.nodeType === 1) {
        const attrs = node.attributes
        // 遍历所有的attrubites找到 v-model
        Array.from(attrs).forEach(attr => {
          const dirName = attr.nodeName
          const dataProp = attr.nodeValue
          console.log(dirName, dataProp)
          if (dirName === 'v-text') {
            console.log(`更新了${dirName}指令,需要更新的属性为${dataProp}`)
            node.innerText = data[dataProp]
            // 收集更新函数
            dep.collect(dataProp, () => {
              node.innerText = data[dataProp]
            })
          }
        })
      }
    })
 }

触发更新函数

function defineReactive(data, key, value) {
    Object.defineProperty(data, key, {
      get() {
        return value
      },
      set(newValue) {
        // 更新视图
        if (newValue === value) return
        value = newValue
        // 再次编译要放到新值已经变化之后只更新当前的key
        dep.trigger(key)
      }
    })
}

5 总结

数据响应式是通过Object.defineProperty来实现对象属性的拦截,而在vue3中使用Proxy对象代理方案进行了优化

专业名词

observe对象指的是把数据处理成响应式的对象

watcher指的就是数据变化之后的更新函数 (vue中的watcher有两种,一种是用来更新视图的watcher,一种是通过watch配置项声明的watcher)

compole主要是扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器

dep指的就是使用发布订阅实现的收集更新函数和触发更新函数的对象

指令实现的核心无非是通过模板编译找到标识然后把数据绑上去,等到数据变化之后再重新放一次

发布订阅模式的本质是解决一对多的问题,在vue中实现数据变化之后的精准更新