你应该知道的Vue3响应式系统实现原理

216 阅读12分钟

Vue3响应式系统实现原理


一、响应式数据与副作用函数

副作用函数:指的是会产生副作用的函数 例如:

let name = ''
function effect() {
  name = 'hello Vue3'
}
effect()

当 effect 函数执行时,它会改变全局变量name的值,但除了 effect 函数之外的任何函数都可以读取或设置 name 的值。也就是说,effect函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用,所以effect就是一个副作用函数。

响应式数据: 假设在一个副作用函数中读取了某个对象的属性:

const obj = { name: 'name 初始值' }
function effect() {
  document.body.innerText = obj.name // 读取obj.name
}
effect()
//修改 obj.name
obj.name = 'hello Vue3' // 此时我们修改了 obj.name的值  我们希望副作用函数effect能够重新执行

如上面的代码所示,副作用函数 effect 会设置 body 元素的 innerText属性,其值为obj.name的值,obj.name的值发生变化时,我们希望副作用函数 effect 会重新执行 如果能够实现当obj的值改变了, 副作用函数effect能够自动重新执行, 那么obj就是一个响应式数据

那应该怎么让一个数据变成响应式数据呢?

仔细观察上面的代码,我们会发现两点核心操作

  1. effect副作用函数执行时, 会读取obj.name的值并赋值给body.innerText , 也就是会触发obj.name的读取操作
  2. 当修改obj.name的值是 , 会触发obj.named的 设置操作

这时候我们只要能劫持obj对象的读取和设置操作,是不是就能够实现让obj变成响应式obj了呢 推而广之,只要我们能劫持数据的读取和设置操作,就能实现让数据变成响应式数据

那如何劫持数据呢? 在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是Vue2所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue3所采用的方式。 我们现在研究是Vue3,因此我们就用Proxy 来实现


二、响应式系统实现

首先需要提供一个注册副作用函数的函数

// 变量activeEffect 用来保存注册的副作用函数
let activeEffect
/**
 * effect用来注册副作用的函数
 * @param fn 副作用函数
*/
function effect(fn) {
  // 把注册的副作用函数赋值给activeEffect
  activeEffect = fn
  fn() // 执行副作用函数
}

然后需要劫持数据的获取和设置操作 , 把数据变成响应式数据

/**
 * 把数据变成响应式数据的函数
 * @param data 数据
*/
const reactive = (data) =>{
  return new Proxy(data ,{
    //劫持数据的获取操作
    get(target , key){
      // TODO 做点什么
    }
    //劫持数据的设置操作
    set(target , key , newVal){
      // TODO 做点什么
    }
  })
}

现在我们已经有了 副作用函数 和 响应式数据了, 下面就需要把副作用函数和响应式数据关联起来, 实现数据变化了 副作用函数自动重新执行

那么怎样实现数据和副作用函数的关联呢?

首先我们思考数据和副作用函数的关系

/**
 *  定义一个数据, 它有name、age、 sex三个属性
 */

const data = {
  name: '张三',
  age: 18,
  sex: '男'
}

//注册一个 effect1 副作用函数 读取data的name和age属性
effect(function effect1(){
  document.body.innerText = `我叫${data.name},我今年${data.age}岁了`
})
//注册一个 effect2 副作用函数 读取data的name和sex属性
effect(function effect2(){
  document.body.innerText = `我叫${data.name},我是一个${data.sex}孩子`
})

观察上面的代码, 我们可以发现 数据和副作用函数存在如下关系


/**
 * data的name 属性关联了 effect1 和 effect2
 * data的age 属性关联了 effect1
 * data的sex 属性关联了 effect2
 *
*/

 data
   |- name
        |- effect1 
        |- effect2
   |- age
        |- effect1
   |- sex
        |- effect2

那怎么用代码来表达这个关系呢? 聪明的你肯定想到了, 想不到也没关系,下面直接告诉你Vue3怎么表达的 哈哈

//首先定义一个 weakMap 用来存储 对象和 key的关系
const reactiveMap = new WeakMap()


/**
 * reactive是基础的响应式api函数  把数据变成响应式数据的函数
 * @param data 数据
 * return proxy 代理对象
*/
const reactive = (data) =>{
  // 因为Proxy 只能劫持对象   所以不是对象  直接返回
  if(!(typeof data === 'object' && typeof data !== null)) {
    new Error('reactive只能代理对象')
  }
  const proxy =  new Proxy(data ,{
    //劫持数据的获取操作
    get(target , key , receiver){
      const result = Reflect.get(target, key, receiver)
      //如果没有副作用函数关联 直接返回数据
      if(!activeEffect) return result
      // 从reactiveMap中取出 存储的target  它是一个Map类型 里面存储 key 和 effect 的关联
      let depsMap = reactiveMap.get(target)
      // 如果不存在depsMap 那就新建一个 Map 和target 关联
      if(!depsMap) {
        reactiveMap.set(target, (depsMap = new Map()))
      }
      //再根据 key 从depsMap 中取出 key关联的 effect 集合  它是一个Set类型  key ---> Set
      let deps = depsMap.get(key)
      //如果 deps存在 就新建一个Set 和key 关联 里面存储 effect
      if(!deps) {
        depsMap.set(key, (deps = new Set()))
      }
      // 最后将注册的 副作用函数存入 Set
      deps.add(activeEffect)
      // 返回属性值
      return result
    },
    //劫持数据的设置操作
    set(target , key , newVal, receiver){
      const result = Reflect.set(target, key, newVal, receiver)
      //通过target 取出 key 组成的 Map
      let depsMap = reactiveMap.get(target)
      if(!depsMap) return result
      // 通过key 取出key 关联的 存储effect函数 的Set集合
      let deps = depsMap.get(key)
      //取出所有的key 关联的 effect执行
      deps && deps.forEach(fn => fn())
      return result
    }
  })
  return proxy
}

通过上面的代码, 我们可以发现Vue3是通过

WeakMap 存储 target(数据对象) ---> Map 建立数据和属性的依赖关系

Map 存储 key(数据对象属性) ---> Set 建立属性和effect函数的依赖关系

weakmap.png

记住并理解这个数据结构, 这是 Vue3实现响应式的核心

通过这个过程就收集了 数据 ---> 属性 ---> effect函数 之间的依赖关系

有了这个依赖关系 就可以在修改数据的时候 重新执行数据关联的 effect函数

我们测试一下


const data = {
  name: '张三',
  age: 18,
  sex: '男'
}
// 把data 变成响应式数据
const obj = reactive(data)


effect(()=>{
  document.body.innerText = `我叫${obj.name},我是一个${obj.sex}孩,我今年${obj.age}岁了`
})

//页面会展示  我叫张三,我是一个男孩,我今年18岁了

//修改name
obj.name = '李四'

//页面会展示  我叫李四,我是一个男孩,我今年18岁了

//修改age 
obj.age = 50

//页面会展示  我叫李四,我是一个男孩,我今年50岁了

//修改age 
obj.sex = '女'

//页面会展示  我叫李四,我是一个女孩,我今年50岁了

这样就实现了 修改数据 视图会自动更新 到这里,我们完成了基本的响应式 ,但是还不完善 , 比如 分支切换和 effect嵌套 还有问题


三、分支切换

如果代码里面有分支切换, 开始依赖了obj.name 分支切换之后又不依赖obj.name了, 这时还是会收集 obj.name的依赖 比如下面这段代码

const obj = reactive({
  name: 'Vue3',
  age: 2,
  isShowName: true
})

effect(()=>{
  document.body.innerText = obj.isShowName ? `大家好,我是${obj.name}` : `我今年${obj.age}岁了`
})
obj.isShowName = false
obj.age = 3

第一次执行effect的时候 obj.isShowName 为true 这时候会走第一个分支 会把effect收集为obj.name依赖 然后把 obj.isShowName修改为false 这时候会走第二个分支 这时候没有依赖obj.name了 按理说不应该把effect收集obj.name 的依赖

我们执行一下代码 看看结果

微信截图_sb.png

我们会发现, effect还是被obj.name收集了 , 这样就出现了不必要的收集 解决方案就是每次添加依赖前清空下上次的 依赖

我们需要记录effect 依赖被谁依赖了

我们改造下现有的 effect 函数:

// 变量activeEffect 用来保存注册的副作用函数
let activeEffect
/**
 * effect用来注册副作用的函数
 * @param fn 副作用函数
*/
function effect(fn) {

  const effectFn = () => {
      activeEffect = effectFn
      fn() // 执行副作用函数
  }
  // 记录下这个 effect 函数被放到了哪些 deps 集合里
  effectFn.deps = []
  effectFn()

}

对之前的 fn 用effectFn包一层,在effectFn函数上添加个 deps 数组来记录被添加到哪些依赖集合里

get 收集依赖的时候,也记录一份到这里:

//其他代码省略  只看get操作
get(target , key , receiver){
      const result = Reflect.get(target, key, receiver)
      //如果没有副作用函数关联 直接返回数据
      if(!activeEffect) return result
      // 从reactiveMap中取出 存储的target  它是一个Map类型 里面存储 key 和 effect 的关联
      let depsMap = reactiveMap.get(target)
      // 如果不存在depsMap 那就新建一个 Map 和target 关联
      if(!depsMap) {
        reactiveMap.set(target, (depsMap = new Map()))
      }
      //再根据 key 从depsMap 中取出 key关联的 effect 集合  它是一个Set类型  key ---> Set
      let deps = depsMap.get(key)
      //如果 deps存在 就新建一个Set 和key 关联 里面存储 effect
      if(!deps) {
        depsMap.set(key, (deps = new Set()))
      }
      // 最后将注册的 副作用函数存入 Set
      deps.add(activeEffect)
      // 记录副作用函数被哪些 deps集合 依赖了
      activeEffect.deps.push(deps)
      // 返回属性值
      return result
    },

这样下次再执行这个 effect 函数的时候,就可以把这个 effect 函数从上次添加到的依赖集合里删掉

// 变量activeEffect 用来保存注册的副作用函数
let activeEffect
/**
 * effect用来注册副作用的函数
 * @param fn 副作用函数
*/
function effect(fn) {

  const effectFn = () => {
      cleanup(effectFn)
      activeEffect = effectFn
      fn() // 执行副作用函数
  }
  // 记录下这个 effect 函数被放到了哪些 deps 集合里
  effectFn.deps = []
  effectFn()

}

function cleanup(effectFn){
  for(let i = 0, len = effectFn.deps.length; i < len; i++){
    // 从依赖effectFn 的deps集合中删掉自己
    effectFn.deps[i].delete(effectFn)
  }
  effectFn.deps.length = 0
}

最后在set的时候 新创建一个Set 集合来执行依赖 避免无限循环

//劫持数据的设置操作
set(target , key , newVal, receiver){
  const result = Reflect.set(target, key, newVal, receiver)
  //通过target 取出 key 组成的 Map
  let depsMap = reactiveMap.get(target)
  if(!depsMap) return result
  // 通过key 取出key 关联的 存储effect函数 的Set集合
  let deps = depsMap.get(key)
  //取出所有的key 关联的 effect执行
  
  // deps && deps.forEach(fn => fn()) //注释掉
  //新增这个逻辑  避免无限循环
  const effectRun = new Set(deps)
  effectRun && effectRun.forEach(fn => fn())
  return result
}

现在我们看见依赖已经被清除了

cq3.png


四、effect 嵌套

effect 嵌套就是effect函数里面 嵌套effect函数

比如下面的代码

effect(() => {
    console.log('外层effect', obj.name);
    effect(() => {
        console.log('内层effect',  obj.age);
    });
});

先解释一下我们为什么要处理嵌套的effect的依赖收集问题,因为我们的组件是会嵌套的,也就是父组件更新 子组件也要更新

现在我们还不能正确的收集嵌套的effect, 因为我们现在定一个effect栈来收集effect

执行 effect 函数前把当前 effectFn 入栈,执行完以后出栈,修改 activeEffect 为栈顶的 effectFn。

这样就保证了收集到的依赖是正确的。

// 变量activeEffect 用来保存注册的副作用函数
let activeEffect
//定义一个effect栈来收集effect
const effectStack = []
/**
 * effect用来注册副作用的函数
 * @param fn 副作用函数
*/
function effect(fn) {
  const effectFn = () => {
      cleanup(effectFn)
      activeEffect = effectFn
      effectStack.push(effectFn) //先让effectFn 入栈
      fn() // 执行副作用函数
      effectStack.pop() // 副作用函数执行完就出栈
      activeEffect = effectStack[effectStack.length - 1] // 最后让activeEffect函数指向栈顶元素
  }
  // 记录下这个 effect 函数被放到了哪些 deps 集合里
  effectFn.deps = []
  effectFn()
}

至此,我们的响应式系统就算比较完善了。

全部代码如下:

// 变量activeEffect 用来保存注册的副作用函数
let activeEffect
//定义一个effect栈来收集effect
const effectStack = []
/**
 * effect用来注册副作用的函数
 * @param fn 副作用函数
*/
function effect(fn) {
  const effectFn = () => {
      cleanup(effectFn)
      activeEffect = effectFn
      effectStack.push(effectFn) //先让effectFn 入栈
      fn() // 执行副作用函数
      effectStack.pop() // 副作用函数执行完就出栈
      activeEffect = effectStack[effectStack.length - 1] // 最后让activeEffect函数指向栈顶元素
  }
  // 记录下这个 effect 函数被放到了哪些 deps 集合里
  effectFn.deps = []
  effectFn()
}
//从依赖集合中删掉自己
function cleanup(effectFn) {
    for (let i = 0; i < effectFn.deps.length; i++) {
        const deps = effectFn.deps[i]
        deps.delete(effectFn)
    }
    effectFn.deps.length = 0
}
//首先定义一个 weakMap 用来存储 对象和 key的关系
const reactiveMap = new WeakMap()

/**
 * reactive是基础的响应式api函数  把数据变成响应式数据的函数
 * @param data 数据
 * return proxy 代理对象
*/
const reactive = (data) =>{
  // 因为Proxy 只能劫持对象   所以不是对象  直接返回
  if(!(typeof data === 'object' && typeof data !== null)) {
    return new Error('reactive只能代理对象')
  }
  const proxy =  new Proxy(data ,{
    //劫持数据的获取操作
    get(target , key , receiver){
      const result = Reflect.get(target, key, receiver)
      //如果没有副作用函数关联 直接返回数据
      if(!activeEffect) return result
      // 从reactiveMap中取出 存储的target  它是一个Map类型 里面存储 key 和 effect 的关联
      let depsMap = reactiveMap.get(target)
      // 如果不存在depsMap 那就新建一个 Map 和target 关联
      if(!depsMap) {
        reactiveMap.set(target, (depsMap = new Map()))
      }
      //再根据 key 从depsMap 中取出 key关联的 effect 集合  它是一个Set类型  key ---> Set
      let deps = depsMap.get(key)
      //如果 deps存在 就新建一个Set 和key 关联 里面存储 effect
      if(!deps) {
        depsMap.set(key, (deps = new Set()))
      }
      // 最后将注册的 副作用函数存入 Set
      deps.add(activeEffect)
       // 记录副作用函数被哪些 deps集合 依赖了
      activeEffect.deps.push(deps)
      // 返回属性值
      return result
    },
    //劫持数据的设置操作
    set(target , key , newVal, receiver){
      const result = Reflect.set(target, key, newVal, receiver)
      //通过target 取出 key 组成的 Map
      let depsMap = reactiveMap.get(target)
      if(!depsMap) return result
      // 通过key 取出key 关联的 存储effect函数 的Set集合
      let deps = depsMap.get(key)
      //取出所有的key 关联的 effect执行
      // deps && deps.forEach(fn => fn())
      //新创建一个Set集合来执行副作用函数  避免add、delete无限循环
      const effectRun = new Set(deps)
      effectRun && effectRun.forEach(fn => fn())
      return result
    }
  })
  return proxy
}

五、总结

3响应式最核心的原理就是 劫持数据的读取和设置操作

而为了达到数据改变自动更页面,我们设计了一套数据结构来让effect副作用函数和数据关联起来

数据结构的最外层是 WeakMap,key 为对象,value 为响应式的 Map。这样当对象销毁时,Map 也会销毁。

Map 里保存了每个 key 的依赖集合,用 Set 组织。

weakmap.png

我们通过 Proxy 监听对象的get和set操作来达到自动的依赖收集和派发更新,也就是添加 effect 到对应 key 的 deps 的集合里。set 的时候触发所有的 effect 函数执行。

这就是基本的响应式系统。

但是还不够完善,每次执行 effect 前要从上次添加到的 deps 集合中删掉它,然后重新收集依赖。这样可以避免因为分支切换产生的无效依赖。

并且执行 deps 中的 effect 前要创建一个新的 Set 来执行,避免 add、delete 造成的无限循环。

此外,为了支持嵌套 effect,需要在执行 effect 之前把它推到栈里,然后执行完出栈 ,最后让activeEffect始终指向栈顶元素,确保activeEffect指向最新的副作用函数 。

解决了这几个问题之后,就是一个完善的 Vue3 响应式系统了。

当然,现在虽然功能是完善的,但是没有实现 computed、watch 等功能。