响应式原理
官网对于响应式原理的介绍:https://cn.vuejs.org/v2/guide/reactivity.html
写在前面
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;
}
可以看到,如果我们访问和增加一个不存在的属性,同样也会触发get和set操作。这样就不必使用$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
- 更多的拦截操作
- 监听对象新增和删除属性。vue2 不能监测对象新增和删除属性,可以用 $set方法监听
- 更好地监听数组。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, {});
});
- 性能优化。Object.defineProperty无法一次性监听对象所有属性,必须遍历或者递归来实现使用defineProperty,对所有 key 绑定响应式。当 data 的层级关系很深时,会影响性能;而proxy可以实现只在访问对象深层级的属性时收集依赖
Reflect
Reflect 是一个内置的对象,它提供拦截 js 对象操作的方法,这些方法与 proxy handlers 的方法相同,所以 vue3.0 使用 Reflect 和 Proxy 来操作响应式对象
WeakMap
这个是 ES6 新增的数据类型,WeakMap 对象其实是一组键值对的映射。vue3 收集的依赖会以 WeakMap 对象的形式存储。相比 Map ,区别在于
- 键必须是对象
- 键是弱引用的。如果我们在 WeakMap中使用一个对象作为键,并且没有其他对这个对象的引用 —— 该对象将会被从内存中自动清除
关于 WeakMap 可以参考这篇好文 《你不知道的 WeakMap》番外篇
响应式更新(reactivity)
先看下整体的响应式更新流程
源码的工作流程可以参见这个图

其实整个响应式更新过程主要分为两个阶段,分别是依赖收集和派发通知
依赖收集
其实这就是一个发布订阅,在访问对象(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
  }
}