vue3 响应式原理

上一次用 vue 在工作中开发项目是在大四的实习期间,不过那会儿还是清一色的 vue2,最近 vue3 也是正式上线不久,看了下文档发现改动蛮大的

写在前面

vue3 的改变主要有以下几点:

  • 实现响应式的 api 由 defineProperty 改为 proxy
  • 副作用 effect(代替 vue2 的 Observer、Watcher 模块)

其他的还有 Composition API、缓存事件处理函数、Block Tree、拥抱 ts…

详情可以参考 Vue3 对比 Vue2.x 差异性、注意点、整体梳理

Proxy

Proxy 是 vue3 实现响应式更新的基石。这个 api 的作用是代理对象,可以拦截对对象的操作,vue3 主要用到了 Proxy 的两个 api 来实现响应式数据:

// 拦截对象属性的访问
get(target, prop, receiver)
// 拦截对象属性的设置,最后返回一个布尔值
set(target, prop, value, receiver)

但是 Proxy 不支持 IE,所以说 vue3 使用 Proxy 也意味着抛弃 IE

更多内容可以参考 Proxy - MDN

简单实现对象数据的响应式如下:

function createReactiveObject(target) {
  if (!isObject(target)) {
    return target;
  }
  const baseHandler = {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      return isObject(res) ? reactive(res) : res;
    },
    set(target, key, value, receiver) {
      const res = Reflect.set(target, key, value, receiver);
      return res;
    },
  };
  const observed = new Proxy(target, baseHandler);
  return observed;
}

可以看到,如果我们访问和增加一个不存在的属性,同样也会触发getset操作。这样就不必使用$set这个 api 了。

当访问的 key 是一个对象时,会触发递归(如果不访问这个对象,就永远不会去做这一步递归操作),比如下面这个例子:

data = {
  title: "007",
  person: {
    name: "",
  },
};
data.person.name = "JacksonZhou";

当对data.person.name赋值时,因为会首先访问data.person,所以会触发get,此时就可以将data.person这个对象转换为响应式对象,之后修改 data.person.name 就会触发 set

对比 defineProperty

  1. 更多的拦截操作
  2. 监听对象新增和删除属性。vue2 不能监测对象新增和删除属性,可以用 $set 方法监听
  3. 更好地监听数组。defineProperty 不能监听对数组的 length 属性的修改以及新增的元素。这也是 vue2 需要改写数组原型的原因之一
// 改写数组原型
const arrayProto = Array.prototype;
const subArrProto = Object.create(arrayProto);
const methods = [
  "pop",
  "shift",
  "unshift",
  "sort",
  "reverse",
  "splice",
  "push",
];
methods.forEach((method) => {
  // 重写原型方法
  subArrProto[method] = function () {
    arrayProto[method].call(this, ...arguments);
  };
  // 监听这些方法
  Object.defineProperty(subArrProto, method, {});
});
  1. 性能优化。Object.defineProperty 无法一次性监听对象所有属性,必须遍历或者递归来实现使用defineProperty,对所有 key 绑定响应式。当 data 的层级关系很深时,会影响性能;而 proxy 可以实现只在访问对象深层级的属性时收集依赖

Reflect

Reflect 是一个内置的对象,它提供拦截 js 对象操作的方法,这些方法与 proxy handlers 的方法相同,所以 vue3.0 使用 ReflectProxy 来操作响应式对象

参见 Reflect - MDN

WeakMap

这个是 ES6 新增的数据类型,WeakMap 对象其实是一组键值对的映射。vue3 收集的依赖会以 WeakMap 对象的形式存储。相比 Map ,区别在于

  • 键必须是对象
  • 键是弱引用的。如果我们在 WeakMap 中使用一个对象作为键,并且没有其他对这个对象的引用 —— 该对象将会被从内存中自动清除

关于 WeakMap 可以参考这篇好文 《你不知道的 WeakMap》番外篇

响应式更新(reactivity)

先看下整体的响应式更新流程 响应式原理.png

源码的工作流程可以参见这个图

其实整个响应式更新过程主要分为两个阶段,分别是依赖收集和派发通知

依赖收集

其实这就是一个发布订阅,在访问对象(get)时收集依赖。

在 Vue3.0 中引入了 effect 副作用函数(类似于 react hooks 的useEffect),这个函数默认会首先执行一次,此时全局的 effect 会指向这个函数,访问函数中的数据时,会收集这个副作用函数作为数据的依赖;当数据发生变化的时候就会执行所有依赖该数据的副作用函数

这里其实用了栈的数据结构来存储执行中的 effect 函数,可以确保嵌套 effect 函数的正确执行,实现如下:

const activeEffectStacks = []; // 栈
function effect(fn) {
  const effect = createReactiveEffect(fn);
  effect();
}

function createReactiveEffect(fn) {
  const effect = function () {
    return run(effect, fn);
  };
  return effect;
}

function run(effect, fn) {
  try {
    activeEffectStacks.push(effect); // effect 入栈
    fn(); // 此过程访问到响应式数据时,会触发 get 并收集依赖
  } finally {
    activeEffectStacks.pop(); // effect 出栈
  }
}

前面知道,我们会在访问对象时触发依赖收集,源码中主要是通过track函数收集依赖

get(target, key, receiver) {
  const res = Reflect.get(target, key, receiver)
  // 收集依赖,将 key 和 effect 关联起来
  track(target, key)
  return isObject(res) ? reactive(res) : res
}

对象的依赖集合如下图所示:

vue 中有一个总的 targetMap, 它是一个 WeakMap ,key 是 target(代理的对象), value 是一个 Map ,称之为 depsMap,它是用于管理当前 target 中每个 key 的 deps(依赖/副作用),每个 deps 是一个 Set ,代码表示如下:

// targetMap
{
  target: {
    key: [effect1, effect2]
  },
  // ...
}

vue3 通过 track 方法收集依赖,参考源码如下:

const targetsMap = new WeakMap();
function track(target, key) {
  const effect = activeEffectStacks[activeEffectStacks.length - 1];
  // 确保当前有 effect 在执行
  if (effect) {
    let depsMap = targetsMap.get(target);
    if (!depsMap) {
      targetsMap.set(target, (depsMap = new Map()));
    }
    let deps = depsMap.get(key);
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    if (!deps.has(effect)) {
      // 收集当前执行的 effect 作为依赖
      deps.add(effect);
    }
  }
}

派发通知

更改对象(set)后,会遍历通知之前收集的所有依赖,并更新视图(也就是执行 renderEffect)

set(target, key, value, receiver) {
  const hadKey = hasOwn(target, key)
  const oldValue = target[key]
  const res = Reflect.set(target, key, value, receiver)
  if (!hadKey) {
    trigger(target, key)
  } else if (oldValue !== value) {
    trigger(target, key)
  }
  return res
}

vue3 通过 trigger 函数派发通知,参考源码如下:

function trigger(target, key) {
  const depsMap = targetsMap.get(target);
  if (depsMap) {
    const deps = depsMap.get(key);
    if (deps) {
      // 依次执行所有依赖该数据的 effect
      deps.forEach((effect) => {
        effect();
      });
    }
  }
}

其他

重复代理

在 Vue 中是使用了 hash 表来实现避免重复代理的,也就是使用了 WeakMap 来在第一次创建代理后缓存一个映射关系,下一次代理的时候如果之前已经代理过了就直接返回之前的代理

Proxy 操作数组会发生多次 set

如下代码

let arr = [1, 2, 3];
arr.push(4);

默认情况下会执行两次操作,分别是修改下标为 3 的属性值和修改 length 属性

我们只需要屏蔽掉修改 length 属性的操作就可以了

set(target, key, value, receiver) {
  const hadKey = hasOwn(target, key)
  const oldValue = target[key]
  const res = Reflect.set(target, key, value, receiver)
  if (!hadKey) {
    console.log('添加属性')
  } else (oldValue !== value) {
    // 添加4之后,其实arr.length已经默认调整为4了,所以不会走到这一步
    console.log('修改属性')
  }
  return res
  }
}