说起 Vue3 的响应式系统,大家估计都会第一时间不约而同地想到 Proxy;诚然,proxy 在整个响应式系统中扮演了非常重要的角色,但是它并不是响应式系统的全部。
今天我们就来一起探索一下 Vue3 的响应式系统,看看它究竟还藏了什么小技巧!

Proxy 介绍
为了防止有的小伙伴遗忘了,正式开篇前我们先来回顾一下响应式系统中这个一把手 Proxy 的基本用法。
Proxy 接收两个参数:
- 第一个参数是需要代理的对象 
target; - 第二个参数是一个对象 
handler,里面定义了代理对象的一些操作; 
同时,Proxy 会返回一个被代理过的对象:
            
            
              js
              
              
            
          
          const data = {
  name: 'lisi',
  age: 18
}
const proxyData = new Proxy(data, {
  get (target, key) {
    return target[key]
  },
  set (target, key, value) {
    target[key] = value
  }
})
proxyData.name // 这里会触发 Proxy 的 get 逻辑
proxyData.age = 19 // 这里会触发 Proxy 的 set 逻辑
        上面的代码中,我们通过 Proxy 对 data 进行了代理;
当我们去 设置 data 的属性时,会触发 Proxy 的 get 逻辑;当我们去获取 data 的属性时,会触发 Proxy 的 set 逻辑。
这就是 Proxy 的基本用法。
当然除了上面最基本的属性值的读取,Proxy 还可以拦截许多其他的操作,比如 has、deleteProperty、ownKeys 等等。
Proxy的所有可拦截操作可以参考 MDN 文档: Proxy
注意事项
在使用 Proxy 的时候,需要注意以下几点:
只能代理对象
Proxy 只能代理对象,不能代理基本数据类型:
            
            
              js
              
              
            
          
          const ProxyData = new Proxy(1, {
  get (target, key) {
    console.log('get', key)
    return target[key]
  },
  set (target, key, value) {
    console.log('set', key, value)
    target[key] = value
  }
})
ProxyData.name // 这里会报错,因为 `Proxy` 只能代理对象
        只能代理基本操作
Proxy 只能代理基本的操作,无法代理复合操作;
那么什么是基本操作和复合操作呢?
基本操作
基本操作就是 直接对对象进行操作 ,比如:
- 对象的读取操作,比如:
ProxyData.name; - 对象的设置操作,比如:
ProxyData.age = 19; - 函数调用,比如:
ProxyData(); in操作符,比如:'name' in ProxyData;
复合操作
而复合操作就是 多个基本操作的组合 ,比如:ProxyData.fn();
这里的操作实际上是:先读取 ProxyData 的 fn 属性,然后再调用 fn 函数;是一个复合操作。
简单回顾了一下 Proxy 的基本用法,下面我们就来正式讲解 Vue3 是如何利用 Proxy 来实现响应式系统的。
响应式系统
假设我们有一个副作用函数:
            
            
              js
              
              
            
          
          function effectFn () {
  document.getElementById('app').innerHTML = ProxyData.name
}
        在函数中使用到了 Proxy 代理过的数据,我们希望 ------ 在代理数据发生变化时,能够触发副作用函数的重新执行;
为此,我们可以利用 Proxy 的特性,在 get 逻辑中进行 依赖收集 ;在 set 逻辑中进行 依赖触发;
代码如下:
            
            
              js
              
              
            
          
          const ProxyData = new `Proxy`(data, {
  get (target, key) {
    // TODO 依赖收集
    return target[key]
  },
  set (target, key, value) {
    target[key] = value
    // TODO 触发依赖
  }
})
        这里需要大家思考两个问题:
- 我们要将 依赖收集到哪里?
 - 当代码运行到 
ProxyData.name时,我们要如何知道 当前正在执行的是哪个函数? 
针对第一个问题,可以使用一个全局的 Map 结构来保存依赖:
            
            
              js
              
              
            
          
          const targetMap = new Map()
        而对于第二个问题,这里可以使用一个通用的 effect 函数来包裹我们要执行的函数;
在 effect 函数中,使用一个全局变量将当前正在执行的函数保存起来 ,这样一来在依赖收集时,就只需要 将这个全局变量作为依赖进行收集 就可以了。
代码如下:
            
            
              js
              
              
            
          
          // 当前激活的effectFn
let activeEffect
function effect (fn) {
  activeEffect = fn
  fn()
}
effect(effectFn)
        依赖收集与依赖触发
解决了上面的问题,接下来我们再来看看具体如何实现 依赖的收集与触发,代码如下:
            
            
              js
              
              
            
          
          // 当前激活的effectFn
let activeEffect
// 用于保存依赖的容器
const targetMap = new Map()
function effect (fn) {
  activeEffect = fn
  fn()
}
const ProxyData = new Proxy(data, {
  get (target, key) {
    if (!activeEffect) return
    // 依赖收集
    const dep = targetMap.get(target)
    if (!dep) {
      // 在 target 和 activeEffect 之间建立依赖关系
      targetMap.set(target, activeEffect)
    }
    return target[key]
  },
  set (target, key, value) {
    // 触发依赖
    // 通过 target 找到对应的依赖并执行
    const dep = targetMap.get(target)
    if (dep) {
      dep()
    }
    target[key] = value
  }
})
        上面的代码里,我们在 get 逻辑中 通过一个 Map 在 target 和 activeEffect 之间建立了依赖关系;
在 set 逻辑中,我们 通过 target 在 Map 中找到对应的依赖并执行。
我们来测试测试一下:
            
            
              js
              
              
            
          
          function effectFn () {
  console.log('我是副作用函数,我被触发了')
  document.getElementById('app').innerHTML = ProxyData.name
}
effect(effectFn)
ProxyData.name = 'zhangsan' // 输出:我是副作用函数,我被触发了
        现在,当我们修改 ProxyData 的 name 属性时,能够触发对应的副作用函数重新执行;这样我们就实现了一个简单的响应式系统。
依赖关系的建立
但是,这里还存在一些问题 ------
首先,当我们修改 ProxyData 的 age 属性时,也会触发副作用函数执行,但是我们的副作用函数中并没有使用到 age 属性:
            
            
              js
              
              
            
          
          ProxyData.age = 19 // 输出:我是副作用函数,我被触发了
        这是因为我们在依赖收集的逻辑中,直接在 target 对象和 effectFn 之间建立了依赖关系;
这样一来,无论 target 上的什么属性发生变化,都会触发 effectFn 函数执行;这显然是不正确的。
其次,一个对象属性可能会被多个副作用函数使用,比如:
            
            
              js
              
              
            
          
          function effectFn () {
  document.getElementById('app').innerHTML = ProxyData.name
  console.log('我是副作用函数,我被触发了')
}
function effectFn1 () {
  document.getElementById('app').innerHTML = ProxyData.name
  console.log('我是副作用函数1,我被触发了')
}
        我们 目前的依赖关系是一对一的 ,新加入的 effectFn1 函数会覆盖掉 effectFn 函数;
所以当我们修改 ProxyData 的 name 属性时,只会触发 effectFn1 函数执行,而不会触发 effectFn 函数执行。
根据上面两点,我们再修改一下依赖收集与触发的相关代码:
            
            
              js
              
              
            
          
          const ProxyData = new Proxy(data, {
  get (target, key) {
    if (!activeEffect) return
    // 依赖收集
    let depsMap = targetMap.get(target)
    if (!depsMap) {
      depsMap = new Map()
      targetMap.set(target, depsMap)
    }
    let dep = dep.get(key)
    if (!dep) {
      // 用一个 Set 来保存 effectFn,满足一对多的同时保证不会有重复的 effectFn
      dep = new Set()
      depsMap.set(key, dep)
    }
    dep.add(activeEffect)
    return target[key]
  },
  set (target, key, value) {
    // 触发依赖
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    const dep = depsMap.get(key)
    if (dep) {
      dep.forEach(fn => {
        fn()
      })
    }
    target[key] = value
  }
})
        修改后的代码中,我们在 target、key 和 effectFn 三者之间建立了依赖关系:

上图中:
targetMap是一个Map结构,它的key是target,value是depsMap;depsMap同样是一个Map结构,它的key是访问的具体值,value是depSet;depSet是一个Set结构;这里使用Set是为了利用Set的特性,保证不会有重复的effectFn。
这样一来,我们在 修改 ProxyData 的 age 属性时,就不会触发 effectFn 函数执行了;
同时,多个函数使用到了同一个 ProxyData 属性时,也能够正确的触发对应的函数执行。
下面我们通过这个简易的响应式系统来看看 Vue3 中的一些响应式 API 是如何实现的。
reactive
reactive 接收一个对象作为参数,并返回一个响应式的对象:
            
            
              js
              
              
            
          
          const reactiveData = reactive({
  name: 'lisi',
  age: 18
})
        我们将前面响应式系统中依赖收集和依赖触发的逻辑单独封装:
            
            
              js
              
              
            
          
          // 依赖收集
function track (target, key) {
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  let dep = dep.get(key)
  if (!dep) {
    dep = new Set()
    depsMap.set(key, dep)
  }
  dep.add(activeEffect)
}
// 依赖触发
function trigger (target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) {
    dep.forEach(fn => {
      fn()
    })
  }
}
        然后基于封装好的 track 函数与 trigger 函数,对传入 reactive 中的对象进行代理:
            
            
              js
              
              
            
          
          function reactive (target) {
  const handler = {
    get (target, key) {
      // 依赖收集
      track(target, key)
      return target[key]
    },
    set (target, key, value) {
      // 触发依赖
      trigger(target, key)
      target[key] = value
    }
  }
  return new Proxy(target, handler)
}
        这里的实现其实很简单,就是 基于我们响应式系统进行了一层封装,不再赘述了。
ref
在介绍 Proxy 时我们说过,Proxy 只能代理对象,不能代理基本数据类型;
而 Vue3 中提供了一个 ref 可以将基本数据类型转换成一个响应式的对象,它又是怎么实现的呢?
其实在 Vue3 源码中,是通过访问器属性 value 来实现这个功能的:
            
            
              js
              
              
            
          
          function ref(value) {
  if (value._isRef === true) {
    return value;
  }
  const RefImpl = {
    _isRef: true,
    _value: value,
    dep: new Set(),
    get value() {
      // 依赖收集 
      this.dep.add(activeEffect);
      return this._value;
    },
    set value(newValue) {
      this._value = newValue;
      // 触发依赖
      this.dep.forEach(fn => fn());
    }
  }
  return RefImpl;
}
        在 Vue3 中使用 ref 时,我们需要通过 .value 的形式来获取到它的值,这样一来就会 命中访问器属性的 get 和 set 逻辑;
同时,因为 ref 是被设计用于代理基本数据类型的,所以它在依赖收集时不会在 key 和副作用函数之间建立依赖关系;而是 直接将副作用函数放到当前实例的 dep 中;
当我们修改 ref 的 value 时,就会命中访问器 set 逻辑;在 set 逻辑中,就可以直接触发 dep 中的所有副作用函数执行。
watch
可调度性
watch 函数允许我们观察一个响应式对象的变化,当响应式对象发生变化时,才会去触发传入的回调函数;
并且,我们也可以通过传递 immediate 参数,来控制 watch 是否在初始化时立即执行一次副作用函数:
            
            
              js
              
              
            
          
          watch(() => proxyData.name, () => {
 console.log('响应式数据改变')
}, {
  immediate: true // 立即执行
})
        它的这些特性意味着我们需要想办法 控制副作用函数的执行时机,也就是所谓的可调度性;
在目前的 effet 函数逻辑中,拿到副作用函数就会立即执行;所以我们需要对 effect 函数进行改造:
            
            
              js
              
              
            
          
          function effect (fn, options = {}) {
  function effecFn () {
    // 全局变量 activeEffect 指向当前的 effectFn
    activeEffect = effecFn
    // 执行传入的副作用函数拿到返回值,并 return 出去
    const result = fn()
    return result
  }
  // 将 options 挂载到 effectFn 上,这样就能在 effectFn 中拿到 options 了
  effecFn.options = options
  // 如果是 lazy 模式,那么就不会立即执行副作用函数
  if (!options.lazy) {
    effecFn()
  }
  return effectFn
}
        改造后的 effect 函数,接收一个 options 参数,它是一个对象,里面包含了 lazy 属性;
如果 lazy 为 true,则 不会立即执行副作用函数,而是把副作用函数返回,让外部来控制它的执行时机;
接下来,我们基于新的 effect 函数,来实现 watch:
            
            
              js
              
              
            
          
          function watch (source, cb, options = {}) {
  const effecFn = effect(
    () => source, 
    {
      lazy: true,
      scheduler: () => {
        cb()
      }
    }
  )
}
        可以看到,watch 实际上就是对 effect 函数的一层封装,它会把用户传入需要 watch 的值封装成一个函数作为副作用函数传递给 effect;
除此之外,还给 effect 函数传递了第二个参数,是一个对象,里面包含了 lazy 和 scheduler 两个属性;
lazy 属性在前面已经介绍过,它是用来 控制副作用函数的执行时机 ;那 scheduler 又是用来做什么的呢?
实际上,scheduler 函数是用来 控制副作用函数的执行方式的;
如果存在 scheduler 函数,那么在触发依赖的时候就不会执行 effectFn,而是去执行 scheduler 函数;
为了实现这个功能,我们还需要对 trigger 函数进行改造:
            
            
              js
              
              
            
          
          function trigger (target, key) {
  if (!activeEffect) return
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) {
    dep.forEach(fn => {
      // 在这里,我们通过判断 fn 上是否有 scheduler 参数,来决定执行 fn 还是执行 options.scheduler 函数
      if (fn.options.scheduler) {
        fn.options.scheduler()
      } else {
        fn()
      }
    })
  }
}
        这么一来,我们就能控制副作用函数的执行时机与方式了。
新旧值
通过上面的改造,watch 的基本框架就实现了;在这个基础上,还需要进一步完善一些功能。
我们知道,在传递给 watch 函数的 cb 函数中,可以拿到 newVal 和 oldVal;接下来就来实现这个功能:
            
            
              js
              
              
            
          
          function watch (source, cb, options = {}) {
  let oldValue
  let newValue
  const job = () => {
    const newValue = effecFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }
  const effecFn = effect(
    () => source,
    {
      lazy: true,
      scheduler: job
    }
  )
  // 如果是 immediate 模式,那么就立即执行 job 函数
  if (options.immediate) {
    job()
  } else {
    oldValue = effecFn()
  }
}
        在上面的代码中,我们将 scheduler 的逻辑抽离出来,放到了一个 job 函数中;
job 函数主要做了几件事:
- 执行副作用函数,拿到 
newValue; - 执行用户传入的 
cb函数,将newValue和oldValue传递给cb函数; - 将 
newValue赋值给oldValue,为下一次求值做准备。 
有的小伙伴可能会疑惑 ------ 为什么 在 job(scheduler) 函数中执行 effectFn 时,拿到的是 newValue ;而在 watch 函数中立即执行 effectFn 时,拿到的却是 oldValue 呢?
这是因为我们前面改造了 trigger 触发依赖时的逻辑,job(scheduler)的执行时机实际上是在 trigger 中;而 trigger 之所以被触发正是因为 修改了响应式变量的值 ,此时对 effectFn 的求值,拿到的就是 newValue 了。
而手动执行 effectFn 时是在 watch 函数中,此时 响应式变量的值并没有发生变化 ,所以拿到的就是 oldValue 。
computed
computed 函数用于创建一个计算属性,它接收一个函数作为参数,返回一个响应式对象:
            
            
              js
              
              
            
          
          const count = ref(1)
const double = computed(() => count.value * 2)
        computed 同样利用了 effect 函数可以 控制副作用函数执行时机的特性,它的实现也很简单:
            
            
              js
              
              
            
          
          function computed (getter) {
  // 将用户传入的 getter 函数作为副作用函数传递给 effect
  // 并且通过 lazy: true 的方式来控制 effect 的执行时机
  const effectFn = effect(getter, { lazy: true })
  // 当用户通过 .value 获取计算属性的值时,会执行 effectFn
  const computedImpl = {
    get value () {
      return effectFn()
    }
  }
  return computedImpl
}
        在 computed 函数中,我们 将用户传入的 getter 函数作为副作用函数传递给 effect 函数;
并且通过 lazy: true 的方式来 控制 effect 的执行时机;
当用户通过 .value 获取计算属性的值时,就会去 执行 effectFn,从而触发 getter 函数的执行拿到计算属性的值。
脏检查
computed 的一个特点就是实现了数据的脏检查逻辑 ------
只有当计算属性的依赖发生变化时,才会重新计算计算属性的值;
下面我们来实现这个功能:
            
            
              js
              
              
            
          
          function computed (getter) {
  // 这里有一个 dirty 变量,用来标识计算属性的依赖是否发生变化
  let dirty = true
  // 保存计算属性的值
  let value
  const effectFn = effect(getter, {
    lazy: true,
    scheduler: () => {
      // 当 scheduler 函数执行时,也就是计算属性的依赖发生变化时,会重新将 dirty 置为 true
      dirty = true
    }
  })
  const computedImpl = {
    get value () {
      if (dirty) {
        value = effectFn()
        // 在获取计算属性的值后,会将 dirty 置为 false
        // 如果后续依赖的值未更改,将不再重新求值
        dirty = false
      }
      // 如果不需要重新计算,那么就直接返回上一次计算的结果
      return value
    }
  }
  return computedImpl
}
        在修改后的逻辑中,使用一个变量来 标识计算属性的依赖是否发生变化;
首次获取计算属性的值时,会执行 effectFn,实际上就是执行了用户传入的 getter 函数,拿到计算属性的值并保存下来;
然后将 dirty 置为 false,表示当前的 计算属性的依赖没有发生变化;
当计算属性的依赖发生变化时,会命中 trigger 函数的逻辑,从而执行 scheduler 函数将 dirty 置为 true;
那么,computed 函数就会 再次进行求值计算。
总结
Vue3 响应式不仅依赖于 Proxy,在具体实现上还需要 effect 函数打配合。
基于 可调度性的设计 ,让 Vue3 可以自由控制副作用函数的执行时机和执行方式;使得整个响应式系统更加灵活,并在此基础上拓展出了 watch、computed 等 API;
当然,除了文章提到的这些,Vue3 还基于这种设计实现了很多其他的功能,比如更新队列的实现、异步更新的实现等等......
这些有机会再和大家唠唠吧~