[Vue3源码剖析(三)]响应式原理

319 阅读6分钟

使用过vue3的同学都知道被reactive和ref包裹的数据,会变成响应式数据。通过推导式的方式从头实现一个响应式原理。

响应式基础

Proxy

如果我们希望监听一个对象的相关操作,那么我们可以先创建一个代理对象(Proxy对象); 之后对该对象的所有操作,都通过代理对象来完成,代理对象可以监听我们想要对原对象进行哪些操作;

vue3之所以不再沿用vue2的方法主要是由于使用object.defineProperty存在一些缺陷,比如它不能监听对象的新增属性和删除属性,同时也无法监听到通过索引改变数组元素的操作。

Reflect

如果我们有Object可以做这些操作,那么为什么还需要有Reflect这样的新增对象呢?

  • 所以在ES6中新增了Reflect,让我们这些操作都集中到了Reflect对象上;
  • Proxy配合Reflect使用,使用Reflect更有语义
  • 另外在使用Proxy时,可以做到不操作原对象;

Reflect常用方法

Reflect.get(target, propertyKey)

  • 获取对象身上某个属性的值,类似于 target[name]

Reflect.set(target, propertyKey, value)

  • 将值分配给属性的函数。返回一个Boolean,如果更新成功,则返回true。

Reflect.deleteProperty(target, propertyKey)

  • 作为函数的delete操作符,相当于执行 delete target[name]
const info = {
    name: '刘德华',
    age: 18
}

const infoProxy = new Proxy(info, {
    get(target, key) {
        console.log(`读取了${key}`);
        return Reflect.get(target, key)
    },
    set(target, key, value) {
        console.log(`将${key}的值修改为${value}`);
        Reflect.set(target, key, value)
    },
    deleteProperty(target, key) {
        console.log(`删除了${key}属性`)
        Reflect.deleteProperty(target, key)
    }
})

infoProxy.name // 读取了name

infoProxy.age = 99  // 将age的值修改为99

delete infoProxy.age // 删除了age属性

响应式原理

image.png

响应式effect函数的实现

响应式就是当响应式数据发生变化时,对应的依赖也跟着执行。

let info = {
  name: "peppa",
  age: 4,
}

function effectFn1() {
  console.log(info.name)
  console.log(info.age)
}

function effectFn2() {
  console.log(info.age * 2)
}

info.age = 10

effectFn1() // peppa // 10
effectFn2() // 20

可以看到我们每次需要对多个依赖进行调用,我们可以封装一个effect函数,对依赖进行保存,之后只需要遍历effectFns挨个调用就行。

let info = {
  name: "peppa",
  age: 4,
}

const effectFns = []
function effect(fn) {
  effectFns.push(fn)
}

effect(function effectFn1() {
  console.log(info.name)
  console.log(info.age)
})

effect(function effectFn2() {
  console.log(info.age * 2)
})

info.age = 10

effectFns.forEach((fn) => fn()) // peppa // 10 // 20

当然我们实现的effect还存在很多问题,在后续我们会不断进行优化

目前我们收集的依赖是放到一个数组中来保存的,但是这里会存在数据管理的问题:

  • 我们在实际开发中需要监听很多对象的响应式;
  • 这些对象需要监听的不只是一个属性,它们很多属性的变化,都会有对应的响应式函数;
  • 我们不可能在全局维护一大堆的数组来保存这些响应函数;

响应式依赖的收集

所以我们需要设计一个Depend类,用于管理某个对象中某个属性的响应式数据

class Depend {
  constructor() {
    this.effectFns = []
  }

  add(fn) {
    this.effectFns.push(fn) // 保存依赖
  }
  notify() {
    this.effectFns.forEach((fn) => fn()) // 遍历依赖执行
  }
}

重构effect函数

之前提到,我们为了能够对每个依赖进行正确的管理,不能将所有的依赖都保存在一个数组中,所以我们对之前的effect函数进行重构,以及对Depend这个类进行一定的修改。

class Depend {
  constructor() {
    this.effectFns = []
  }
  add() {
    if (activeFn) {
      this.effectFns.push(activeFn) // 3.将全局的fn保存到dep的effectFns中
    }
  }
  notify() {
    this.effectFns.forEach((fn) => fn())
  }
}

let activeFn = null 
function effect(fn) {
  activeFn = fn // 1.将fn保存在全局,用于在add中保存fn
  activeFn() // 2.此处调用activeFn的目的是为了触发监听的get方法,用于收集依赖(后续实现)
  activeFn = null // 4.保存完fn后置为null
}

监听对象的变化

在vue2中监听对象采用的方式是Object.defineProperty的方式,在vue3在选择了new Proxy的方式实现,在上面也提到了new Proxy相较于Object.defineProperty有哪些优势。我们可以封装一个reactive,之后将传入的对象能够转化为被监听的代理对象。

function reactive(target) {
  const proxy = new Proxy(target, {
    get(target, key) {
       ... // 收集依赖(后续实现)
      return Reflect.get(target, key)
    },
    set(target, key, value) {
       ... // 触发依赖(后续实现)
      Reflect.set(target, key, value)
    },
  })
  return proxy
}

对象的依赖管理

对象的依赖管理在响应式原理中,算是一个难点,这个难点攻克了其他都不是问题了。

◼ 我们目前是创建了一个Depend对象,用来管理对于info变化需要监听的响应函数:

  • 但是实际开发中我们会有不同的对象,另外会有不同的属性需要管理;
  • 我们如何可以使用一种数据结构来管理不同对象的不同依赖关系呢?

要让每一个响应式对象中的key都映射一个dep实例。这样当key发生变化时,就可以及时的通知与该key所有的依赖。所以我们通过一种特殊的数据结构来进行保存,

  • 全局有一个的targetMap,用于存放targetdepsMap的映射
  • depsMap这个map中,用于存放keydep的映射
  • 数据结构如下图所示
image.png
// 使用weakMap的好处是弱引用,如果将target置为null是可以销毁的
const targetMap = new WeakMap()
// 封装函数:根据target和key获取对应depend实例
function getDep(target, key) {
  // 根据对象target,知道对应的map对象
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  // 根据key,找到对应的depend对象
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Depend()))
  }
  return dep
}

收集与触发依赖

这一块就比较简单了,将对依赖的收集与触发执行时机添加到get和set中即可

function reactive(target) {
  const proxy = new Proxy(target, {
    get(target, key) {
      const dep = getDep(target, key)
      dep.add() // 收集依赖
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      const dep = getDep(target, key)
      Reflect.set(target, key, value) 
      dep.notify() // 触发依赖
    },
  })
  return proxy
}

完整代码

class Depend {
  constructor() {
    this.effectFns = []
  }
  add() {
    if (activeFn) {
      this.effectFns.push(activeFn) // 4.将全局的依赖存到effectFns中
    }
  }
  notify() {
    this.effectFns.forEach((fn) => fn()) // 6.对所有依赖遍历挨个执行
  }
}

const targetMap = new WeakMap()
function getDep(target, key) {
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Depend()))
  }
  return dep
}


let activeFn = null
function effect(fn) {
  activeFn = fn // 1. 将依赖设置到全局
  activeFn() // 2.执行fn(),用于触发get方法
  activeFn = null
}

function reactive(target) {
  const proxy = new Proxy(target, {
    get(target, key) {
      const dep = getDep(target, key) // 获取dep实例
      dep.add(activeFn) // 3.对依赖进行收集
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      const dep = getDep(target, key) // 获取dep实例
      Reflect.set(target, key, value) // 5.触发依赖
      dep.notify()
    },
  })
  return proxy
}


// ====================测试代码========================

let info = reactive({
  name: "peppa",
  age: 4,
})

effect(function effectFn1() {
  console.log(info.name)
  console.log(info.age)
})

effect(function effectFn2() {
  console.log(info.age * 2)
})

effect(function effectFn3() {
  console.log(info.name + "是ping pig")
})

info.age = 10

输出结果

image.png

总结

  1. vue中所有的依赖会被effect函数包裹,会将该依赖函数保存到全局的变量中,并且调用依赖函数,其目的是为了被属性的get劫持。
  2. 在get中通过一种特殊的数据结构,获取到该属性唯一的dep实例,将之前全局的依赖函数保存到dep实例中。
  3. 之后对响应式对象发生更新时,也就是会被set所劫持,在set中拿到dep实例,并且对实例挨个调用。