Vue3 响应式只知道 Proxy?快来学点新技巧!

说起 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 逻辑

上面的代码中,我们通过 Proxydata 进行了代理;

当我们去 设置 data 的属性时,会触发 Proxyget 逻辑;当我们去获取 data 的属性时,会触发 Proxyset 逻辑

这就是 Proxy 的基本用法。

当然除了上面最基本的属性值的读取,Proxy 还可以拦截许多其他的操作,比如 hasdeletePropertyownKeys 等等。

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()

这里的操作实际上是:先读取 ProxyDatafn 属性,然后再调用 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 触发依赖
  }
})

这里需要大家思考两个问题:

  1. 我们要将 依赖收集到哪里
  2. 当代码运行到 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 逻辑中 通过一个 MaptargetactiveEffect 之间建立了依赖关系

set 逻辑中,我们 通过 targetMap 中找到对应的依赖并执行

我们来测试测试一下:

js 复制代码
function effectFn () {
  console.log('我是副作用函数,我被触发了')
  document.getElementById('app').innerHTML = ProxyData.name
}
effect(effectFn)
ProxyData.name = 'zhangsan' // 输出:我是副作用函数,我被触发了

现在,当我们修改 ProxyDataname 属性时,能够触发对应的副作用函数重新执行;这样我们就实现了一个简单的响应式系统。

依赖关系的建立

但是,这里还存在一些问题 ------

首先,当我们修改 ProxyDataage 属性时,也会触发副作用函数执行,但是我们的副作用函数中并没有使用到 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 函数;

所以当我们修改 ProxyDataname 属性时,只会触发 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
  }
})

修改后的代码中,我们在 targetkeyeffectFn 三者之间建立了依赖关系:

上图中:

  • targetMap 是一个 Map 结构,它的 keytargetvaluedepsMap
  • depsMap 同样是一个 Map 结构,它的 key 是访问的具体值,valuedepSet
  • depSet 是一个 Set 结构;这里使用 Set 是为了利用 Set 的特性,保证不会有重复的 effectFn

这样一来,我们在 修改 ProxyDataage 属性时,就不会触发 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 的形式来获取到它的值,这样一来就会 命中访问器属性的 getset 逻辑

同时,因为 ref 是被设计用于代理基本数据类型的,所以它在依赖收集时不会在 key 和副作用函数之间建立依赖关系;而是 直接将副作用函数放到当前实例的 dep

当我们修改 refvalue 时,就会命中访问器 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 属性;

如果 lazytrue,则 不会立即执行副作用函数,而是把副作用函数返回,让外部来控制它的执行时机

接下来,我们基于新的 effect 函数,来实现 watch

js 复制代码
function watch (source, cb, options = {}) {
  const effecFn = effect(
    () => source, 
    {
      lazy: true,
      scheduler: () => {
        cb()
      }
    }
  )
}

可以看到,watch 实际上就是对 effect 函数的一层封装,它会把用户传入需要 watch 的值封装成一个函数作为副作用函数传递给 effect

除此之外,还给 effect 函数传递了第二个参数,是一个对象,里面包含了 lazyscheduler 两个属性;

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 函数中,可以拿到 newValoldVal;接下来就来实现这个功能:

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 函数主要做了几件事:

  1. 执行副作用函数,拿到 newValue;
  2. 执行用户传入的 cb 函数,将 newValueoldValue 传递给 cb 函数;
  3. 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 可以自由控制副作用函数的执行时机和执行方式;使得整个响应式系统更加灵活,并在此基础上拓展出了 watchcomputedAPI

当然,除了文章提到的这些,Vue3 还基于这种设计实现了很多其他的功能,比如更新队列的实现、异步更新的实现等等......

这些有机会再和大家唠唠吧~

相关推荐
学习使我快乐012 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19952 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
黄尚圈圈3 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水4 小时前
简洁之道 - React Hook Form
前端
正小安6 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
小飞猪Jay8 小时前
C++面试速通宝典——13
jvm·c++·面试
_.Switch8 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光8 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   8 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   8 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d