阅读 605
Vue3响应式原理

Vue3响应式原理

Vue3 的稳定版发布了很久了,去阅读官网的文档时发现推荐的一个优秀的 Vue 教程网站:Vue Mastery,里面的一篇Vue 3 Reactivity(Vue3 响应式)讲的是真的不错。跟着学完收获很多,顺着课程的思路总结一篇 Vue3 响应式的笔记,手动还原响应式的原理。

手动实现响应式

响应式,就是一个变量依赖了其他变量,当被依赖的其他变量更新后该变量也要响应式的更新。所以从零开始,先实现手动的更新。

单个变量的手动响应

先看几个单词的意思,

  • depdependence 的缩写,也就是依赖。
  • effect,指因某种原因导致产生结果,着重持续稳定的影响。
  • track,指追踪、踪迹。
  • trigger,指触发。

再看手动实现的代码,这里用到了 Set,不熟悉的话可参考 MDN:

let price = 5;
let quantity = 2;
let total = 0;

// dep是一个“依赖”集合,用来存放众多的effect
let dep = new Set();

/**
 * effect就是“影响”,其实反过来说就是单个的“依赖”
 * total “依赖”的就是 price 和 quantity,也就是被 price 和 quantity "影响"
 */
let effect = () => {
  total = price * quantity;
};

/**
 * track就是“追踪”,其实就是留下记录
 * 做的事情就是将 effect函数 添加到 dep 这个集合中
 */
function track() {
  dep.add(effect);
}

/**
 * trigger是触发
 * 就是执行了保存在 dep 这个集合中的所有 effect函数
 */
function trigger() {
  dep.forEach((effect) => effect());
}

track();
effect();
console.log(total); // output: 10
复制代码

上述代码是最最简单的实现,流程就是三步:

  1. 通过 effect 来表明影响 total 的依赖
  2. 通过 track 来保存 effect
  3. 通过 trigger 来执行 effect

最后输出的 total 肯定就是计算后的 10

对对象的多个属性手动响应

上个例子中 pricequantity 都是放在了不同的变量里,现在更进一步,把他们放到同一个对象里 let product = { price: 5, quantity: 2 },现在如果想让 product 对象变为响应式,就需要指定每个键的响应。

depsMap 的意思就是 dependencemap。也就是一个 map 中,每个键都对应某个属性的 dep

这里用到了 Map,不熟悉的话可参考 MDN

// 新建一个Map来存储deps
const depsMap = new Map();

/**
 *  与上例中的变化是,指定了个参数key,表明effect存储到对象哪个键对应的的dep中
 */
function track(key) {
  let dep = depsMap.get(key);
  // 对应dep不存在时就new一个
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  // 把effect添加进去
  dep.add(effect);
}
/**
 * 同样,触发的时候也要指定一个key,表明执行的是对象哪个键的对应的dep中的effect
 */
function trigger(key) {
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach((effect) => {
      effect();
    });
  }
}

let product = { price: 5, quantity: 2 };
let total = 0;

let effect = () => {
  total = product.price * product.quantity;
};

// 首次调用计算出total
effect();
console.log(total); // output: 10

// 将effect保存给 quantity 键对应的dep
track('quantity');
product.quantity = 3;
// 执行 quantity 键对应 dep 保存的 effect
trigger('quantity');
console.log(total); // output: 15
复制代码

在上述代码中,实现了对整个 product 对象的手动响应。

对多个对象的多个属性进行手动响应

继续升级上述代码,如果有多个对象需要响应式,那么就需要给不同的对象设置不同 depsMap。所以创建一个 WeakMap 类型的变量,命名为 targetMap 来存储多个对象的 depsMaptarget 就是指的需要被响应式的对象。

而之所以用 Map 类型是因为 Map 可以用“对象”作为键,用 WeakMap 方便对键(也就是被响应式的对象)进行垃圾回收。不熟悉 WeakMap 的话可参考 MDN

// 新建一个 WeakMap 来存储 depsMap
const targetMap = new WeakMap();

/**
 *  与上例中的变化是,多指定了个参数 target,表明 effect 存储到哪个对象对应的的 depsMap 中
 */
function track(target, key) {
  // 新增👇
  let depsMap = targetMap.get(target);
  // 对应depsMap不存在时就new一个
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  // 👆
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  dep.add(effect);
}

/**
 * 同样,触发的时候也要指定一个 target,表明执行的是哪个对象的对应的depsMap中的effect
 */
function trigger(target, key) {
  // 新增👇
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  // 👆
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach((effect) => {
      effect();
    });
  }
}
let product = { price: 5, quantity: 2 };
let total = 0;
let effect = () => {
  total = product.price * product.quantity;
};

// 首次调用计算出total
effect();
console.log(total); // output: 10
// 将 effect 保存给对应 product对象 的 depsMap 中的对应 quantity键 的 dep
track(product, 'quantity');
product.quantity = 3;
// 执行对应 product对象 的 depsMap 中的对应 quantity键 的 dep 中保存的 effect
trigger(product, 'quantity');
console.log(total); // output: 15
复制代码

上述代码实现了对不同对象的不同键进行手动响应。到了这一步,可以用课程中的一张图来清楚的表示下 targetMapdepsMapdep 之间的关系:

deps.png

变为自动响应

继续升级代码,给上述代码添加自动响应。在这里用到了 ProxyReflect, 不熟悉的话可参考 MDN

const targetMap = new WeakMap();
function track(target, key) {
  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(effect);
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach((effect) => {
      effect();
    });
  }
}
// 新增 👇
/**
 * @description: 例用Proxy和Reflect实现自动响应式
 * @param {Object} target 要响应的对象
 * @return {Proxy} 返回要响应对象的代理
 */
function reactive(target) {
  const handlers = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver);
      // 在访问这个target对象的key键之前,先把effect保存下
      track(target, key);
      return result;
    },
    set(target, key, value, receiver) {
      let oldValue = target[key];
      // 下面两步的顺序不能颠倒,很关键
      // 这一步其实就已经赋值成功了
      let result = Reflect.set(target, key, value, receiver);
      // 到这里再执行get时获取的是新设的值
      if (result && oldValue != value) {
        // 如果把这个target对象的key键的值改了,就得执行一遍对应的effect
        trigger(target, key);
      }
      return result;
    },
  };
  return new Proxy(target, handlers);
}
// 👆
let product = reactive({ price: 5, quantity: 2 });
let total = 0;

var effect = () => {
  total = product.price * product.quantity;
};

// 首次调用计算出total
effect();
console.log(total); // output: 10

// 注意:在这里与前面代码的不同就是我们没有手动调用trigger,实现的自动响应
product.quantity = 3;
console.log(total); // output: 15
复制代码

这段代码实现了自动响应,最关键的核心部分就是 reactive 函数,它返回了一个 Proxy,代理了对 target 对象的存取。首先在在 get 返回之前,自动调用 trackeffect 存到对应的位置。

巧妙的地方是 set,当我们执行 product.quantity = 3; 时,会先将 quantity 设为 3,再自动触发 trigger。这时最关键的地方来了,trigger 调用了存储的对应的 effect,计算出最新的 total15,实现了自动响应。

优化自动响应过程

上述代码实现了自动响应,但是现在还有两个明显不如人意的地方:

  1. 不能设置多种 effect
  2. 在我们设置 quantity3 的时候,trigger 调用了对应的 effect,这里的 effect 函数执行来计算 total 时,会再走一遍 proxyget 的流程。所以就会触发 track 的流程,但是这里我们并不需要触发 track 再保存一遍 effect

下面来优化上述两个问题:

const targetMap = new WeakMap();
let activeEffect = null; // 👈 新增,是否需要添加effect的标志

function track(target, key) {
  // 👇 新增,只有再activeEffect为真时才执行保存的操作
  if (activeEffect) {
    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); // 👈 修改,有直接添加effect改为了添加activeEffect
  }
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach((effect) => {
      effect();
    });
  }
}

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver);
      track(target, key);
      return result;
    },
    set(target, key, value, receiver) {
      let oldValue = target[key];
      let result = Reflect.set(target, key, value, receiver);
      if (result && oldValue != value) {
        trigger(target, key);
      }
      return result;
    },
  };
  return new Proxy(target, handler);
}

// 👇 新增
// 为了用统一的方式,把eff添加到对应的dep中,顺便还执行了一遍设置了初始值
// 这样以后,只有我们手动调用effect那次才会保存dep,用trigger触发的get就不会再保存一遍了
function effect(eff) {
  activeEffect = eff;
  activeEffect();
  activeEffect = null;
}
// 👆

let product = reactive({ price: 5, quantity: 2 });
let salePrice = 0;
let total = 0;

// 👇 同理也要对effect函数改造,把每一个要保存的dep变为了effect函数的参数
// 手动设定了total呃salePrice的初始值
effect(() => {
  total = product.price * product.quantity;
});
// 所以这里就不会把这个eff添加给quantity
effect(() => {
  salePrice = product.price * 0.9;
});
// 👆

console.log(total, salePrice); // output: 10, 4.5

// 设置quantity只会重新计算total
product.quantity = 3;
console.log(total, salePrice); // output: 15, 4.5

// 设置price后,total和salePrice对应的effect都会执行,都会被重新计算
product.price = 10;
console.log(total, salePrice); // output: 30, 9
复制代码

上述代码通过添加了一个 activeEffect 的标志位,解决了无效的重复执行保存的缺点;并把 effect() 变为 effect(eff) 的带参数形式,解决了多个 effect 的问题。

到这里为止,Vue3 Composition APIreactive 方法的实现流程已经被我们手动大致实现了一遍!

实现 ref

Vue3 Composition API 设计中,reactive 主要用于引用类型,另外专门提供了一个 ref 方法实现对原始类型的响应式。

大部分的地方都不用变,基本上就是添加了个 ref 方法,实现的方式就是对象访问器 getter/setter 来模仿 Proxy

const targetMap = new WeakMap();
let activeEffect = null;

function track(target, key) {
  if (activeEffect) {
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    if (!dep) {
      depsMap.set(key, (dep = new Set()));
    }
    dep.add(activeEffect);
  }
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach((effect) => {
      effect();
    });
  }
}

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver);
      track(target, key);
      return result;
    },
    set(target, key, value, receiver) {
      let oldValue = target[key];
      let result = Reflect.set(target, key, value, receiver);
      if (result && oldValue != value) {
        trigger(target, key);
      }
      return result;
    },
  };
  return new Proxy(target, handler);
}

// 👇 新增
/**
 * @description: ref使用getter和setter实现,模仿了Proxy的get和set
 * @param {Primary} raw
 * @return {Object} 返回响应对象
 */
function ref(raw) {
  const r = {
    get value() {
      // 在get之前,先保存到targetMap中
      track(r, 'value');
      return raw;
    },
    set value(newVal) {
      raw = newVal;
      // set了之后,触发effect更新
      trigger(r, 'value');
    },
  };
  return r;
}
// 👆

function effect(eff) {
  activeEffect = eff;
  activeEffect();
  activeEffect = null;
}

let product = reactive({ price: 5, quantity: 2 });
let salePrice = ref(0); // 👈 修改 此时的salePrice自身也是个响应式对象
let total = 0;

// 此时的salePrice自身也是个响应式对象
effect(() => {
  salePrice.value = product.price * 0.9; // 👈 修改
});

// 注意这里计算总价的方式变了,使用的是打折后的值来计算
effect(() => {
  total = salePrice.value * product.quantity; // 👈 修改
});

console.log(total, salePrice); // output: 9, 4.5

product.quantity = 3;
console.log(total, salePrice); // output: 13.5, 4.5

product.price = 10;
console.log(total, salePrice); // output: 27, 9
复制代码

其实可以发现,reactive 也能实现对原始类型的响应式,为什么还要专门提供一个 ref 方法?看对尤大的访谈中,尤大回答的是 reactive 还会添加更多处理流程,对于原始类型来说,是一种无用的负担。

实现 computed

跟响应式相关的最后一部分内容就是 computed 了,继续来手动实现它:

// 👇 代码不变
const targetMap = new WeakMap();
let activeEffect = null;

function track(target, key) {
  if (activeEffect) {
    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);
  }
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach((eff) => {
      eff();
    });
  }
}

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver);
      track(target, key);
      return result;
    },
    set(target, key, value, receiver) {
      let oldValue = target[key];
      let result = Reflect.set(target, key, value, receiver);
      if (result && oldValue != value) {
        trigger(target, key);
      }
      return result;
    },
  };
  return new Proxy(target, handler);
}

function ref(raw) {
  const r = {
    get value() {
      track(r, 'value');
      return raw;
    },
    set value(newVal) {
      raw = newVal;
      trigger(r, 'value');
    },
  };
  return r;
}

function effect(eff) {
  activeEffect = eff;
  activeEffect();
  activeEffect = null;
}
// 👆 代码不变

// 👇 新增
/**
 * @description: computed实现,其实就是封装了ref
 * @param {Function} getter 取值函数
 * @return {Object} ref返回的对象
 */
function computed(getter) {
  // 创建一个响应式的引用
  let result = ref();
  // 用effect封装着调用getter,将结果设给result.value的同时,也将eff保存在了targetMap中的对应位置
  effect(() => (result.value = getter()));
  // 最后把result返回
  return result;
}
// 👆

let product = reactive({ price: 5, quantity: 2 });

// 👇 修改
// 此时的salePrice自身也是一个响应式对象
let salePrice = computed(() => {
  return product.price * 0.9;
});

// total也是个响应式对象
let total = computed(() => {
  return salePrice.value * product.quantity;
});
// 👆

console.log(total.value, salePrice.value); // output: 9, 4.5

product.quantity = 3;
console.log(total.value, salePrice.value); // output: 13.5, 4.5

product.price = 10;
console.log(total.value, salePrice.value); // output: 27, 9
复制代码

可以发现,computed 本质上就是封装了 ref 方法,用 effect 封装着来调用 getter,将结果设给 result.value 的同时,也将 eff 保存在了 targetMap 中的对应位置,实现了 computed 的响应式。

Vue3 源码中响应式的实现

Vue3 整体是用 Typescript 写的,reactivity 是一个独立的模块,源代码位于packages/reactivity/src目录下,几个不同的方法分别位于不同文件中:

reactivity_source.png

  • effecttracktrigger 方法,位于 effect.ts
  • Proxygetset 这些 handler 方法,位于 baseHandlers.ts
  • reactive 方法位于 reactive.ts,使用了 Proxy
  • ref 方法位于 ref.ts,使用了对象访问器。
  • computed 方法位于 computed.ts,使用了 effectref

关于 Vue Mastery 课程

Vue Mastery 课程是收费的,25% 的收入会捐给 Vue 项目,所以大家对课程感兴趣的话可以开会员支持一波。不过它的会员很贵,可以有一些取巧的方法跳过收费验证,可以关注“林景宜的记事本”公众号发送“Vue3 响应式”获取方法试看一波。


前端记事本,不定期更新,欢迎关注!


文章分类
前端
文章标签