巧妙实现一个Vue3的响应式系统,深入理解核心问题

2,026 阅读7分钟

目标

看完之后你能明白Vue3响应式的底层,以及手动实现一个简单版本的响应式系统。

Vue3 和 Vue2的响应式对比

1.在以往的Vue2设计中Object.defineProperty监听数据中整个对象时,对象的嵌套是需要不断Object.keys去遍历来解决响应问题,而proxy可以在嵌套对象中递归到对应的区域直接使用Reflect.get获取。

2.其次Object.defineProperty是无法对数组增删改查,对象的属性修改,增加属性等,在实际开发中非常容易遇到,Vue2的设计者通过变异方法重写了常见的数组使其能够监听数组,通过$set来监听对象操作。proxy就不存在这个问题。

3.同时Proxy的handler内部的api多达十几种,拓展性更强,但是不兼容IE。

基本实现

首先我们需要监听一个数据,我们平常开发会经常改变数据里面的属性,我们需要将所有被这些属性依赖的副作用函数都收集在一个容器中,那么只要数据改变时,就要将其中所有的副作用函数执行。同时这也能帮助我们在拆分组件不同依赖,也能关联到对应的数据产生影响。像vue3的很多api基于Proxy的代理对象实现,如compoted,watch等都是以此为基础扩展。

const obj = {text: 'hello'}
let a = null
function effect() {
  a = obj.text
}

obj就是我们要响应式的数据,effect就是我们的监听属性改变后执行的业务代码,目标:我们需要的就是一旦我obj.text的值改变,那么就调用effect。

基于proxy的简易响应式

// 你需要收集所有依赖的副作用函数的集合
const bucket = new Set()

const data = {text: 'hello world'}
const obj = new Proxy(data, {
  get(target, key) {
    bucket.add(effect)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    bucket.forEach(fn => fn())
    return true
  }
})

let a = null
const effect = () => {
  a = obj.text
}
effect()

setTimeout(() => {
  obj.text = 'change'
  console.log(a);  // 一旦你改变text,对应的effect会执行改变a为change
}, 1000);

本质就是通过proxy代理的obj对象来实现get,set的监听,并将函数名为effect对应的依赖放入Set数据结构的容器中。

问题1: 如何解决effect副作用函数的命名冲突?

let activeEffect  //全局依赖的副作用函数
function effect(fn) {
   activeEffect = fn
   fn()
}

那么我们改写下effect就可以了,将依赖都传递统一收集到这个effect函数,本质就是一个闭包,此时我们只需要关注activeEffect,根本不需要关注命名问题。

问题2:改变不存在的属性,effect竟然也执行了

这个问题就涉及到数据结构的问题了,Set结构作为收集依赖的容器,没有处理以下问题:

  1. 同个对象的同个属性依赖多个函数
  2. 同个对象的不同属性依赖同个函数
  3. 不同对象的不同属性依赖同个函数
  4. ....... 那么其实我们只需要一个树状的结构,给每个属性设立单独的effectFnList,任何数据的变化只需要遍历对应属性内部的函数执行就可以了。所以对应的数据结构关系是 不同的target-----多个key-----多个effect集合

完善的响应式

const bucket = new WeakMap()

let activeEffect
function effect (fn) {
  activeEffect = fn
  fn()
}
const data = { text: 'hello world' }
const obj = new Proxy(data, {
  //获取数据时
  get (target, key) {
    if (!activeEffect) return

    //获取对应的数据结构  taraget------key--------effectFn
    let depsMap = bucket.get(target)
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    //放入依赖
    deps.add(activeEffect)
    return target[key]
  },
  //修改数据时
  set (target, key, newVal) {
    target[key] = newVal
    let depsMap = bucket.get(target)
    if (!depsMap) return
    let effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
  }
})
let a = null
const effectFn = () => {
  a = obj.text
}
effect(effectFn)

setTimeout(() => {
  obj.text2 = 'change'  //修改不存在属性,不会影响
  console.log(a);
}, 1000);   

知识点: 容器为什么要用WeakMap而不是Map数据结构呢?

先下结论:WeakMap的最大作用是避免了内存溢出,即插即拔,用完了就让自动被垃圾回收器回收。而map如果不手动清除,那么会一直占用内存

关于WeakMap你需要知道的:

  1. WeakMap通过弱引用解决内存占用,不用手动释放。
  2. WeakMap是map的实例,map有的api,它也有
  3. WeakMap一旦执行完退出作用域他是会被回收的,以这个特性可以用在dom卸载、定时器、闭包等中使用,要注意此时外部环境无法引用

关于Map你需要知道的:

  1. Map字典集合出现是为了应为不同类型的数据存储的问题,symbol,set,函数都可以作为其key值,当然在大量处理属性的时候其性能会高很多

其实这里容器用WeakMap,本质其实为了性能,减少不必要的释放内存操作。同时也能使用更多不同数据结构充当key值。

问题3: 如果在effect函数有分支语句,依赖的集合竟然全都收集了!

const bucket = new WeakMap()

let activeEffect
//解决重命名, 将函数
function effect (fn) {
  //effectFn对应每个属性的诺干个依赖
  const effectFn = () => {
    //当这个函数执行,将此为依赖的副作用函数
    cleanup(effectFn) //完成清除工作
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []  //直接在函数上挂载deps数组
  console.log(effectFn);
  effectFn()
}
const data = { text: 'hello world', ok: true }
const obj = new Proxy(data, {
  //获取数据时
  get (target, key) {
    if (!activeEffect) return

    //获取对应的数据  taraget------key--------effectFn
    let depsMap = bucket.get(target)
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    //放入依赖
    deps.add(activeEffect)

    activeEffect.deps.push(deps)  //将对应的依赖放到全局的activeEffect的effect收集起来
    return target[key]
  },
  //修改数据时
  set (target, key, newVal) {
    console.log("好像互不影响了");
    target[key] = newVal
    let depsMap = bucket.get(target)
    if (!depsMap) return
    let effects = depsMap.get(key)
    //effects && effects.forEach(fn => fn())   需要注意这里的effect执行会响应式死循环
    //我们需要再创建一个深拷贝的数据
    const effectsToRun = new Set(effects)
    effectsToRun.forEach(fn => fn())
  }
})
let a = null
const effectFn = () => {
  a = obj.ok ? obj.text : 'not'  //分支语句影响
}
effect(effectFn)

setTimeout(() => {
  obj.text2 = 'change' 
  console.log(a);
}, 1000);

//每次执行前将对应依赖清空
function cleanup (effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i] //这里的每一项是一个set类型
    deps.delete(effectFn) 
  }
  effectFn.deps.length = 0
}

当我们在effectFn副作用函数中,内部的三元表达式执行obj.ok和obj.text其实都会触发get读取,直接导致后期我如果改变了值,那么就会导致两个effectFn触发。这里主要的解决方案就是每次我get监听到数据的变化时,我用一个deps数组去记录所有的依赖,只要在被其他干扰的副作用函数执行的时候清空对应的deps,这里的清空用cleanup函数执行,那么就不会影响到了。

问题4 组件嵌套时的响应式为什么跟我想的渲染不一样?

组件渲染的多级嵌套,在render函数渲染的初始化中,我们一旦某个层级内部某个属性修改,我们只会触发对应依赖各个层级的effect函数集合去执行,但是当我修改外部层级的属性,反而让最深处的effect执行了。

effect(() => {
    Foo1.render()   //当我修改Foo组件内的属性,反而内部Foo2组件的effect重新执行了
    effect(() => {
       Foo2.render()  
    })
})

问题的本质就是activeEffect这个变量一直是全局变量,一旦内部响应式触发收集依赖那么activeEffect就被覆盖了,所以这里一定通过一个栈的结构,先进后出。刚进入函数执行推入栈中,一旦执行完毕就从栈顶移除,同时将activeEffect改成当前栈顶元素。下面是代码实现:

let activeEffect
let effectStack = []  // 这里用一个栈结构

function effect (fn) {
  function effectFn () {
    cleanup(effectFn)
    activeEffect = effectFn  //将依赖挂载到全局
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }

  effectFn.deps = []  //将对应的依赖收集到deps数组中
  effectFn()
}

最终实现的响应式


const bucket = new WeakMap()
//存储被注册的副作用函数(全局状态下)
let activeEffect
//解决重命名, 将函数
function effect (fn) {
  //effectFn对应每个属性的诺干个依赖
  const effectFn = () => {
    //当这个函数执行,将此为依赖的副作用函数
    cleanup(effectFn) //完成清除工作
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []  //依赖函数挂载deps数组
  effectFn()
}
const data = { text: 1, ok: true }
const obj = new Proxy(data, {
  //获取数据时
  get (target, key) {
    track(target, key)
    return target[key]
  },
  //修改数据时
  set (target, key, newVal) {
    target[key] = newVal
    trigger(target, key)
  }
})

//每次执行前将对应依赖清空
function cleanup (effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i] //这里的每一项是一个set类型
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

//get拦截函数内调用track函数追踪变化
function track (target, key) {
  if (!activeEffect) return
  //获取对应的数据  taraget------key--------effectFn
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  //放入依赖
  deps.add(activeEffect)
  activeEffect.deps.push(deps)  //将对应的依赖放到全局的activeEffect的effect收集起来
}

//set拦截函数内调用trigger函数追踪变化
function trigger (target, key) {
  let depsMap = bucket.get(target)
  if (!depsMap) return
  let effects = depsMap.get(key)

  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {  //防止栈溢出
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
}

const effectFn = () => {
  let a = obj.text, b = obj.text
  obj.text = obj.text + 1      //最终执行代码后,这里deps会存储3个effectFn
}
effect(effectFn)

总结

最终核心的响应式中,将trigger和track的函数抽离,单独处理数据的存储和执行。 在effectFn函数执行中我们能发现,读取了3次的get操作,set执行一次。最终会连续执行3次。到目前为止完成的响应式系统还有很多欠缺的地方,比如如何使其给用户对应的option配置修改依赖的执行顺序,次数等等,如果多次重复执行的依赖函数那么我如何使其只执行最后一次的变更.....