手写代码实现Vue3.0 响应式

479 阅读12分钟

大家知道吗,Vue3.0中通过Proxy代理数据的方式替换了vue2.x中通过Object.defineProperty挟持对象属性的方式实现了响应式;这篇文章,我们将仿照Reactive API 中的reactive和watchEffect,结合vue3.0源码手写代码,研究Vue3.0如何通过Proxy代理数据实现响应式

下面这篇文章,主要是通过手写 丐版的vue3.0响应式代码,去掉部分非核心的边界条件和除了reactive外其他响应式API的影响(比如readonly,shallowReactive,shallowReadonly等),一步步带领大家从零开始了解和掌握Vue3.0 响应式核心原理;

回顾Vue 2.x响应式:

vue2.X使用Object.defineProperty,挟持对象的属性的get和set,收集依赖和触发依赖响应;

Object.defineProperty(obj, key, {  enumerable: true,  configurable: true,  get: () => {    var value = getter ? getter.call(obj) : val;    if (Dep.target) {      dep.depend();      if (childOb) {        childOb.dep.depend();        if (Array.isArray(value)) {          dependArray(value);        }      }    }    return value;  },  set: newVal => {    // ...省略    childOb = !shallow && observe(newVal);    dep.notify();  }});

我们看一个例子:

<template>  {{ obj.c }}</template><script>   export default {       data: {           obj: { a: 1 }       },       mounted() {           this.obj.c = 3       }   }</script>

这个例子中,我们对obj上原本不存在的c属性进行了一个赋值,但是在Vue2中,这是不会触发响应式的。

这是因为Object.defineProperty必须对于确定的key值进行响应式的定义,

这就导致了如果data在初始化的时候没有c属性,那么后续对于c属性的赋值都不会触发Object.defineProperty中的劫持。

在Vue2中,这里只能用一个额外的api Vue.set来解决。

Vue 3.0 ----Proxy代理:

const raw = {}const data = new Proxy(raw, {    get(target, key) { },    set(target, key, value) { }})

proxy代理实现代码如上,对整个raw对象实现访问代理,可以看出来,Proxy在定义的时候并不用关心key值;只要你定义了get方法,那么后续对于data上任何属性的访问(哪怕是不存在的),都会触发get的劫持,set也是同理。

这样Vue3中,对于需要定义响应式的值,初始化时候的要求就没那么高了,只要保证它是个可以被Proxy接受的对象或者数组类型即可。

  • Object.defineProperty只能劫持对象的属性,而Proxy是直接代理对象;

  • Object.defineProperty对新增属性需要手动进行Observe

  • vue2.x无法监控到数组下标的变化,因为vue2放弃了这个特性;

  • Proxy支持13种拦截操作,这是defineProperty所不具有的;

  • Proxy支持对Set,WeakSet,Map,weakMap的数据代理

当然Proxy也是有缺陷的,IE适配问题是它最大的缺陷,所有版本的IE浏览器都不能用。好在IE马上要光荣退出浏览器舞台了,微软宣布2021年停止支持IE浏览器,edge浏览器已经接过IE的大旗。

基于Proxy的实现:

基本思路:

  1. 定义某个数据为响应式数据,它会拥有收集访问它的函数的能力。

  2. 定义观察函数,在这个函数内部去访问响应式数据

要实现的代码如下:

// 响应式数据const counter = reactive({ num: 0 });​// 观察函数observe(() => console.log(counter.num));

这已经一目了然了,

  • reactive包裹的数据叫做响应式数据

  • observe内部执行的函数叫观察函数

观察函数默认定义后会立即执行:

访问时:observe函数会帮你去执行 console.log(counter.num),这时候 proxyget拦截到了对于counter.num的访问,这时候又可以知道访问者是 () => console.log(counter.num)这个函数,那么就把这个函数作为 num这个key值的观察函数收集在一个地方。修改时:下次对于

counter.num修改的时候,去找num这个key下所有的观察函数,轮流执行一遍。这样就实现了响应式模型。 ``

1.reactive()

在看reactive函数之前,以下是需要提前掌握的知识点:

// 键值对: proxy => target 的 weakMapexport const proxyToRaw = new WeakMap<ReactiveProxy, Raw>();// 键值对: target => proxy 的 weakMapexport const rawToProxy = new WeakMap<Raw, ReactiveProxy>();​export function canObserve(val: any): val is object {  return typeof val === "object" && val !== null;}

reactive() 的作用主要是将目标转化为响应式的 proxy 实例。例如:

export function reactive(target: Raw) {  if (!canObserve(target)) {    console.warn(`value cannot be made reactive: ${String(target)}`);    return target;  }  // 已经被定义成响应式proxy了 或者传入的是内置对象 就直接原封不动的返回  if (proxyToRaw.has(target)) {    return target;  }​  // 如果这个原始对象已经被定义过响应式 就返回存储的响应式proxy  const existProxy = rawToProxy.get(target);  if (existProxy) {    return existProxy;  }​  // 新建响应式proxy  return createReactive(target);}

reactive 函数的处理逻辑:

  1. 如果target不是可以代理的对象,返回target;

  2. 如果target已经是proxy实例,返回target;

  3. 如果target已经存在proxy实例,返回其proxy实例;

  4. 调用createReactive函数,新建响应式proxy;

其中1.需要单独说一下:

为什么要调用canObserve函数?

答案:通过canObserve函数判断target类型是否是可观察的对象,target 的类型必须为下列值之一 Object,Array,Map,Set,WeakMap,WeakSet 才可被监听。

2.createReactive()

在看createReactive函数之前,以下是需要提前掌握的知识点:

export enum TargetType {  INVALID = 0,  COMMON = 1,  COLLECTION = 2,}​function targetTypeMap(rawType: string) {  switch (rawType) {    case "Object":    case "Array":      return TargetType.COMMON;    case "Map":    case "Set":    case "WeakMap":    case "WeakSet":      return TargetType.COLLECTION;    default:      return TargetType.INVALID;  }}​export function getTargetType(value: Raw) {  return targetTypeMap(toRawType(value));}​export const toRawType = (value: unknown): string => {  // extract "RawType" from strings like "[object RawType]"  return Object.prototype.toString.call(value).slice(8, -1);};

createReactive() 函数根据target类型生成不同handler处理对象的Proxy实例:

function createReactive(target: Raw) {  // 获取当前taget的TargetType类型  const targetType = getTargetType(target);  const reactive = new Proxy(    target,    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,  );​  // 双向存储原始值和响应式proxy的映射  rawToProxy.set(target, reactive);  proxyToRaw.set(reactive, target);​  // 建立一个映射  // 原始值 -> 存储这个原始值的各个key收集到的依赖函数的Map  storeObservable(target);​  // 返回响应式proxy  return reactive;}

createReactive函数的处理逻辑:

  1. 根据getTargetType返回的类型 ,创建不一样的handler参数;

  2. 创建 target和row 互为k,v的weakMap存储;

  3. 通过storeObservable函数创建一个key -> ReactionForRaw(观察者的map)的映射;

  4. 返回 proxy实例;

步骤3 中生成的connectionStore ,这个connectionStore实际上是target收集key的依赖函数的映射;如下图

/** * connectionStore 类型 WeakMap * key值是target:传入的需要 * key -> ReactionForRaw(观察者的map)的映射 * **/const connectionStore = new WeakMap<Raw, ReactionForRaw>();​// reactionForRaw的key为对象key值 value为这个key值收集到的Reaction集合export type ReactionForRaw = Map<Key, ReactionForKey>;​// key值收集到的Reaction集合export type ReactionForKey = Set<ReactionFunction>;

这里面重点要提的是1:

创建的proxy实例,这里有两种传递的参数,一个是collectionHandlers,一个是baseHandlers;这个三元表达式targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,如果是普通的对象 ObjectArray,处理器对象就使用 baseHandlers;如果是 Set, Map, WeakMap, WeakSet 中的一个,就使用 collectionHandlers。

collectionHandlers 和 baseHandlers 是从 collectionHandlers.tsbaseHandlers.ts 处引入的。

baseHandlers对象

处理器对五种操作进行了拦截,分别是:

  1. get 属性读取

  2. set 属性设置

  3. deleteProperty 删除属性

  4. ownKeys

其中 get、ownKeys 操作会收集依赖,set、deleteProperty 操作会触发依赖。

export const baseHandlers = {  get,  set,  ownKeys,  deleteProperty,};

其中 ownKeys 可拦截以下操作:

  1. Object.getOwnPropertyNames()

  2. Object.getOwnPropertySymbols()

  3. Object.keys()

  4. Reflect.ownKeys()

get函数:

/** 劫持get访问 收集依赖 */function get(target: Raw, key: Key, receiver: ReactiveProxy) {  //... 此处忽略对数组的内置方法的特殊处理; 'includes', 'indexOf', 'lastIndexOf''push', 'pop', 'shift', 'unshift', 'splice'  const result = Reflect.get(target, key, receiver);  // 内置的Symbol不观察  if (    (typeof key === "symbol" && wellKnownSymbols.has(key)) ||    key === "__proto__"  ) {    return result;  }  // 收集依赖  track({ target, key, receiver, type: "get" });​  // 如果访问的是对象 则返回这个对象的响应式proxy  // 如果没有就重新调用reactive新建一个proxy  const reativeResult = rawToProxy.get(result);  if (canObserve(result)) {    if (reativeResult) {      return reativeResult;    }    return reactive(result);  }​  return result;}

大体的流程:

  1. Reflect.get()获取 key对应的值;

  2. wellKnownSymbols.has(key) 为 true 或原型对象返回result,不收集依赖;

  3. track收集依赖;

  4. 查看Reflect返回的result是否存可以observe;

  5. 查看result是否存在Proxy实例,存在返回reativeResult,不存在调用reactive响应化result;

为什么要用Reflect,用target[key]行不行?

track我们后面会讲!

set函数:

/** 劫持set访问 触发收集到的观察函数 */function set(target: Raw, key: Key, value: any, receiver: ReactiveProxy) {  // 确保原始值里不要被响应式对象污染  if (canObserve(value)) {    value = proxyToRaw.get(value) || value;  }  // 先检查一下这个key是不是新增的  const hadKey = hasOwnProperty.call(target, key);  // 拿到旧值  const oldValue = target[key];  // 设置新值  const result = Reflect.set(target, key, value, receiver);​  if (!hadKey) {    // 新增key值时以type: add触发观察函数    trigger({ target, key, value, receiver, type: "add" });  } else if (value !== oldValue) {    // 如果新旧值不相等,才触发依赖    // 什么时候会有新旧值相等的情况?例如监听一个数组,执行 push 操作,会触发多次 setter    // 第一次 setter 是新加的值 第二次是由于新加的值导致 length 改变    // 但由于 length 也是自身属性,所以 value === oldValue    trigger({      target,      key,      value,      oldValue,      receiver,      type: "set",    });  }  return result;}

大致流程是:

  1. 如果value是一个Proxy实例,那么获取他的target赋值给value;

  2. Reflect.set设置新值;

  3. 如果key之前不存在,trigger 触发key值的响应,type为add;

  4. 如果key存在 且 旧值不等于新值,trigger 触发key值的响应,type为set;

  5. 返回 Reflect.set 的返回值result;

observe.ts 文件(observe, trick, trigger)

observe函数

/** * 观察函数 * 在传入的函数里去访问响应式的proxy 会收集传入的函数作为依赖 * 下次访问的key发生变化的时候 就会重新运行这个函数 */export function observe(fn: Function): ReactionFunction {  if (fn[IS_REACTION]) {    fn = (fn as ReactionFunction).raw;  }​  // reaction是包装了原始函数只后的观察函数  // 在runReactionWrap的上下文中执行原始函数 可以收集到依赖。  const reaction: ReactionFunction = (...args: any[]) => {    return runReactionWrap(reaction, fn, this, args);  };​  // 先执行一遍reaction  reaction();  reaction[IS_REACTION] = true;  reaction.raw = fn;  // 返回出去 让外部也可以手动调用  return reaction;}

大致流程是:

  1. 如果fn已经是ReactionFunction,fn赋值为ReactionFunction.raw;

  2. 创建reaction函数,内部执行并返回 runReactionWrap(reaction, fn, this, args);

  3. 执行一遍reaction函数 并返回;

runReactionWrap

runReactionWrap观察当前ReactionFunction的状态,符合条件执行最后一步:清空当前ReactionFunction的依赖和清空依赖此的keyreactionsForKey依赖函数集合,将ReactionFunction入栈,执行并返回用户传入的函数fn,通过触发Proxy实例的get,track收集观察函数, ReactionFunction出栈;

首先看下ReactionFunction的类型:

export const IS_REACTION = Symbol("is reaction");export type Key = string | number | symbol;​// 需要定义响应式的原值export type Raw = object;​// 定义成响应式后的proxyexport type ReactiveProxy = object;​// 收集响应依赖的的函数export type ReactionFunction = Function & {  cleaners?: ReactionForKey[];  unobserved?: boolean;  raw?: Function;  [IS_REACTION]?: boolean;};

/** 把函数包裹为观察函数 */export function runReactionWrap(  reaction: ReactionFunction,  fn: Function,  context: any,  args: any[],) {  // 已经取消观察了 就直接执行原函数  if (reaction.unobserved) {    return Reflect.apply(fn, context, args);  }​  // 如果观察函数是已经在运行 直接返回  if (isRunning(reaction)) {    return;  }​  // 把上次收集到的依赖清空 重新收集依赖  // 这点对于函数内有分支的情况很重要  // 保证每次收集的都是确实能访问到的依赖  releaseReaction(reaction);  try {    // 把当前的观察函数推入栈内 开始观察响应式proxy    reactionStack.push(reaction);    // 运行用户传入的函数 这个函数里访问proxy就会收集reaction函数作为依赖了    return Reflect.apply(fn, context, args);  } finally {    // 运行完了永远要出栈    reactionStack.pop();  }}​​/** * 把上次收集到的观察函数清空 重新收集观察函数 * 这点对于函数内有分支的情况很重要 * 保证每次收集的都是确实能访问到的观察函数 */export function releaseReaction(reaction: ReactionFunction) {  if (reaction.cleaners) {    // 把key -> reaction的set里相应的观察函数清楚掉    reaction.cleaners.forEach((reactionsForKey: ReactionForKey) => {      reactionsForKey.delete(reaction);    });  }  // 重置队列  reaction.cleaners = [];}​

其中 releaseReaction(effect) 的作用是让 reactionFunction关联下的所有 的cleaners清空ReactionForKey,即清除这个依赖函数。

执行 track() 收集的依赖就是 ReactionFunction。趁热打铁,现在我们再来看一下 track()trigger() 函数。

回顾下上面提到的connectionStore ,它是一个weakMap类型的集合:

/** * connectionStore 类型 WeakMap * key值是target:传入的需要 * key -> ReactionForRaw(观察者的map)的映射 * **/const connectionStore = new WeakMap<Raw, ReactionForRaw>();​// reactionForRaw的key为对象key值 value为这个key值收集到的Reaction集合export type ReactionForRaw = Map<Key, ReactionForKey>;​// key值收集到的Reaction集合export type ReactionForKey = Set<ReactionFunction>;

WeakMap 和Map的区别是什么??

WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。

track

/** 依赖收集栈 */const reactionStack: ReactionFunction[] = [];​/** 依赖收集 在get操作的时候要调用 */export function track(operation: Operation) {  const runningReaction = getRunningReaction();  if (runningReaction) {    registerReactionForOperation(runningReaction, operation);  }}

/** •• * 把对响应式对象key的访问与观察函数建立关联 * 后续就可以在修改这个key的时候 找到响应的观察函数触发 */export function registerReactionForOperation(  reaction: ReactionFunction,  { target, key, type }: Operation,) {  if (type === "iterate") {    key = Array.isArray(target) ? "length" : ITERATION_KEY;  }​  // 拿到原始对象 -> 观察者的map  const reactionsForRaw = connectionStore.get(target);  // 拿到key -> 观察者的set  let reactionsForKey = reactionsForRaw.get(key);​  if (!reactionsForKey) {    // 如果这个key之前没有收集过观察函数 就新建一个    reactionsForKey = new Set();    // set到整个value的存储里去    reactionsForRaw.set(key, reactionsForKey);  }​  if (!reactionsForKey.has(reaction)) {    // 把这个key对应的观察函数收集起来    reactionsForKey.add(reaction);    // 把key收集的观察函数集合 加到cleaners队列中 便于后续取消观察    reaction.cleaners.push(reactionsForKey);  }}

trigger

/** 值更新时触发观察函数 */export function trigger(operation: Operation) {  getReactionsForOperation(operation).forEach((reaction) => reaction());}

/** *  根据key,type和原始对象 拿到需要触发的所有观察函数 */export function getReactionsForOperation({ target, key, type }: Operation) {  // 拿到原始对象 -> 观察者的map  const reactionsForTarget = connectionStore.get(target);  const reactionsForKey: ReactionForKey = new Set();  if (type === "clear") {    reactionsForTarget.forEach((_, key) => {      addReactionsForKey(reactionsForKey, reactionsForTarget, key);    });  } else {    // 把所有需要触发的观察函数都收集到新的set里    addReactionsForKey(reactionsForKey, reactionsForTarget, key);  }  // add和delete的操作 需要触发某些由循环触发的观察函数收集  // observer(() => rectiveProxy.forEach(() => proxy.foo))  if (type === "add" || type === "delete" || type === "clear") {    // ITERATION_KEY:    // 如果proxy拦截到的ownKeys的操作 就会用ITERATION_KEY作为观察函数收集的key    // 比如在观察函数里通过Object.keys()访问了proxy对象 就会以这个key进行观察函数收集    // 那么比如在delete操作的时候 是要触发这个观察函数的 因为很明显Object.keys()的值更新了​    // length:    // 遍历一个数组的相关操作都会触发对length这个属性的访问    // 所以如果是数组 只要把访问length时收集到的观察函数重新触发一下就可以了    // 如observe(() => proxyArray.forEach(() => {}))    const iterationKey = Array.isArray(target) ? "length" : ITERATION_KEY;    addReactionsForKey(reactionsForKey, reactionsForTarget, iterationKey);  }​  return reactionsForKey;}​

function addReactionsForKey(  reactionsForKey: ReactionForKey,  reactionsForTarget: ReactionForRaw,  key: Key,) {  const reactions = reactionsForTarget.get(key);  reactions &&    reactions.forEach((reaction) => {      reactionsForKey.add(reaction);    });}

流程大体清洗后,我们再看下那些触发track或trigger的操作:

export const enum TrackOpTypes {  GET = 'get',  HAS = 'has',  ITERATE = 'iterate'}​export const enum TriggerOpTypes {  SET = 'set',  ADD = 'add',  DELETE = 'delete',  CLEAR = 'clear'}​

总结以上,我们可以得出下面的数据响应式流程图,reactive作响应式数据注册,初始化并返回proxy实例,effect(或observe)作为观察函数,触发proxy和观察函数收集依赖;当proxy数据改变,会触发响应的effect(或observe)观察函数再次执行;

大家了解了vue3响应式的原理之后可以去学习下vue3关于reactive这一块的源码;

git仓库地址:《vue-next-master》

参考:

TypeScript从零实现基于Proxy的响应式库

Vue3 响应式原理