JS中响应式原理(Vue3,Vue2)实现

210 阅读9分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

什么是响应式

先看一下下面这段代码:

let count = 1
console.log(count)
console.log(count * 2)
count++

在初始化count的值后,我们分别对countcount * 2进行了打印,之后我们又对count进行了自增1的操作;那么如果我们希望count的值发生变动的时候,控制台打印countcount * 2的代码能够自动重新执行,这种可以自动响应数据变化的代码机制,我们就称之为是响应式。

那么我们在来看一下对象的响应式:

响应式函数的封装

对于一个响应式的变量,它对应需要执行的代码可能很多,那么我们可以将这些代码放到一个函数中,这样我们需要做的就是当变量的数据发生变化时,自动去执行这个函数。

而在实际开发中,我们会定义很多的函数,我们需要能够区分哪些函数需要响应式,因此我们可以封装一个新函数watchFn,这个函数的作用是接收一个函数作为参数,并将这个传入的函数标记为需要响应式的函数:

const reactiveFns = []
function watchFn(fn) {
  reactiveFns.push(fn)
}

const obj = {
  name: 'lzh'
}

function foo() {
  const newName = obj.name
  console.log('xixixi', obj.name);
}

function bar() {
  console.log('hhhh', obj.name);
}

function baz()  {
  console.log('这个函数不需要响应式');
}

watchFn(foo)
watchFn(bar)

obj.name = 'loftyamb'
reactiveFns.forEach(fn => fn())
// xixixi loftyamb
// hhhh loftyamb

依赖收集类的封装

在上面代码中,对于一个对象相关的依赖(需要响应的函数)我们都是将它们放入到一个数组中,并在对象属性发生变化的时候,去遍历这个数组执行其中的函数成员。

但在实际开发中,我们需要监听很多对象的多个属性的响应式,它们都需要进行保存,并在对象属性发生变化的时候执行相关的响应函数。为此,我们可以将这些操作封装成一个类,这个类的作用是管理对象的某个属性的所有响应式函数来替代我们之前定义的reactiveFns数组:

class Depend {
  constructor() {
    // 在内部还是通过一个数组来保存相关的依赖
    this.reactiveFns = []
  }
  // 添加依赖
  add(fn) {
    this.reactiveFns.push(fn)
  }
  // 执行相关的依赖
  notify() {
    this.reactiveFns.forEach(fn => fn())
  }
}

const obj = {
  name: 'lzh'
}
// 需要响应式的函数
function bar() {
  console.log('hhhh');
  console.log(obj.name);
}
function baz() {
  console.log('xixixi');
}
// 普通函数(不需要响应式)
function foo() {
  console.log('我是一个平平无奇的函数');
}

// 对对象相关依赖进行收集
const objDepned = new Depend()
function watchFn(fn) {
  objDepned.add(fn) 
}
watchFn(bar)
watchFn(baz)

// 对象属性发生变化
obj.name = 'loftyamb'
// 执行相关的响应式函数
objDepned.notify()

自动监听对象的变化

在上面的代码实现中,我们在每次对象属性发生变化的时候,都是自己手动去调用执行它的相关响应式函数,有没有办法能够让对象属性发生变化的时候就自动执行相关的响应式函数呢?

答:可以通过监听对象属性变化来实现。

方式一:通过Object.defineProperty的方式来实现(Vue2采用的方式);

方式二:通过new Proxy的方式(Vue3采用的方式);

// 依赖收集类
class Depend {
  constructor() {
    this.reactiveFns = []
  }
  add(fn) {
    this.reactiveFns.push(fn)
  }
  notify() {
    this.reactiveFns.forEach(fn => fn())
  }
}
const depend = new Depend()

// 响应式函数
function watchFn(fn) {
  depend.add(fn)
}

// 要监听的对象
const obj = {
  name: 'lzh'
}

// 生成代理对象
const objProxy = new Proxy(obj, {
  set(target, key, value, receiver) {
    Reflect.set(target, key, value, receiver)
    depend.notify()
  },
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  }
})
// 需要响应式的函数
function bar() {
  console.log('hhhh', objProxy.name);
}
function baz() {
  console.log('xixixi', objProxy.name);
}
// 普通函数(不需要响应式的函数)
function foo() {
  console.log('我是一个平平无奇的函数');
}
// 收集依赖
watchFn(bar)
watchFn(baz)

// 对象属性发生变化
objProxy.name = 'loftyamb'
// hhhh loftyamb
// xixixi loftyamb

依赖收集的管理

在到目前为止,我们的代码案例都是对obj对象中name属性来实现响应式,我们使用了一个depend对象,来对于管理name变化需要监听的响应函数;

但在实际开发中,我们会有多个的对象,在每个对象中也会有多个属性,它们都需要实现响应式,那么我们需要一种更加合理的数据结构来管理不同对象的不同依赖管理;

我们可以使用WeakMap来管理这些响应式的数据依赖,它的结构如图所示:

// 依赖收集类
class Depend {
  constructor() {
    this.reactiveFns = []
  }
  add(fn) {
    this.reactiveFns.push(fn)
  }
  notify() {
    this.reactiveFns.forEach(fn => fn())
  }
}
const targetMap = new WeakMap()

// 定义一个函数,这个函数用于获取一个对象某个属性相关的依赖
function getDepend(target, key) {
  let map = targetMap.get(target)
  if (!map) {
    map = new Map()
    targetMap.set(target, map)
  }
  let depend = map.get(key)
  if (!depend) {
    depend = new Depend()
    map.set(key, depend)
  }
  return depend
}

// 需要响应式的对象
const obj = {
  name: 'lzh',
  age: 23
}

// 设置对象监听
const objProxy = new Proxy(obj, {
  set(target, key, value, receiver) {
    Reflect.set(target, key, value, receiver)
    const depend = getDepend(target, key)
    depend.notify()
  },
  get(target, key, value, receiver) {
    Reflect.get(target, key, receiver)
  }
})

正确的依赖收集

在上面我们已经设计了一种比较合理的数据结构用来保存不同对象的不同属性的依赖,但是到目前为止,我们在收集依赖时都是通过调用watchFn函数去手动收集依赖,这在实际开发中显然是不现实的,所以我们需要实现依赖的自动收集。

我们知道在需要响应式的函数执行时,在这个函数内部会对一个响应式对象进行访问。因此,我们可以在这个对象访问时设置拦截,并将此时执行的函数收集进这个对象属性的依赖中。

// 依赖收集类:封装了对于一个对象属性依赖的收集和执行
class Depend {
  constructor() {
    this.depend = []
  }
  add (fn) {
    this.depend.push(fn)
  }
  notify() {
    this.depend.forEach(fn => fn())
  }
}

// 依赖收集管理
const targetMap = new WeakMap()
// 封装函数:获取一个对象的某个属性相关依赖
function getDepend(obj, key) {
  let dependMap = targetMap.get(obj)
  // 如果初次获取对象的依赖此时是不存在的,需要创建
  if (!dependMap) {
    dependMap = new Map()
    targetMap.set(obj, dependMap)
  }
  let depend = dependMap.get(key)
  // 如果该对象属性的依赖还没保存,需要创建
  if (!depend) {
    depend = new Depend()
    dependMap.set(key, depend)
  }
  return depend
}
// 依赖收集函数:调用该函数时会将该函数保存到对应的依赖中
let activeFn = null
function watchFn(fn) {
  activeFn = fn
  fn()
  activeFn = null
}


const obj = {
  name: 'lzh',
  age: 23
}
const objProxy = new Proxy(obj, {
  set(target, key, value, receiver) {
    Reflect.set(target, key, value, receiver)
    // 当对代理对象进行赋值的时候,需要通知执行相关依赖
    getDepend(target, key).notify()
  },
  get(target, key, receiver) {
    // 在获取代理对象时,对依赖进行自动收集
    if (activeFn) {
      // 需要判断activeFn是否为空,当为空的时候不对依赖进行收集
      // 比如:不通过watchFn而是直接执行一个函数时,这时候说明这个函数
      // 是一个普通的函数,并不需要响应式,也就不需要进行依赖收集
      const depend = getDepend(target, key)
      depend.add(activeFn)
    }
    return Reflect.get(target, key, receiver)
  }
})

function bar () {
  console.log('bar---', objProxy.name);
  console.log('bar---', objProxy.age);
}
function foo() {
  console.log('foo---', objProxy.age);
}
function baz() {
  console.log('baz---', objProxy.name);
}

watchFn(bar)
watchFn(foo)
baz()

console.log('objProxy数据发生了变化');
objProxy.name = 'loftyamb'
objProxy.age++

代码的执行结果:

我们可以看到,通过watchFn函数我们对需要响应式的函数barfoo进行的对应的依赖收集,故而在objProxy对象的nameage属性发生变化时,对应的响应式函数会自动再次执行,并使用nameage的当前值。而普通函数baz则只执行一次。

对Depend类的重构

在上面我们已经基本实现了对象的响应式,但是还存在一些问题:

  1. 如果我们在一个需要响应式的函数中多次用到一个对象的同个属性(比如上面的objProxy.name),那么在收集依赖时,这个函数由于访问了这个属性多次,因此会在添加依赖时被添加多次,但在实际上,这个函数只需要收集一次就可以了;
  2. 在通过给代理对象设置get拦截器从而收集依赖时,我们在其中做了一些关于依赖的判断,来将activeFn添加到依赖中;但我们希望将这样的操作放入到get中,因为它是属于Depend的行为;

因此,我们需要对Depend类进行以下重构:

  1. 需要保证添加依赖时不重复,我们可以使用Set来存放依赖而不是使用数组;
  2. 添加一个新方法,用于收集依赖,也就是activeFn;
let activeFn = null
// 依赖收集类:封装了对于一个对象属性依赖的收集和执行
class Depend {
  constructor() {
    this.depend = new Set()
  }
  collect () {
    if (activeFn) {
      this.depend.add(activeFn)
    }
  }
  notify() {
    this.depend.forEach(fn => fn())
  }
}

// 依赖收集管理
const targetMap = new WeakMap()
// 封装函数:获取一个对象的某个属性相关依赖
function getDepend(obj, key) {
  let dependMap = targetMap.get(obj)
  // 如果初次获取对象的依赖此时是不存在的,需要创建
  if (!dependMap) {
    dependMap = new Map()
    targetMap.set(obj, dependMap)
  }
  let depend = dependMap.get(key)
  // 如果该对象属性的依赖还没保存,需要创建
  if (!depend) {
    depend = new Depend()
    dependMap.set(key, depend)
  }
  return depend
}
// 依赖收集函数:调用该函数时会将该函数保存到对应的依赖中
function watchFn(fn) {
  activeFn = fn
  fn()
  activeFn = null
}


const obj = {
  name: 'lzh',
  age: 23
}
const objProxy = new Proxy(obj, {
  set(target, key, value, receiver) {
    Reflect.set(target, key, value, receiver)
    // 当对代理对象进行赋值的时候,需要通知执行相关依赖
    getDepend(target, key).notify()
  },
  get(target, key, receiver) {
    // 在获取代理对象时,对依赖进行自动收集
      const depend = getDepend(target, key)
      depend.collect()
    return Reflect.get(target, key, receiver)
  }
})

创建响应式对象

目前我们的响应式都是针对obj对象的,我们可以创建一个函数,这个函数接收一个对象,并返回一个响应式对象:

// 调用该函数使一个对象成为响应式
function reactive(obj) {
  return new Proxy(obj, {
    set(target, key, value, receiver) {
      Reflect.set(target, key, value, receiver)
      // 当对代理对象进行赋值的时候,需要通知执行相关依赖
      getDepend(target, key).notify()
    },
    get(target, key, receiver) {
      // 在获取代理对象时,对依赖进行自动收集
        const depend = getDepend(target, key)
        depend.collect()
      return Reflect.get(target, key, receiver)
    }
  })
}

Vue2的响应式原理

我们上面所实现的响应式代码,其实就是Vue3中的响应式原理:Vue3主要是通过Proxy来监听数据的变化以及收集相关的依赖的;

而在Vue2中,则使用的的Object.defineProperty的方式来实现对对象属性的监听;

我们可以将上面的reactive函数进行如下的重构:

  1. 在传入对象时,我们可以遍历它的所有key,通过设置属性存取描述符来监听属性的获取和设置;
  2. 在setter和getter方法中的逻辑和前面的Proxy是一致的;
// 调用该函数使一个对象成为响应式
function reactive(obj) {
  Object.keys(obj).forEach(key => {
    let value = obj[key]
    Object.defineProperty(obj, key, {
      get() {
        depend = getDepend(obj, key)
        depend.collect()
        return value
      },
      set(newValue) {
        value = newValue
        depend = getDepend(obj, key)
        depend.notify()
      }
    })
  })
  return obj
}