Vue3响应式原理解析

1,127 阅读5分钟

前言

今年上半年开始,自己开始在新项目中使用 Vue3 进行开发,相比较于 Vue2 来说,最大的变化就是 composition Api 代替了之前的 options Api,更像是 React Hooks 函数式组件的编程方式。

Vue3相对于Vue2响应式原理也发生了变化,由原先的 Object.defineproperty 改成了使用 Proxy 替代。Proxy 相对于 Object.defineproperty 有以下几个优化点:

  • 对象新增属性不再需要手动 $set 添加响应式,Proxy 默认会监听动态添加属性和属性的删除等操作。
  • 消除无法监听数组索引,length 属性等等,不再需要在数组原型对象上重写数组的方法。
  • Object.defineproperty 是劫持所有对象属性的 get/set 方法,需要遍历递归去实现,Proxy 是代理整个对象。
  • Vue2 只能拦截对象属性的 get 和 set 操作,而 Proxy 拥有 13 种拦截方法。

所有这些优化,都指向了同一个点:Vue3 将拥有更快的响应速度。下面,将结合代码揭秘 Vue3 实现响应式的原理。

Proxy

Proxy 能够为另一个对象创建代理,该代理可以拦截和重新定义该对象的基本操作,例如获取、设置和定义属性。

Proxy 接受两个参数:

  • 要代理的原始对象。
  • 一个对象,它定义了哪些操作将被拦截以及如何重新定义被拦截的操作。
const target = {
  name: "ts",
  age: 18
};
const handler = {};
const proxy = new Proxy(target, handler);

我们可以在 handler 对象上定义函数做自定义代理:

const target = {
    name: "ts",
    age: "18"
};
const handler = {
    get(target, key, receiver) {
        console.log(`访问属性${key}值`)
        return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
        console.log(`设置属性${key}值`)
        return Reflect.set(target, key, value, receiver)
    },
};
const proxy = new Proxy(target, handler);
console.log(proxy.name)
proxy.name = 'jkl';
proxy.sex = 'male';

打印:

注意:

  • set 方法,要求返回一个布尔值,而 Reflect.set 方法刚好就是一个返回一个布尔值,直接 return 就好了。

  • sex 属性是我们后面新增的,但是也能在 get 和 set 中拦截到,说明 Proxy 是自动给新增属性添加响应式,而不需要手动 $set 添加响应式。

通过对 Proxy 用法的基本介绍,我们发现 Proxy 和 Object.defineproperty 用法有一个相似之处,它们内部都有 get 和 set 方法,我们可以在 get 和 set 方法中拦截和重新定义一些逻辑处理,和 Object.defineproperty 一样,我们可以在 Proxy 的 get 方法中进行依赖收集即 track 操作,在 set 方法中进行触发更新即 trigger 操作。

Reflect

Reflect定义

Reflect 是一个内置的对象,与 Math 类似,它提供拦截 JavaScript 操作的方法,这些方法与 Proxy handlers 提供的的方法是一一对应的,且 Reflect 不是一个函数对象,即不能进行实例化,其所有属性和方法都是静态的。

Reflect对象上的静态方法

Reflect.get/set 参数说明

  • target 指的是原始数据对象。
  • key 指的是操作的属性名。
  • newVal 指的是操作接收到的最新值。
  • receiver 指向的是当前操作正确的上下文,代理对象。

参数值打印

receiver 作用

receiver 是为了在执行对应的拦截操作的方法时能传递正确的 this 上下文。

reactive

基于上面对 Proxy 的基本使用,我们可以试着实现 reactive,在 Vue3 中 reactive 是返回一个 Proxy 的方法,接受一个对象作为参数:

基本实现

export const reactive = (target: object) => {
  return new Proxy(target, {
    get(target, key, receiver) {
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      return Reflect.set(target, key, value, receiver)
    }
  })
}

如果 target 对象存在深层次结构,我们就需要递归实现:

递归完整实现

const isObject = target => target !== null && typeof target == 'object'
export const reactive = (target: object) => {
  return new Proxy(target, {
    get(target, key, receiver) {
      console.log(`访问属性${key}值`)
      const result = Reflect.get(target, key, receiver)
      // 判断result是否是引用类型,是需要递归处理
      if (isObject(result)) {
        return reactive(result)
      }
      return result
    },
    set(target, key, value, receiver) {
      console.log(`设置属性${key}值`)
      return Reflect.set(target, key, value, receiver)
    }
  })
}

测试

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script type="module">
        import { reactive } from './reactive.js'
        const proxy = reactive({
            name: 'ts', age: 18
        })
        console.log(proxy.name);
        console.log(proxy.age);
        proxy.name = 'jkl';
        proxy.age = 20;
    </script>
</body>

</html>

测试打印

收集依赖和触发更新

收集依赖和触发更新是 Vue3 响应式最核心的部分。这里涉及到三个核心概念:effect、track、trigger 即依赖、收集依赖、触发更新。

收集依赖和触发更新流程

访问代理对象 target 属性,会触发 get 方法,在这里会进行依赖收集即执行 track 方法。收集的依赖存储在 deps 里。修改 target 对象属性时,触发 set 方法,在这里会进行触发更新的操作即依次执行 deps 里面的依赖。

依赖数据存储结构

存储容器说明:

  • 选择 weakMap 类型作为容器是因为 weakMap 对键的引用是弱类型,当外部没有对键引用时,weakMap会自动删除,保证对象能被垃圾回收。
  • Map 类型对键的引用是强引用,即便外部没有对该对象保持引用,但至少还存在 Map 本身对该对象的引用关系,因此会导致该对象不能及时的被垃圾回收。
  • 对应的响应式数据对象作为 targetMap 的键,存储和当前响应式数据对象相关的依赖关系 depsMap,即 depsMap 存储的就是和当前响应式对象的每一个 key 对应的具体依赖。
  • deps 作为 depsMap 每个 key 对应的依赖集合,因为每个响应式数据可能在多个副作用函数中被使用,并且 Set 类型用于自动去重的能力。

effect

effect 依赖里面放着数据更新的逻辑,通常我们放在一个函数里面。

// activeEffect 表示当前正在走的 effect
let activeEffect = null;
export const effect = (fn:Function) => {
    activeEffect = fn
    fn()
    activeEffect = null
}

这里使用一个全局变量 activeEffect 来收集当前正在走的副作用函数,并且初始化的时候调用一下。

let age = 18;
let result;
const effect = () => result = age * 2
age = 20;
effect();
console.log(result) // 40

为了让大家理解 effect,上面这段代码是一个比较形象的例子:age 是一个变量,effect 是副作用函数,当 age 发生了变化 age = 20,这时候我们调用 effect(),更新了 result 值。在这里我们是手动写的调用 effect(),在真实响应式流程中,我们如何进行依赖收集以及自动触发更新 effect 呢?

track

track 函数用来进行依赖收集,即把依赖于变量的 effect 函数收集起来,放在 deps 里面,deps 是一个 Set 数据结构。

const targetMap = new WeakMap()
export const track = (target, key) => {
  // 没有activeEffect就不进行追踪
  if (!activeEffect) return
  // 获取target的依赖图
  let depsMap = targetMap.get(target)
  // 没有就新建
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  // 获取key所对应依赖的集合
  let deps = depsMap.get(key)
  // 没有就新建
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }
  // 判断activeEffect是否存在,不存在才添加,防止重复添加
  if (!deps.has(activeEffect)) {
    deps.add(activeEffect)
  }
}

在介绍 Proxy 的时候,我们提到“我们会在 Proxy 的 get 方法中进行依赖收集即 track 操作”,现在我们可以把 track 添加到 get 方法中了:

const isObject = target => target !== null && typeof target == 'object'
export const reactive = (target: object) => {
  return new Proxy(target, {
    get(target, key, receiver) {
      console.log(`访问属性${key}值`)
      const result = Reflect.get(target, key, receiver)
      // 收集依赖
      track(target,key)
      // 判断result是否是引用类型,是需要递归处理
      if (isObject(result)) {
        return reactive(result)
      }
      return result
    },
    set(target, key, value, receiver) {
      console.log(`设置属性${key}值`)
      return Reflect.set(target, key, value, receiver)
    }
  })
}

trigger

实现 trigger

const targetMap = new WeakMap()
export const trigger = (target, key) => {
  // 获取target的依赖图
  const depsMap = targetMap.get(target)
  // 没有说明没有被追踪,就return
  if (!depsMap) return
  // 获取key所对应依赖的集合
  const deps = depsMap.get(key)
  // 遍历依赖的集合,依次执行副作用函数
  if (deps) {
    deps.forEach(effect => effect())
  }
}

在介绍 Proxy 的时候,我们提到“在 set 方法中进行触发更新即 trigger 操作”,现在我们可以把 trigger 添加到 set 方法中了:

const isObject = target => target !== null && typeof target == 'object'
export const reactive = (target: object) => {
  return new Proxy(target, {
    get(target, key, receiver) {
      console.log('访问属性"+key+"值')
      const result = Reflect.get(target, key, receiver)
      // 收集依赖
      track(target,key)
      // 判断result是否是引用类型,是需要递归处理
      if (isObject(result)) {
        return reactive(result)
      }
      return result
    },
    set(target, key, value, receiver) {
      console.log(`设置属性${key}值`)
      // 触发更新
      trigger(target, key)
      return Reflect.set(target, key, value, receiver)
    }
  })
}

完整代码

core.js

// activeEffect 表示当前正在走的 effect
let activeEffect = null
export const effect = fn => {
  activeEffect = fn
  fn()
  activeEffect = null
}

const targetMap = new WeakMap()
export const track = (target, key) => {
  // 没有activeEffect就不进行追踪
  if (!activeEffect) return
  // 获取target的依赖图
  let depsMap = targetMap.get(target)
  // 没有就新建
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  // 获取key所对应依赖的集合
  let deps = depsMap.get(key)
  // 没有就新建
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }
  // 判断activeEffect是否存在,不存在才添加,防止重复添加
  if (!deps.has(activeEffect)) {
    deps.add(activeEffect)
  }
}

export const trigger = (target, key) => {
  // 获取target的依赖图
  const depsMap = targetMap.get(target)
  // 没有说明没有被追踪,就return
  if (!depsMap) return
  // 获取key所对应依赖的集合
  const deps = depsMap.get(key)
  console.log(deps, 'deps=====')
  // 遍历依赖的集合,依次执行副作用函数
  if (deps) {
    deps.forEach(effect => effect())
  }
}

reactive.js

import { track, trigger } from './core.js'
const isObject = target => target !== null && typeof target == 'object'
export const reactive = target => {
  return new Proxy(target, {
    get(target, key, receiver) {
      console.log(`访问属性${key}值`)
      const result = Reflect.get(target, key, receiver)
      // 收集依赖
      track(target, key)
      // 判断result是否是引用类型,是需要递归处理
      if (isObject(result)) {
        return reactive(result)
      }
      return result
    },
    set(target, key, value, receiver) {
      console.log(`设置属性${key}值`)
      // 触发更新
      trigger(target, key)
      return Reflect.set(target, key, value, receiver)
    }
  })
}

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
  <div class="box"></div>
  <button>修改</button>
  <script type="module">
      import { reactive } from './reactive.js'
      import { effect } from './core.js'
      const user = reactive({
          name: 'ts'
      })
      effect(() => { document.querySelector('.box').innerText = `${user.name}` })
      document.querySelector('button').onclick = function () {
          user.name = 'jkl'
      }
  </script>
</body>
</html>

效果

总结

Vue2 和 Vue3 实现响应式的思路或者核心都是相同的,即数据劫持/对象代理(自定义get / set)、依赖收集、触发更新。Vue3 使用 Proxy 实现响应式是对 Object.defineproperty 实现方案存在缺陷的一种优化。