◆

Vue3 响应式原理

2023-02-02 01:370

响应式原理

Vue3 的响应式系统解决的是一个很具体的问题:数据变了,用到这个数据的地方要自动重新执行。手写的话我们得在每次改数据后手动调用更新函数,数据一多就完全维护不动。

整套机制拆开就三步:拦截读写、收集依赖、触发更新。

Proxy 取代 defineProperty

Vue2 用 Object.defineProperty 给每个属性装 getter/setter,它的局限很明确:只能拦截已存在的属性。新增属性、删除属性、数组下标赋值、修改 length 都拦不到,所以 Vue2 得提供 Vue.set、Vue.delete 这类 API 打补丁,还得重写数组的 7 个变更方法。

Vue3 换成 Proxy,代理的是整个对象而不是单个属性,所有操作都能被 handler 捕获:

const obj = { a: 1 };
const proxy = new Proxy(obj, {
  get(target, key, receiver) {
    console.log('读取', key);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log('设置', key, value);
    return Reflect.set(target, key, value, receiver);
  },
  deleteProperty(target, key) {
    console.log('删除', key);
    return Reflect.deleteProperty(target, key);
  },
  has(target, key) {
    console.log('in 判断', key);
    return Reflect.has(target, key);
  },
});

proxy.b = 2; // 设置 b 2,新增属性也能拦到
delete proxy.a; // 删除 a
console.log('b' in proxy); // in 判断 b

这里必须用 Reflect 而不是直接 target[key]。原因是 Reflect.get 的第三个参数 receiver 能正确传递 this:当被代理对象内部有 get fullName() { return this.first + this.last } 这种访问器时,直接返回 target[key] 会让 this 指向原始对象,于是 this.first 的读取不经过代理,依赖收集不到;用 Reflect.get(target, key, receiver) 才能让 this 指向代理对象。

副作用函数与依赖收集

拦截只是手段,核心问题是「数据变了要重新执行谁」。Vue3 把这个「谁」叫做副作用函数(effect),组件的渲染函数就是一个 effect。

要建立「数据 → effect」的关联,需要一个全局变量记录当前正在执行的 effect:

let activeEffect = null;

function effect(fn) {
  const run = () => {
    activeEffect = run;
    fn(); // 执行 fn 时,里面读到的响应式数据会把 run 收集进去
    activeEffect = null;
  };
  run();
  return run;
}

依赖存在一个三层结构里:WeakMap<target, Map<key, Set<effect>>>。用 WeakMap 是为了让对象被回收时依赖关系自动释放,不造成内存泄漏。

const targetMap = new WeakMap();

// getter 里调用:把当前 effect 记到 target.key 名下
function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  let dep = depsMap.get(key);
  if (!dep) depsMap.set(key, (dep = new Set()));
  dep.add(activeEffect);
}

// setter 里调用:把 target.key 名下所有 effect 拿出来重跑
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const dep = depsMap.get(key);
  if (!dep) return;
  // 复制一份再遍历,避免执行过程中增删导致无限循环
  [...dep].forEach(fn => fn());
}

用 Set 存 effect 是为了自动去重——同一个 effect 里多次读同一个属性只会被收集一次。

拼起来的最小实现

把上面的部分组合成一个能跑的 reactive:

function reactive(target) {
  return new Proxy(target, {
    get(t, key, receiver) {
      track(t, key);
      const res = Reflect.get(t, key, receiver);
      // 深层响应:取到的值是对象就继续代理(Vue3 是懒代理,读到才转换)
      return typeof res === 'object' && res !== null ? reactive(res) : res;
    },
    set(t, key, value, receiver) {
      const old = t[key];
      const res = Reflect.set(t, key, value, receiver);
      // 值没变就不触发,避免无意义的重新渲染
      if (!Object.is(old, value)) trigger(t, key);
      return res;
    },
  });
}

// 验证
const state = reactive({ price: 10, quantity: 2 });
let total = 0;
effect(() => {
  total = state.price * state.quantity;
  console.log('total:', total);
});
// total: 20

state.quantity = 5;
// total: 50,自动重新执行了

注意 getter 里的深层代理是懒执行的:只有真正读到嵌套对象时才会给它套 Proxy。Vue2 是在初始化时递归遍历整个对象装 getter/setter,对象很深时初始化开销大。这是 Vue3 性能提升的一个来源。

ref 为什么需要 .value

Proxy 代理不了原始值——new Proxy(1, {}) 会直接抛 TypeError。所以要让一个数字、字符串变成响应式的,必须包一层对象,把值放在属性上才有 get/set 可拦:

function ref(value) {
  const wrapper = {
    get value() {
      track(wrapper, 'value');
      return value;
    },
    set value(v) {
      if (Object.is(value, v)) return;
      value = v;
      trigger(wrapper, 'value');
    },
  };
  return wrapper;
}

.value 不是设计上的多余负担,而是原始值无法被代理的必然结果。模板里能直接写 {{ count }} 不写 .value,是因为编译期做了自动解包(setup 返回的 ref 会被 proxyRefs 处理)。

这也解释了两个常见的坑:解构 reactive 对象会丢响应性,因为解构出来的是原始值,脱离了 Proxy;toRefs 就是把每个属性转成一个保留了 .value 通路的 ref 来解决它。

const state = reactive({ count: 0 });
let { count } = state; // count 是普通数字 0,之后 state.count 变了它不变
const { count: c2 } = toRefs(state); // c2.value 始终跟 state.count 同步

更新是异步批量的

trigger 里如果直接同步执行渲染函数,一次事件里改五个属性就会渲染五次。Vue3 的做法是把 effect 推进一个微任务队列,同步代码跑完后统一执行一次,并且队列内去重:

const state = reactive({ a: 1 });
state.a = 2;
state.a = 3;
state.a = 4;
// DOM 只会更新一次,且是在微任务里更新
console.log(document.querySelector('#app').textContent); // 还是旧值
await nextTick();
console.log(document.querySelector('#app').textContent); // 4

这就是为什么改完数据要拿更新后的 DOM 必须等 nextTick。

几个实际的注意点

reactive 只对对象有效,传原始值会原样返回并在开发模式下警告。

reactive 返回的是代理对象,和原对象不是一个引用。reactive(obj) === obj 是 false,所以做身份比较时要统一用代理对象。对同一个对象重复调用 reactive 会返回缓存的同一个代理。

集合类型走单独的 handler。Map、Set 的读写是通过 get、set、add、delete 这些方法进行的,不是属性访问,Vue3 为它们准备了专门的 mutableCollectionHandlers 去重写这些方法。

数组的 length 有隐式依赖。arr.push(x) 会同时改下标和 length,Vue3 在 trigger 里对数组做了特殊处理,length 变小时要通知所有下标 ≥ 新 length 的依赖。

不要在 effect 里既读又写同一个属性,比如 state.count++,这会形成自我触发的无限循环。Vue3 在 trigger 时会跳过当前正在执行的 effect 来避免这种情况,但逻辑上还是应该避开这种写法。

💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEVue3 响应式原理加载中