手写实现最简易的mini-vue3 (一)响应式系统

920 阅读9分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

小案例

在学习Vue3响应式系统前我们要理解什么是响应式对象、什么又是副作用函数?我们看下面一个例子

let a = 1
let b = 2
let c = a + b
b = 5 //重设b
console.log(c) //输出为3 并不为6

此时如果我们修改b的值为5,c的值显然是不会变的。那么怎样才能改变c的值呢?很简单重新执行依赖b变量的表达式即可,如下

let c = a + b
console.log(c) // 输出6

effect和Proxy

我们试想,难道每次更改一个变量值,都需要重新执行?这其实是非常很麻烦的,那么有没有一种方法:当我修改一个变量值时,依赖了这个变量的所有表达式都自动重新执行?答案是有的,为了解决这个问题就引入了副作用函数和响应式对象。响应式对象其实就是一个代理对象,这个代理用于监听对象的一些操作,比如get、set操作。副作用函数则是当数据发生变化时需要重新执行的函数。

effect

我们定义一个函数如下,它接收一个回调函数effect,并且默认调用一次。这个effect就是副作用函数

function watchEffect(effect){
    effect()
}

把需要重新执行的代码写进这个副作用函数中。

let a = 1
let b = 2

//把需要重新执行的代码写进这个副作用函数中
watchEffect(() => {
   let c = a + b
   console.log(c)
})

响应式对象

根据我们上面的思路,当修改变量b的值时就需要重新执行这个副作用函数。这就需要用到响应式对象来拦截变量b的set操作并触发这个副作用函数。


let a = 1

// 响应式对象b
const b = new Proxy({value: 2},{
       get(target,key){
       //访问拦截 
       
       return target[key]
       },
       set(taret,key,value){
       //赋值拦截
       //检测到赋值直接再次执行副作用函数
        watchEffect(() => {
           let c = a + b.value 
           console.log(c)
        })
        //更改值
        target[key] = value
        return true
       }
   })
   
//把需要重新执行的代码写进这个副作用函数中
watchEffect(() => {
   let c = a + b.value //调用的时候就需要加.value
   console.log(c)
})

watchEffect(()=>{
    //其他副作用函数
})

b.value = 10  //数据发生改变会触发b的set拦截方法

我们通过Proxy来创建一个b的代理对象,Proxy构造函数第一个参数是被代理对象,第二个参数是对被代理对象的一些拦截选项。由于Proxy拦截的必须是一个对象,所以这里直接将b改成{value:2}来进行拦截。

effect和Proxy如何配合实现响应性

当改变b的值会触发代理对象拦截选项的set方法,我们就需要在set方法中执行这个副作用函数。在这其实又有一个新的问题:副作用函数可能出现多个,因此就得让这些副作用函数和响应式对象的属性建立依赖关系,即哪个属性对应哪些effect,这样就能保证被访问时合理的触发某个属性的依赖(effect)集合。


activeEffect = null //正在执行的副作用函数

function watchEffect(effect){
    activeEffect = effect //保存状态
    effect()
    activeEffect = null //执行完毕后清空
}

const depsMap = new Map() //保存响应式对象属性的所有副作用函数

这里需要修改effect方法,并创建一个map来维护他们之间的关系 (key就是属性,value就是一个副作用函数集合),activeEffect是当前正在执行的effect。

// depsMap
{
    value: [副作用函数1,副作用函数2,副作用函数3], 
    属性1: [副作用函数1,副作用函数2,副作用函数3],
    属性2: [副作用函数1,副作用函数2,副作用函数3]}

depsMap维护对象 {value:1} 中属性所对应的副作用集合。

activeEffect = null //正在执行的副作用函数

//副作用函数
function watchEffect(effect){
    activeEffect = effect //保存状态
    effect()
    activeEffect = null //执行完毕后清空
}

const depsMap = new Map() //保存响应式对象属性的所有副作用函数


let a = 1
// 响应式对象b
const b = new Proxy({value: 2},{
       get(target,key){
        //访问拦截 
        //在此处直接进行副作用函数的收集
        //查看当前是否存在key
        let effects =  depsMap.get(key)
        //如果不存在effects就创建一个
        if(!effects){
            depsMap.set(key,effects = new Set()) //依赖可能重复所以set去重
        }
        //如果activeEffect存在就添加当前正在执行的effect 即activeEffect
        activeEffect && effects.add(activeEffect)
        //返回当前的值
        return target[key]
       },
       set(target,key,value){
        //赋值拦截
        //检测到赋值直接执行副作用函数
         
        //一定要先修改最终的值,保证后面执行的effect是最新的
         target[key] = value
         
        //找到当前key所依赖的effects,并遍历执行它
        let effects = depsMap.get(key)
        effects && effects.forEach(effect => effect())
 
        return true
       }
   })
   
//把需要重新执行的代码写进这个副作用函数中
watchEffect(() => {
   let c = a + b.value //调用的时候就需要加.value
   console.log(c)
})

watchEffect(()=>{
    //其他副作用函数
})

b.value = 12  //数据发生改变会触发b的set拦截方法

上述代码中在这些副作用内部如果有响应式变量被访问(即b.value触发会进入响应式对象b的get方法),就会添加当前的activeEffect到相关的响应式对象的属性(value属性)的依赖集合deps中,这个过程就是依赖收集(track)。当我重新设置值时(即b.value = 10会进入响应式对象b的set方法,此时会从depsMap中查找当前访问属性key的依赖集合并遍历执行这些该集合中的依赖),这个过程叫做依赖触发(trigger)

以上代码执行如,可以看到已经实现了响应式。

3
13

小重构

我们重构一下上面的代码,将响应式对象的创建逻辑独立出来,提供一个工厂函数传入对象obj,返回响应式对象。再将依赖收集逻辑和依赖触发逻辑独立出来。

activeEffect = null //正在执行的副作用函数

//副作用函数
function watchEffect(effect) {
  activeEffect = effect //保存状态
  effect()
  activeEffect = null //执行完毕后清空
}

const depsMap = new Map() //保存响应式对象属性的所有副作用函数

//创建响应式对象工厂函数
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      //调用收集函数
      track(target, key)
      return target[key]
    },
    set(target, key, value) {

      //触发之前应修改最终的值
      target[key] = value
      //调用触发函数
      trigger(target, key)

      return true
    }
  })
}

//依赖收集
function track(target, key) {
  let effects = depsMap.get(key)
  //如果不存在effects就创建一个
  if (!effects) {
    depsMap.set(key, effects = new Set()) //依赖可能重复所以set去重
  }
  //如果activeEffect存在就添加当前正在执行的effect 即activeEffect
  activeEffect && effects.add(activeEffect)
}

//依赖触发
function trigger(target, key) {
  //找到当前key所依赖的effects,并遍历执行它们
  let effects = depsMap.get(key)
  effects && effects.forEach(effect => effect())
}


let a = 1
// 响应式对象b
const b = reactive({value: 2})

//把需要重新执行的代码写进这个副作用函数中
watchEffect(() => {
  let c = a + b.value //调用的时候就需要加.value
  console.log(c)
})

watchEffect(() => {
  //其他副作用函数
})

b.value = 12 //数据发生改变会触发b的set拦截方法

响应式对象依赖污染

上述代码其实还存在一个严重的问题:只支持一个响应式对象。为什么这么说呢?因为当前的响应式对象b所有属性的依赖都是由depsMap维护,map的key是唯一的,所以如果两个响应式对象的属性都一样,会导致依赖被污染


const a = reactive({value: 1})
const b = reactive({value: 2})

//effect1
watchEffect(() => {
  let c = a.value + b.value 
  console.log(c)
})

//effect2
watchEffect(() => {
  console.log(a.value)
})

b.value = 12 

我们看看上面这个例子,当我执行 b.value = 12 时此时会触发两个effect,按照正常的逻辑,只会触发effect1,不会触发effect2。因为只有effect1才依赖了b这个响应式对象。所以我们应该还需要一个map来维护每个响应式对象的关系防止互相污染。这个map比较特殊,是一个弱引用的map,只能以对象为key,且不可遍历即WeakMap。

//维护每个对象的depsMap,让每个对象的属性依赖集隔离
const targetsMap = new WeakMap()

我们需要更改track和trigger中的一些代码,新增targetsMap并删除depsMap

const targetsMap = new WeakMap()

//依赖收集
function track(target, key) {
  //这里直接从targetMap中获取depsMap,就不需要全局的depsMap了
  let depsMap = targetsMap.get(target)
  //如果不存在,就给当前的target创建一个空的depsMap
  if(!depsMap){
      targetMap.set(target,depsMap = new Map() )
  }
  
  let effects = depsMap.get(key)
  if (!effects) {
    depsMap.set(key, effects = new Set()) 
  }
  activeEffect && effects.add(activeEffect)
}

//依赖触发
function trigger(target, key) {
  const depsMap = targetsMap.get(target)
  //如果存在就触发,不存在就不触发
  if(depsMap){
    let effects = depsMap.get(key)
    effects && effects.forEach(effect => effect())
  }
}

代码修改完成之后就不会出现多个响应式对象相同属性出现的依赖污染问题了。

最后贴出完整的代码

activeEffect = null //正在执行的副作用函数
//维护每个对象的depsMap,让每个对象的属性依赖集隔离
const targetsMap = new WeakMap()

//副作用函数
function watchEffect(effect) {
  activeEffect = effect //保存状态
  effect()
  activeEffect = null //执行完毕后清空
}


//创建响应式对象工厂函数
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      //调用收集函数
      track(target, key)
      return target[key]
    },
    set(target, key, value) {

      //触发之前应修改最终的值
      target[key] = value
      //调用触发函数
      trigger(target, key)

      return true
    }
  })
}

//依赖收集
function track(target, key) {
  //保存响应式对象属性的所有副作用函数
  let depsMap = targetsMap.get(target)
  //如果不存在,就给当前的target创建一个空的depsMap
  if (!depsMap) {
    targetsMap.set(target, depsMap = new Map())
  }

  let effects = depsMap.get(key)
  //如果不存在effects就创建一个
  if (!effects) {
    depsMap.set(key, effects = new Set()) //依赖可能重复所以set去重
  }
  //如果activeEffect存在就添加当前正在执行的effect 即activeEffect
  activeEffect && effects.add(activeEffect)
}

//依赖触发
function trigger(target, key) {
  const depsMap = targetsMap.get(target)
  //如果存在就触发,不存在就不触发
  if (depsMap) {
    //找到当前key所依赖的effects,并遍历执行它们
    let effects = depsMap.get(key)
    effects && effects.forEach(effect => effect())
  }
}



const a = reactive({
  value: 1
})
// 响应式对象b
const b = reactive({
  value: 2
})

//把需要重新执行的代码写进这个副作用函数中
watchEffect(() => {
  let c = a.value + b.value //调用的时候就需要加.value
  console.log(c)
})

watchEffect(() => {
  //其他副作用函数
  console.log(a.value)
})

b.value = 12 //数据发生改变会触发b的set拦截方法
a.value = 12

总结

vue3的响应式系统已经基本完成,但是还有许多的情况需要处理:嵌套effect的执行导致的一些问题;数组、set、map的响应式代理;effect修改值会出现死循环的问题等等。现在我们只实现最简版的响应式系统,后续实现工程化的mini-vue3时再来详细探讨,请各位看官持续关注。

如果对本文有疑问请评论区留言