Day40:vue原理

152 阅读11分钟

1.Vue2的响应式原理

在Vue2中,我们会把数据放到配置选项data中,一旦我们这样做了,我们心里清楚此时一旦视图中的操作修改了data中的数据,视图会立刻(这个立刻仍然需要等待一个异步更新队列 在nextTick中讨论过这个问题)自动更新。

似乎data的每个数据都被“劫持”了,一旦它被修改为了一个新值,Vue就能监测到这个变化通知视图更新。

在ES5的规范中,提供了一个API叫做Object.defineProperty(),它可以为对象属性添加getter/setter,从而对对象的存值和取值行为进行拦截,而这个API即是Vue2响应式原理的实现核心,由于这个API是ES5中一个无法被Babel模拟的属性,所以Vue无法支持像IE8等低版本浏览器。

每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染,当然今天我们不会关注这个通知的过程,我们重点在于关注响应式原理的实现。

模板编译的时候使用了虚拟dom,再把被改变的虚拟dom转为真实dom,就改变了视图

2.Vue2检测变化的注意事项

Vue2在检测数据和对象变化时,是存在一些不足的,之前我们提到过,这里再来回顾下,我们后面手写原理实现时需要考虑到。

2.1 检测对象

Vue2是无法检测到数据的添加或者删除的,所以我们在初始化数据的时候推荐把可能用到的响应式数据都设置到data中。

对于已经创建的实例,Vue 不允许动态添加或删除根级别的响应式数据。如果你希望响应式地添加或者删除,可以调用Vue.set或者Vue.delete来嵌套添加。

var vm = new Vue({
  data:{
    a:1
  }
})
Vue.set(vm,'b',2) //这么做是错误地  因为'b' 被视作了根级别地数据
var vm = new Vue({
  data:{
    msg:{
      a:1
    }
  }
})
vm.msg.b=2 //不具有响应性
Vue.set(vm.msg,'b',2) //这样做是符合Vue规范地

2.2 检测数组

Vue2中是无法检测数组的以下变化的,也不能直接通过修改数组索引的形式来修改数组,只能使用方法修改;也不能直接修改数组长度来修改数组

  • 利用索引直接设置一个数组项
  • 修改数组的长度

数组有七个修改的方法:push pop shift unshift splice reverse sort

var vm = new Vue({
  el: '#app',
  data() {
    return {
      list: [1, 2, 3, 4, 5],
    }
  },
})
vm.list[0] = 10 //直接通过索引值修改某一项无法实现响应性
vm.list.splice(0, 1, 10) //利用数组方法可以实现响应性
// 改第一个数,改一个,改成10
vm.list.length = 2

实现数组的响应性必须调用常见的改变数组的方法来实现,而这些方法在Vue2中是经过重新包装的,我们在手写原理实现的时候需要单独处理。

3.手写Vue2响应式原理

了解完毕后,我们来实现一个简易版本的响应式原理。明确一下,示例代码中的一些函数名称尽量和Vue源码中保持一致,但具体实现要简单很多。

import newArrayPrototype from './3.array.js' //引入重写数组的方法
//定义一个起始数据data data有可能是一个函数 也可能是一个对象 所以需要做一个判断  
data = typeof data === 'function' ? data() : data
//然后定义一个方法 用于观测数据 
observe(data)
function observe(data){
  // 数据是基本数据类型 直接终止
  if (typeof data !== 'object' || typeof data === null) return
  //如果数据是引用值类型 我们就需要分为对象和数组2大类 来单独处理
  if (Array.isArray(data)) {
    observeArray(data) //进一步观测数组中的每一项
    //这里重写部分数组方法 实现数组操作的响应性 这里的思路是在数组的原型链上多加一层 因为除开重写的方法 还有其他数组方法不能被丢失 
    data.observeArray=observeArray //给自身挂载一个观测数组的方法 在下面会用到
    data.__proto__= newArrayPrototype  //这个newArrayPrototype 可以放到单独文件中编写实现 然后引入 
  } else {
    walk(data) //对象的处理方法
  }
}
//先处理对象 实现walk方法
function walk(data){
  //对象数据 需要对data中的每一个数据都实现响应式  所以需要遍历
  Object.keys(data).forEach((key) => {
    defineReactive(data, key, data[key]) //这里的defineReactive即是实现响应式原理的核心方法
  })
}
function defineReactive(data, key, value) {
  observe(value) //对象内部可能存在多层嵌套 所以要递归观测
  Object.defineProperty(data, key,{ //这里就调用核心API来实现数据劫持
    get() {
      return value
    },
    set(newValue) {
      if (newValue === value) return
      observe(newValue) //修改后的数据也要观测 变成响应式的
      value = newValue
      console.log('视图更新成功') //这里不关注是如何通知视图的 用一句输出语句代替
    },
  })
}
//再来处理数组
function observeArray(data){ //数组中的具体每一项仍旧需要观测 
  data.forEach((item) => {
    observe(item)
  })
}
const oldArrayPrototype = Array.prototype 
//数组本身的方法需要拷贝一份 因为有些方法没有重写 需要保留下来
const newArrayPrototype = Object.create(oldArrayPrototype) 
//在data和Array中间加了一层原型链
let arrayLists = [ //这是7个改变数组的方法 需要重写 其他数组方法不用重写
  'push',
  'pop',
  'shift',
  'unshift',
  'reverse',
  'sort',
  'splice',
]
arrayLists.forEach((item) => {
  newArrayPrototype[item] = function (...arg) {
    const result = oldArrayPrototype[item].call(this, ...arg) //实现数组方法本身的功能
    let inserted  //有3个方法 push unshift splice 会加入新数组 这些数组也需要被观测
    switch (item) {
      case 'push':
      case 'unshift':
        inserted = arg
        break
      case 'splice':
        inserted = arg.slice(2)
      default:
        break
    }
    if(inserted){
      this.observeArray(inserted) //这里的this 指向data 
    }
    console.log('视图更新成功') //通知视图更新
    return result
  }
})
export default newArrayPrototype

再来总结一下刚才实现的几个核心方法:

  • observe 用来观测数据 判断数据的类型 对象和数组需要做不同的处理
  • observeArray 进一步观测数组中的每一项数据
  • newArrayPrototype 重写数组方法 在数组原型链上多加的一层原型对象
  • walk 遍历对象的每一个属性 添加响应式
  • defineReactive 响应式的具体实现 通过getter/setter 实现数据拦截

写完之后,我们可以来到浏览器中验证一下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      let data = {
        name: '张三',
        friends: ['李四', '王五'],
        info: { age: 18, tall: 180 },
      }
    </script>
    <script src="./3.手写Vue2响应式原理.js" type="module"></script>
  </body>
</html>

书写一个vue2的响应式原理

import newArrayPrototype from './array'
// 对data数据进行判断
data = typeof data === 'function' ? data() : data
  1. 定义一个方法  用来观测数据,如果你是基本数据类型,不管;如果不是,就再进行判断
  2. 在observe方法中,判断data对象的类型是对象类型还是数组类型
    1. 如果不是对象或者数组,直接终止观测
  1. 如果data是数组:
    1. 对于数组,推荐使用七个方法push pop shift unshift splice reverse sort
    2. vue底层对这七个方法进行了重写,vue的push实现了两个功能,push的原功能以及通知视图更新功能
    3. 使用if判断和isArray方法,判断对象是否为数组,如果data是数组.观测数组,可能有多个数组元素,进行遍历。并为data添加数组响应式添加方法observeArray
    4. 将定义好的数组方法重写方法从array文件中引入,并挂载在data上,可以在array文件中使用
    5. 首先在vue文档中对每一个数组元素进行观测,为其添加响应式
    6. 进入array文件
    7. newArrayPrototype是我们在data和array.prototype的原型链中间新增一个原型对象,劫持并改变原有的针对数组的方法。原型对象运行的结构为:data--> newArrayPrototype -->array.prototype。原型对象的访问是逐层的,访问到newArrayPrototype能调用,就不再向上访问到原有的方法了。
    8. newArrayPrototype初始化为一个空对象,再让他上层原型链指向array,它表示新增方法与原始方法之间的链接
    9. 首先进行遍历数组元素,并且使用..arg接收所有传参
    10. newArrayPrototype中对七个数组方法进行重写,需要实现数组方法原有功能并通知更新
    11. 导出newArrayPrototype方法,并挂载在vue文档的对象类型判断为数组后
  1. 如果data是个对象:
    1. 书写walk方法,在walk方法中遍历对象(拿到key属性 value属性值),添加响应式
    2. 定义方法defineReactive给每一个key value 添加响应性
    3. 注意:[key]这里不能点,点意味data上有个key的属性,但它不是属性是一个形参,所以只能用中括号访问
    4. 在方法defineReactive中,再调用一次定义的observe,这是函数递归用法,对传值进行递归观测。
    5. 递归观测会再做一次判断,对每一组key的value进行深度观测和判断,是基本数据类型还是引用值,就能对所有深度内容添加响应式了
    6. 给每个key value添加响应性/ 数据劫持,劫持了添加操作
    7. 调用实现响应性的核心api,defineProperty() (mdn文档里可以查)
    8. 使用Object.defineProperty,传入三个参数,第三个参数为get,set方法构成
    9. 进入set表示要修改data[key],修改值本身后,通知视图更新,不修改就不更新
    10. 如果修改后的数据(newvalue)是一个引用值的话,还需要重新深度观测
    11. 注意:是否被深度观测到的数据即时内容与形式一样,在程序中仍然是不等价的

在定义数组处理方法时,出现了在数组中添加或删除元素的方法splice,在此复习一下

splice() 方法用于添加或删除数组中的元素。

如果删除一个元素,则返回一个元素的数组。 如果未删除任何元素,则返回空数组。

参数描述
index必需。规定从何处添加/删除元素。 该参数是开始插入和(或)删除的数组元素的下标,必须是数字。
howmany可选。规定应该删除多少元素。必须是数字,但可以是 "0"。 如果未规定此参数,则删除从 index 开始到原数组结尾的所有元素。
item1, ..., itemX可选。要添加到数组的新元素

4.Vue3的响应式原理

Vue3响应式原理被重新实现了,不再是利用Object.defineProperty这个核心API来实现响应式了,它利用了ES6的一个新的内置类Proxy来实现,而且解决了Vue2中检测数据变化的一些问题(不能添加删除对象属性 不能通过索引操作数组)

在这里我们不会探讨Proxy本身的内容,在日常业务开发中我们几乎不会直接操作Proxy。大家只需要明白Proxy 是一个对象,它包装了另一个对象,并允许你拦截对该对象的任何交互。

基于这一点,我们同样可以实现数据的响应式,而且更加完美。通过具体的代码实现,我们对于Proxy本身也会有一定的了解。

5.手写Vue3的响应式原理

刚刚Vue2的实现其实已经非常接近真实源码的实现,Vue3中我们更多需要体会一下Proxy对于数据是如何“劫持”的。

正式书写代码之前,我们仍需要验证一件事,就是Vue2中检测数据变化的问题(不能添加删除对象属性 不能通过索引操作数组)是不是真的被完全解决了。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app">
      <p>{{msg.a}}--{{msg.b}}</p>
      <p v-for="item in list">{{item}}</p>
    </div>
    <script src="vue3.js"></script>
    <script>
      var app = Vue.createApp({
        data() {
          return {
            list: [1,2,3],
            msg: {
              a: 1,
            },
          }
        },
      }).mount('#app')
    </script>
  </body>
</html>

通过验证,Vue3中的响应式确实更加强大,现在利用Proxy来尝试实现它,Vue3中提供了reactive方法内部即是利用Proxy来实现的,可能有的同学会疑惑那不是还有一个ref吗,ref处理基础数据类型的时候仍然是沿用Vue2的那一套方法,而且基础数据也不存在操作对象和数组的问题。

声明一点,Vue3的代码实现更多是希望能理解到和Vue2的区别变化,没有像Vue2实现那样考虑到很多的细节,这一点希望大家能够注意。

function reactive(data) { //定义自己的reactive
  const proxy = new Proxy(data, { //利用ES6新增的Proxy类 来创造一个代理对象
    get(_data, propName) { //对代理对象任何属性的访问都会走 get
      console.log(`你访问了${propName}属性`)
      return _data[propName]
    },
    set(_data, propName, newValue) { //对代理对象任何属性的修改和增加都会走 set
      if (newValue !== _data[propName]) {
        _data[propName] = newValue
        console.log('视图更新成功')
      }
    },
    deleteProperty(_data, propName) { //对代理对象任何属性的删除都会走deleteProperty
      delete _data[propName]
      console.log('视图更新成功')
    },
  })
  return proxy
}

然后尝试用自己实现的reactive来获得一个数据,通过修改这个数据我们在控制台看到响应式也完美实现了。

let data = {
  name: '张三',
  age: 18,
  tall: 20,
}
let proxyData = reactive(data)

对比一下Proxy和Object.defineProperty,我们能总结一下其中的优势:

  • 代码本身的实现要简洁很多 Vue2中定义了大量函数方法反复调用 Vue3几乎都没有
  • Vue2中需要对data中每个数据进行递归遍历添加getter/setter,Vue3通过Proxy能一次性实现代理。
  • Vue2中不能直接增删对象数据,不能通过索引值操作数组,Vue3中也不存在了这些问题。
  • ....

实现vue3

ref reactive 添加响应式

vue2中对象底层通过数据劫持实现,defineProperty

ref定义的数据,如果是基本值类型,他的响应式原理,仍然是vue2的数据劫持,

如果ref定义的是复杂数据类型,ref.value => reactive,所以主要是要实现reactive

reactive在vue3中主要是通过数据代理实现,Proxy能够实现对原始数据的代理

  1. 首先定义自己的reactive方法,添加一个起始数据data
  2. 利用ES6新增的Proxy类 来创造一个代理对象,实现对原始数据的代理
    1. Proxy并不关注具体value,直到开始设置某个值
    2. vue2中要做递归观测,这个操作需要消耗大量计算性能,vue3中采用了对象代理的形式,不用进行遍历操作,节约了性能
  1. 对代理对象任何属性的访问都会走 get
    1. 两个参数分别为data和key,所以通过中括号的形式访问key
  1. 对代理对象任何属性的修改和增加都会走 set
    1. 如果newValue和key不相等,则为新值,则修改值,并且通知视图更新
  1. 对代理对象任何属性的删除都会走deleteProperty,vue2中没有对应的删除,只能通过其他方法进行删除
  2. 最终将操作的代理对象返回

如何实现深度代理-->

// 创建一个深度代理函数
function deepProxy(object, handler) {
  if (isComplexObject(object)) {
    addProxy(object, handler)
  }
  return new Proxy(object, handler)
}
// 新增代理函数实现
function addProxy(obj, handler) {
  for (let i in obj) {
    if (typeof obj[i] === 'object') {
      if (isComplexObject(obj[i])) {
        addProxy(obj[i], handler)
      }
      obj[i] = new Proxy(obj[i], handler)
    }
  }
}
// 判断是不是是一个对象
function isComplexObject(object) {
  if (typeof object !== 'object') {
    return false
  } else {
    for (let prop in object) {
      if (typeof object[prop] == 'object') {
        return true
      }
    }
  }
  return false
}

function reactive(data) {
  const proxy = deepProxy(data, {
    get(_data, propName) {
      console.log(`你访问了${propName}属性`)
      return _data[propName]
    },
    set(_data, propName, newValue) {
      if (newValue !== _data[propName]) {
        _data[propName] = newValue
        console.log('视图更新成功')
      }
    },
    deleteProperty(_data, propName) {
      delete _data[propName]
      console.log('视图更新成功')
    },
  })
  return proxy
}

通知视图更新的实现在本次书写中没有实现,