深入理解vue的响应式原理

4/22/2024 vue

# 前言

众所周知,vue2 是通过 Object.defineProperty 实现依赖监听的,vue3 是通过 proxy 实现依赖监听的。下面就两种方式进行讲解。

# vue2

# Object.defineProperty(obj, prop, descriptor) (opens new window)

在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。

  • obj,要定义属性的对象。
  • prop,一个字符串或 Symbol,指定了要定义或修改的属性键。
  • descriptor,要定义或修改的属性的描述符。
const obj = {};

Object.defineProperty(obj, "key2", {
  enumerable: false, // 是否可枚举,为false则不会在v-for等枚举方法中出现
  configurable: false, // 是否可配置,为false则不可被删除,该属性的类型不能在数据属性和访问器属性之间更改
  get() { // 访问 obj.key2 触发 ,比如 if(obj.key2 === 3) 或者 console.log(obj.key2)
    return bValue;
  },
  set(newValue) { // 设置值的时候触发,比如 obj.key2 = 2
    bValue = newValue;
  },
});

# 收集依赖

我们之所以要观察数据,其目的是当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。

在 Vue.js 2.0 中, 模板使用数据等同于组件使用数据, 所以当数据发生变化时, 会将通知发送到组件, 然后组件内部再通过虚拟 DOM 重新渲朵。

答案:先收集依赖, 即把用到数据 name 的地方收集起来, 然后等属性发生变化时,把之前收集好的依赖循环触发一遍就好了。总结起来, 其实就一句话, 在 getter 中收集依赖, 在 setter 中触发依赖。

# 依赖收集放在哪里

先假象一个数组,每次调用get时,我们都往这个数组放入这个依赖(可以想象成展示这个值的div节点位置),每次调用set时,我们就需要循环这个数组,来循环更新每个依赖(更新每个div节点里面的值)。

但是数组太粗糙了,所以抽象成一个类,这个类有一个subs数组,然后有添加依赖和移除依赖,向依赖发送更新的方法。

  class Dep{
    constructor(){
      this.subs = []
    }
    addSub(sub){
      this.subs.push(sub)
    }
    removeSub(sub){
      if(this.subs.length){
        const index = this.subs.indexOf(sub)
        if(index > -1){
          return this.subs.splice(index, 1)
        }
      }
    }
    depend(){ // 收集依赖,先拿window.target假装是依赖
      if(window.target){
        this.addSub(window.target)
      }
    }

    notify(){
      const subs = this.subs.slice()
      // slice 会返回原数组的浅拷贝
      for(let i=0, l = subs.length;i < l; i++) {
        subs[i].update()
      }
    }
  }

# 依赖是谁

依赖依赖,就是使用到 这个数据 的地方,收集依赖干嘛?在发生变化的时候告诉这些地方,变了。

但是使用数据的地方可能是某个div,也可能是用户写的一个watch,所以我们需要抽象出来一个类 Watcher。

# 什么是 Watcher

Watcher 是一个中介的角色,数据发生变化时通知它, 然后它再通知其他地方。

我们只需要把 watcher 实例添加到 需要监听的属性(比如 data.a.b )的 Dep 中就行了。当属性发生变化时,通知watcher,watcher再执行一个回调函数,告诉使用到数据的地方就行了。

下面的方法最难弄明白的已经通过序号标识了出来

export default class Watcher {
  constructor (vm, expOrFn, cb){
    this.vm = vm
    // 1. parsePath 会返回一个方法,调用这个方法会读取 expOrFn 的值
    this.getter = parsePath(expOrFn)
    this.cb = cb // 记录回调函数
    this.value = this.get()
  }

  get(){
    // 2. 把 watcher 放在 window.target 上面,然后读取 expOrFn 的值的时候会把 window.target 放入 Dep
    window.target = this 
    let value = this.getter.call(this.vm, this.vm); // 触发读取值的操作
    window.target = undefined
    return value
  }

  // 会在 监听的值变化时,调用此函数,此函数会触发回调方法 cb
  update(){
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue)
  }
}

parsePath 是读取属性值,内容如下

  const bailRE = /[^\w.$]/
  function parsePath(path){
    if(bailRE.test(path)){
      return
    }
    const segments = path.split('.') // 通过 . 分割路径
    return function (obj) {
      for(let i = 0;i < segments.length; i++) {
        if(!obj) return
        obj = obj[segments[i]]
      }
      return obj
    }
  }

可以看到他返回了一个方法,这个方法循环读取属性路径,直到读取我们监听的值。

Function.prototype.call() 以给定的 this 值和逐个提供的参数调用该函数。

function Product(name, price) {
  this.name = name;
  this.price = price;
}

function Food(name, price) {
  Product.call(this, name, price);
  this.category = 'food';
}

console.log(new Food('cheese', 5).name);
// Expected output: "cheese"

# 一个完整的实践

让我们完整的理一遍流程。

  1. 通过 Object.defineProperty 我们给某个值添加了监听依赖,这个步骤我们抽象出来了方法 defineReactive
  2. defineReactive 中有一个 Dep 对象,这个对象负责收集依赖(get里面收集),并在监听值变化的时候(set的时候)触发依赖的更新
  3. 依赖就是 Watcher 对象,每个用到这个值的地方,比如某个div的内容,或者某个watch对象,都是一个 Watcher 实例。
  4. Watcher 有一个 expOrFn 是监听值的路径,有一个 cb 是回调函数。
  5. Watcher 会在初始化时,通过路径读取监听值,从而触发 Dep 的收集依赖行为,Watcher 还把自己放到了 window.target 上面,方便 Dep 来收集他
  6. Watcher 有一个 update 方法,在 Dep 通知依赖更新的时候,会依次调用每个 Watcher 的 update 方法,update 又会调用之前的 cb 回调函数,从而触发更新

下面的实例预览会监听 data.a.b.c 的值,并把他放到 某个div 里面,当 data.a.b.c 变化时,更新div的内容。

# 数组的奇妙现象

push()、pop()、shift()、unshift()、splice()、sort()、reverse()这些方法会改变被操作的数组; filter()、concat()、slice()这些方法不会改变被操作的数组,返回一个新的数组; 以上方法都可以触发视图更新。

以下两种方法不可以触发视图更新;

  • 利用索引直接设置一个数组项,例:this.array[index] = newValue
    • 可以用this.$set(this.array,index,newValue)或this.array.splice(index,1,newValue)解决
  • 直接修改数组的长度,例:this.array.length = newLength
    • 可以用this.array.splice(newLength)解决

# vue3