Vue3源码实现(二)- 实现reactive

140 阅读9分钟

前言

书接上节,我们已经把环境准备工作做完了,这节就来到了 Vue 中比较核心的功能之一:响应式,下面我们写下它如何实现的。

分解实现

本节主要是在 reactivity 目录里面实现所有的逻辑。

vue3-core
├─ packages
│  ├─ reactivity
│  │  ├─ package.json
│  │  └─ src
│  │     ├─ baseHandlers.ts
│  │     ├─ effect.ts
│  │     ├─ index.ts
│  │     └─ reactive.ts
│  └─ shared
│     ├─ package.json
│     └─ src
│        └─ index.ts
├─ scripts
│  └─ dev.js
└─ tsconfig.json

实现 reactive

我们先引用下官方 vuereactivity 模块中的 reactive 方法,我就直接在 html 里面操作演示了,它里面暴露出来的是一个对象,名叫 VueReactivity

const { reactive } = VueReactivity;

const state = reactive({
    name: "lisi",
    age: 12,
    hobby: ["rap"],
});
console.log(state);

上面代码运行效果如下:经过 reactive 方法调用,它返回的是一个 proxy 的代理对象。

image.png

下面我们实现自己的逻辑。至于为什么要使用 Reflect,可以看下我之前写的一篇文章

// packages/reactivity/src/index.ts
import { reactive } from "./reactive";

export { reactive };
// packages/reactivity/src/reactive.ts
import { isObject } from "@vue/shared";
import { baseHandlers } from "./baseHandlers";

// 将数据转化为响应式数据,只能代理对象类型
export function reactive(target: any) {
  if (!isObject(target)) return;

  let proxy = new Proxy(target, baseHandlers);
  
  return proxy
}
// packages/reactivity/src/baseHandlers.ts
export const baseHandlers = {
  get(target: object, key: PropertyKey, receiver: any) {
    return Reflect.get(target, key, receiver);
  },
  set(target: object, key: PropertyKey, value: any, receiver: any) {
    return Reflect.set(target, key, value, receiver);
  },
};
// packages/shared/src/index.ts
export const isObject = (val: unknown): val is Record<any, any> =>
  val !== null && typeof val === "object";

这样就实现了最基础的对象代理,那现在我们思考这样一个场景:在设置了代理后得到一个代理对象 state1,我们又将原对象代理了一遍 或者将代理对象又代理了一遍 得到 state2,那么它们应该是同一个对象,即 state1 === state2。好,下面我们完善下这个逻辑,原理是使用 weakMapproxy 寸一份,等到下次再调用 如果有的话直接返回,这种处理方式只能解决咱们的第一个问题:也就是将原对象代理。那将代理对象又代理如何处理呢,在这里其实是 添加了一个标识 IS_REACTIVE,在第二次代理的时候,判断 target 上有没有这个标识, 那么就会走 get,里面有判断逻辑,返回 true,最后直接返回的是 target,也就是代理对象。

const data = {
    name: "lisi",
    age: 12,
    hobby: ["rap"],
}
const state1 = reactive(data);
const state2 = reactive(data 或者 state1);
console.log(state1 === state2);
// packages/reactivity/src/reactive.ts
import { isObject } from "@vue/shared";
+ import { baseHandlers, ReactiveFlags } from "./baseHandlers";

+ const reactiveMap = new WeakMap<any, any>();

// 将数据转化为响应式数据,只能代理对象类型
export function reactive(target: any) {
  if (!isObject(target)) return;

+ if (target[ReactiveFlags.IS_REACTIVE]) return target;
  
+ let observed = reactiveMap.get(target);
+ if (observed) return observed;

  let proxy = new Proxy(target, baseHandlers);
  
+  reactiveMap.set(target, proxy);
  
  return proxy
}
// packages/reactivity/src/baseHandlers.ts
+ export const enum ReactiveFlags {
+  IS_REACTIVE = "__v_isReactive",
+ }

export const baseHandlers = {
  get(target: object, key: PropertyKey, receiver: any) {
+   if (key === ReactiveFlags.IS_REACTIVE) return true;

    return Reflect.get(target, key, receiver);
  },
  set(target: object, key: PropertyKey, value: any, receiver: any) {
    return Reflect.set(target, key, value, receiver);
  },
};

实现 Effect

经过前面 reactive 方法的实现,我们已经能够拿到一个响应式的数据对象了,我们进行 getset 操作都能够被拦截。 Effect 是 实现响应式重要的组成部分之一,主要负责收集依赖(核心就是将当前的 effect 和 所要渲染的属性关联起来,也就是 track 方法),更新依赖(对应的是 trigger 方法)。初始化时会默认执行一次,当依赖数据发生变化时,会再次更新视图

我们先引用官方的 effect 方法来演示下:初始化时页面读取 state中的值 进行视图渲染,当数据变化后会再次触发视图更新。

const { reactive, effect } = VueReactivity;

const state = reactive({
    name: "lisi",
    age: 12,
    hobby: ["rap"],
});
effect(() => {
    document.getElementById("app").innerHTML = `${state.name} ${state.age} 岁`;
});
setTimeout(() => {
    state.age++;
}, 1000);

xx.gif

下面我们实现自己的逻辑。看看如何和响应式数据关联起来。

即然要实现 effect,那么我们先 创建一个 effect 方法,里面的逻辑是:创建响应式的 effect,便于后续依赖数据变化了,要自动做更新。

在这里我们使用类的形式创建 ReactiveEffect 构造函数,在 run 方法里面我们执行 fn 函数,并使用 activeEffect 保存了当前正在执行的 effect,目的就是 在稍后进行取值操作时,也就是在 track 逻辑里能够获取到这个全局的 activeEffect

// packages/reactivity/src/effect.ts

export let activeEffect: ReactiveEffect | undefined;

class ReactiveEffect<T = any> {
  constructor(public fn: () => T) {}
  // 执行 effect
  run() {
    try {
      activeEffect = this;
      const result = this.fn();
      return result;
    } finally {
      activeEffect = undefined;
    }
  }
}

export function effect<T = any>(fn: () => T) {
  // 创建 响应式 effect
  const _effect = new ReactiveEffect(fn);
  _effect.run();
}

那如果有嵌套的 effect 呢?上面逻辑就有问题了,因为在 finally 里面,把 activeEffect 置为了 undefined,导致后续的逻辑不能执行,如下代码:state.c = 3 将不会被收集。

effect(() => {
  state.a = 1;
  effect(() => {
    state.b = 2;
  })
  state.c = 3;
})

那如何解决呢:框架老版本使用的栈结构记录了 effect的先后顺序,当前执行的永远是指向最后一个,每执行完一个,就会弹出栈顶元素, 在新版本的 core 里面,使用树形结构记录了 parent 是谁。

// packages/reactivity/src/effect.ts

export let activeEffect: ReactiveEffect | undefined;

class ReactiveEffect<T = any> {
+ parent: ReactiveEffect | undefined = undefined; 
  constructor(public fn: () => T) {}
  // 执行 effect
  run() {
    try {
+     this.parent = activeEffect;
      activeEffect = this;
      const result = this.fn();
      return result;
    } finally {
-     activeEffect = undefined;
+     activeEffect = this.parent;
+     this.parent = undefined;
    }
  }
}

export function effect<T = any>(fn: () => T) {
  // 创建 响应式 effect
  const _effect = new ReactiveEffect(fn);
  _effect.run();
}

实现 track

如何把 effect 和 依赖数据关联起来呢,下面我们来实现 track 逻辑。

// packages/reactivity/src/baseHandlers.ts

+ import { track } from "./effect";

export const enum ReactiveFlags {
  IS_REACTIVE = "__v_isReactive",
}

export const baseHandlers = {
  get(target: object, key: PropertyKey, receiver: any) {
    if (key === ReactiveFlags.IS_REACTIVE) return true;

+   track(target, "get" as const, key);

    return Reflect.get(target, key, receiver);
  },
  set(target: object, key: PropertyKey, value: any, receiver: any) {
    const result = Reflect.set(target, key, value, receiver);
    return result;
  },
};

我们使用 WeakMap 来存储 若干个响应式对象 && 它们的若干个属性 对应哪些 effect,若干个响应式对象用一个 Map 来存储,那么它里面的属性使用 Set 结构存储的依赖的哪些 effect。 大致的结构是这样的:

{
  state1: {
    key: [effect, effect]
  },
  state2: {
    key: [effect, effect]
  }
}

当修改响应式数据不在 effect 中,也就是没有 activeEffect 的时候,这个时候不应该被收集,所以先做了一次判断,另一个处理点就是判断 dep 里面是否已经包含了 activeEffect 来决定是否被收集。

// packages/reactivity/src/effect.ts

const targetMap = new WeakMap();
export function track(target: object, type: string, key: PropertyKey) {
  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()));
  }
  let shouldTrack = !dep.has(activeEffect);
  if (shouldTrack) {
    dep.add(activeEffect);
  }
}

写到这里,我们只是实现了单向收集:也就是 属性记录了哪些 effect, 我们试想另一个问题:在 effect 里面遇到 三目运算符,当判断条件变化的时候,我们要删除之前记录的属性,所以我们还要让 effect 记录它被哪些属性收集过,这样做的好处是为了清理。这里先记录下,具体实现逻辑在下面 实现 cleanupEffect

一个属性对应多个 effect, 一个 effect 对应多个属性

effect(() => {
  isBoy ? state.value1++ : state.value2++
})

// packages/reactivity/src/effect.ts
export let activeEffect: ReactiveEffect | undefined;

class ReactiveEffect<T = any> {
  parent: ReactiveEffect | undefined = undefined;
+ deps: any = [];
  constructor(public fn: () => T) {}
  // 执行effect
  run() {
    try {
      this.parent = activeEffect;
      activeEffect = this;
      const result = this.fn();
      return result;
    } finally {
      activeEffect = this.parent;
      this.parent = undefined;
    }
  }
}

const targetMap = new WeakMap();
export function track(target: object, type: string, key: PropertyKey) {
  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()));
  }
  let shouldTrack = !dep.has(activeEffect);
  if (shouldTrack) {
    dep.add(activeEffect);
+   activeEffect.deps.push(dep);
  }
}

export function effect<T = any>(fn: () => T) {
  // 创建 响应式 effect
  const _effect = new ReactiveEffect(fn);
  _effect.run();
}

实现 trigger

上面实现了依赖收集,接下来就是触发更新了,也就是 trigger 逻辑。

// packages/reactivity/src/baseHandlers.ts
+ import { track, trigger } from "./effect";

export const enum ReactiveFlags {
  IS_REACTIVE = "__v_isReactive",
}

export const baseHandlers = {
  get(target: object, key: PropertyKey, receiver: any) {
    if (key === ReactiveFlags.IS_REACTIVE) return true;

    track(target, "get" as const, key);

    return Reflect.get(target, key, receiver);
  },
  set(target: object, key: PropertyKey, value: any, receiver: any) {
+   // 判断新值和老值是否一致,不一致,执行 trigger
+   let oldValue = (target as any)[key];
const result = Reflect.set(target, key, value, receiver);
+   if (oldValue !== value) {
+     trigger(target, 'set', key, value, oldValue)
+   }
    return result;
  },
};
// packages/reactivity/src/effect.ts
export function trigger(
  target: object,
  type: string,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown
) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  effects && effects.forEach((e: ReactiveEffect) => {
    e.run()
  });
}

上面的实现是有点小问题的,我们看如下例子:

const { reactive, effect } = VueReactivity;

const state = reactive({
    name: "lisi",
    age: 12,
    hobby: ["rap"],
});
effect(() => {
    state.age = Math.random();
    document.getElementById(
      "app"
    ).innerHTML = `${state.name} ${state.age} 岁`;
});
setTimeout(() => {
    state.age++;
}, 1000);

运行代码后:导致死循环,原因是 state.age 会频繁改变,触发 set,进而触发 trigger,又执行 该 effect。所以我们加下判断条件。

image.png

// packages/reactivity/src/effect.ts
export function trigger(
  target: object,
  type: string,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown
) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  effects && effects.forEach((e: ReactiveEffect) => {
+   if (e !== activeEffect) e.run()
  });
}

好,上面我们只考虑了 对象的情况,那数组的情况呢,我们看下面的例子:

const state = reactive([1, 2, 3]);
effect(() => {
    document.getElementById("app").innerHTML = `${state}`;
});

setTimeout(() => {
    state.length = 1;
}, 1000);

数据变化是直接更新数组的长度,而在 effect 中没有使用 length 属性,所以在更新 length 属性时不会触发 effects 的依次执行,这样 length 改变 effect 回调函数不会执行,视图也不会被更新。这时就需要对属性是 length 的数组进行验证,如果直接更新的是数组的长度就需要单独处理:

// packages/reactivity/src/effect.ts
export function trigger(
  target: object,
  type: string,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown
) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
- const effects = depsMap.get(key); 
- effects && effects.forEach((e: ReactiveEffect) => { 
-   if (e !== activeEffect) e.run() 
- });
+ const run = (effects: ReactiveEffect<any>[]) => {
+   effects &&
+     effects.forEach((e: ReactiveEffect) => {
+       if (e !== activeEffect) e.run();
+     });
+ };
+ if (key === "length" && Array.isArray(target)) {
+   depsMap.forEach((deps: any, key: string) => {
+     key === "length" && run(deps);
+   });
+ } else run(depsMap.get(key));
}

实现 cleanupEffect

上面我们已经实现了比较基本的 tracktrigger 了,下面我们看下面的例子:

const { reactive, effect } = VueReactivity;

const state = reactive({
  flag: true,
  name: "lisi",
  age: 12,
  hobby: ["rap"],
});
effect(() => {
  console.log("重新执行");
  document.getElementById("app").innerHTML = state.flag
    ? state.name
    : state.age;
});
setTimeout(() => {
  state.flag = false;
  setTimeout(() => {
    state.name = "zhangsan";
  }, 1000);
}, 1000);

上面代码运行效果如下:在定时器里我们修改了 state.flag 值后, effect 里面将不会再对 state.name收集,这是我们期望的,所以打印 两次 log是正常的,但实际上却打印了三次,原因是我们 分支控制的功能还没有实现。

QQ20220826-102512-HD.gif

// packages/reactivity/src/effect.ts 
export let activeEffect: ReactiveEffect | undefined; 
class ReactiveEffect<T = any> { 
    parent: ReactiveEffect | undefined = undefined; 
    deps: any = []; 
    constructor(public fn: () => T) {} 
    // 执行effect 
    run() { 
        try { 
            this.parent = activeEffect; 
            activeEffect = this; 
+           cleanupEffect(this);
            const result = this.fn(); 
            return result; 
        } finally { 
            activeEffect = this.parent; 
            this.parent = undefined; 
        }
    } 
}

+ function cleanupEffect(effect: ReactiveEffect) {
+   const { deps } = effect;
+   if (deps.length) {
+     for (let i = 0; i < deps.length; i++) {
+       deps[i].delete(effect);
+     }
+     effect.deps.length = 0;
+   }
+ }

好家伙,又死循环了,我们分析下原因:执行完 cleanupEffect 逻辑后,我们执行 fn,会触发依赖收集,改值会触发 trigger,里面又执行了 run,我们可以拷贝一份,生成一个新的引用。

// packages/reactivity/src/effect.ts
export function trigger(
  target: object,
  type: string,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown
) {
  ....
-   } else run(depsMap.get(key));
+   } else run([...depsMap.get(key)]);
}

xx.mp4.gif

总结

本文我们实现了基础版的 reactiveeffect,并对里面的细节做了些处理,相比于 core 来说,处理边缘问题和覆盖更多 case 还是差的比较多呢。

最后希望各位大佬积极指出错误。