likes
comments
collection
share

手把手带你实现一个自己的简易版 Vue3(三)

作者站长头像
站长
· 阅读数 4

👉 项目 Github 地址:github.com/XC0703/VueS…

(希望各位看官给本菜鸡的项目点个 star,不胜感激。)

3、依赖收集

3-1 effect 方法的使用

effect 本质是一个函数,第一个参数为函数,第二个参数为一个配置对象。第一个传入的参数会默认执行,执行过程中会收集该函数所依赖的响应式数据。当这些响应式数据发生变化时,effect 函数将被重新执行。官方文档见:cn.vuejs.org/api/reactiv…

<!-- effect 函数的基本使用 -->
<!-- weak-vue\packages\examples\2.effect.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>2.effect</title>
  </head>
  <body>
    <div id="id"></div>
    <script src="../dist/vue.global.js"></script>
    <script>
      let { reactive, effect } = Vue;
      let state = reactive({ name: "张三", age: 20 });
      // effect方法相当于Vue2.0中的watch方法,第一个参数传入函数,默认执行(如果传入了{laze: true}则不是)
      // 观察者模式
      // 默认执行过程:effect是一个依赖收集器,如果执行的函数中用到的数据已经被代理过了,则会去执行get()方法收集effect依赖
      effect(
        () => {
          app.innerHTML = state.name + state.age;
        },
        { laze: true }
      );

      // 1s后修改被代理的数据,导致触发set方法,执行effect
      setTimeout(() => {
        state.name = "lis";
      }, 1000);
    </script>
  </body>
</html>

3-2 effect 方法的实现

3-2-1 effect 方法的定义

首先定义一个响应式创建函数 createReactEffect(fn, options),该高阶函数会将传入的用户方法 fn 包装成一个新的 effect 函数,并返回这个新的函数。每个 fn 都有自己的 effect

// weak-vue\packages\reactivity\src\effect.ts
// effect 的基本结构
export function effect(fn, options: any = {}) {
  // 对于每个fn,都能创建自己的effect
  const effect = createReactEffect(fn, options);

  // 判断一下
  if (!options.lazy) {
    effect(); // 默认执行
  }
  return effect;
}

effect 是一个高阶函数,同时也是一个和每个 fn 一一对应的对象,这个对象上面有很多属性,比如 id(唯一标识)、_isEffect(私有属性,区分是不是响应式的 effect)、raw(保存用户的方法)、options(保存用户的 effect 配置):

// weak-vue\packages\reactivity\src\effect.ts
// 创建一个依赖收集器effect,并定义相关的属性。每个数据(变量)都有自己的effect。
function createReactEffect(fn, options) {
  // effect是一个高阶函数
  const effect = function reactiveEffect() {
    fn();
  };
  // effect也是一个对象
  effect.id = uid++; // 区分effect
  effect._isEffect = true; // 区分effect是不是响应式的effect
  effect.raw = fn; // 保存用户的方法
  effect.options = options; // 保存用户的effect配置
  activeEffect = effect;
  return effect;
}

3-2-2 属性和 effect 方法的关系

要实现响应式,首先第一步是收集变量涉及的 effect,也就是对于一个变量/对象来说,他是在函数中会被用到的,这个函数会经过我们上面的处理会变成 effect 函数,如果变量/对象发生改变(具体的是某个属性 key,比如 增 GET删 DELETE 等),则去重新触发对应的 effect 函数。在被代理的时候,就去收集相应的 effect依赖。这一步是在定义代理- 获取 get()配置中实现的:

// weak-vue\packages\reactivity\src\baseHandlers.ts
// 判断
if (!isReadonly) {
  // 不是只读则收集依赖(三个参数为代理的变量/对象,对该变量做的操作(增删改等),操作对应的属性)
  Track(target, TrackOpType.GET, key);
}

那怎么找到 target[key]涉及的所有 effect 呢?首先我们可以借助一个全局变量 activeEffect 拿到当前的 effect

// weak-vue\packages\reactivity\src\effect.ts
let activeEffect; // 保存当前的effect

export function Track(target, type, key) {
  console.log(`对象${target}的属性${key}涉及的effect为:`);
  console.log(activeEffect); // 拿到当前的effect
}

此时去跑一下我们的示例:

<!-- weak-vue\packages\examples\2.effect.html -->
<body>
  <div id="app"></div>
  <script src="../reactivity/dist/reactivity.global.js"></script>
  <script>
    let { reactive, effect } = VueReactivity;
    let state = reactive({ name: "张三", age: 20 });
    // effect方法相当于Vue2.0中的watch方法,第一个参数传入函数,默认执行(如果传入了{laze: true}则不是)
    // 观察者模式
    // 默认执行过程:effect是一个依赖收集器,如果执行的函数中用到的数据已经被代理过了,则会去执行get()方法收集effect依赖
    effect(() => {
      app.innerHTML = state.name + state.age;
    });
  </script>
</body>

可以看到,控制台打印了两次我们当前的 effect手把手带你实现一个自己的简易版 Vue3(三)

3-2-3 effect 栈的定义

上面说明被代理的对象属性是能够拿到涉及的 effect 的,但是如果仅凭一个全局变量 activeEffect 实现属性的 effect 收集,可能会导致一个问题:如果像下面这样存在嵌套结构,则可能会导致 effect 收集出错:

effect(() => {
  // effect1,activeEffect变为effect1
  state.name; // 触发get收集activeEffect,即effect1
  effect(() => {
    // effect2,activeEffect变为effect2
    state.age; // 触发get收集activeEffect,即effect2
  });
  state.a; // 触发get收集activeEffect,即effect2,出错。应该是effect1!!!
});

针对这个问题,说明仅凭一个全局变量 activeEffect 实现属性的 effect 收集是不够的,应该借助一个栈存储结构来存储我们产生的所有 effect,借助入栈出栈操作来避免这个问题:

// weak-vue\packages\reactivity\src\effect.ts
const effectStack = []; // 用一个栈来保存所有的effect

const effect = function reactiveEffect() {
  // 确保effect唯一性
  if (!effectStack.includes(effect)) {
    try {
      // 入栈,将activeEffect设置为当前的effect
      effectStack.push(effect);
      activeEffect = effect;
      fn(); // 执行用户的方法
    } finally {
      // 不管如何都会执行里面的方法
      // 出栈,将当前的effect改为栈顶
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1];
    }
  }
};

此时上面的例子执行过程变成这样,收集正确:

effect(() => {
  // effect1,effect1入栈,activeEffect变为effect1,此时栈顶为effect1
  state.name; // 触发get收集activeEffect,即effect1
  effect(() => {
    // effect2,effect2入栈,activeEffect变为effect2,此时栈顶为effect2
    state.age; // 触发get收集activeEffect,即effect2
  }); // effect2执行完毕,即finally,此时effect2出栈,此时栈顶为effect1,activeEffect变为effect1
  state.a; // 触发get收集activeEffect,即effect1,收集正确
});

3-2-4 key 和 effect 一一对应

上面提到的依赖收集方法 Track 只是将每个被代理的属性 key 涉及的 effect 打印出来,那么如果描述这种一对多且不重复的对应关系呢?答案是借助 weakmap 结构和 set 结构,实现 target=>Map(key=>Set(n) {effect1, effect2, ..., effectn})这种结构。实现 Track 方法的逻辑如下:

  • 如果 activeEffect 不存在,说明当前 get 的属性没有在 effect 中使用或者变量不存在;
// weak-vue\packages\reactivity\src\effect.ts
if (activeEffect === undefined) {
  // 说明没有在effect中使用(变量不是响应式或者变量不存在)
  return;
}
  • 首先用一个全局 targetMap 对象存储所有 target 对象和各自的 Map(key=>Set(n) {effect1, effect2, ..., effectn})的映射关系,targetMap 中的 key 为一个 target 对象,value 为依赖 Map(key=>Set(n) {effect1, effect2, ..., effectn}):
// weak-vue\packages\reactivity\src\effect.ts
let targetMap = new WeakMap();
  • 然后借助 targetMap,可以拿到每个 target 对象的依赖 Map,如果该依赖 Map 不存在则新插入一个:
// weak-vue\packages\reactivity\src\effect.ts
let depMap = targetMap.get(target);
if (!depMap) {
  targetMap.set(target, (depMap = new Map()));
}
  • depMap 是一个依赖 map,它的 key 为 target 对象中的每个属性 key,value 为每个属性涉及的所有不重复 effect。可以借助 depMap 拿到每个属性 key 的所有 effect 的 Set 结构,如果该 Set 不存在则新建一个:
// weak-vue\packages\reactivity\src\effect.ts
let dep = depMap.get(key);
if (!dep) {
  // 没有属性
  depMap.set(key, (dep = new Set()));
}
  • 拿到属性 key 的所有 effect 之后,可以去判断 activeEffect 是否已经在其中,没有则插入,实现 effect 依赖的收集:
// weak-vue\packages\reactivity\src\effect.ts
if (!dep.has(activeEffect)) {
  dep.add(activeEffect);
}

Track 方法的全部代码如下:

// weak-vue\packages\reactivity\src\effect.ts
// 收集依赖的操作(触发get()的时候,如果数据(变量)不是只读的,则触发Track,执行对应的依赖收集操作)
let targetMap = new WeakMap();
export function Track(target, type, key) {
  // console.log(`对象${target}的属性${key}涉及的effect为:`);
  // console.log(activeEffect); // 拿到当前的effect

  // key和我们的effect一一对应(map结构)
  if (activeEffect === undefined) {
    // 说明没有在effect中使用(变量不是响应式或者变量不存在)
    return;
  }
  // 获取对应的effect
  let depMap = targetMap.get(target);
  if (!depMap) {
    targetMap.set(target, (depMap = new Map()));
  }
  let dep = depMap.get(key);
  if (!dep) {
    // 没有属性
    depMap.set(key, (dep = new Set()));
  }
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect);
  }

  console.log(targetMap);
}

此时执行我们的示例:

<!-- weak-vue\packages\examples\2.effect.html -->
<script>
  let { reactive, effect } = VueReactivity;
  let state = reactive({ name: "张三", age: 20, sex: "男" });
  effect(() => {
    state.name;
    effect(() => {
      state.age;
    });
    state.sex;
  });
</script>

可以看到 targetMap 是我们想要的结构,能极大方便我们找到依赖关系:手把手带你实现一个自己的简易版 Vue3(三)


自此,我们对每个变量/属性的依赖收集操作便已完成,到这里的源码请看提交分支:3、依赖收集

4、触发更新

4-1 前言

从前面关于响应式 API 的实现中可以知道,Vue3 发布后,在双向数据绑定这里,使用 Proxy 代替了 Object.defineProperty

众所周知,Object.defineProperty 在对对象属性监听时,必须通过循环遍历对象,对一个个属性进行监听(在 Vue 中考虑性能问题并未采用这种方式,因为可能存在递归层叠地狱,所以需要特殊处理数组的变动,重写了数组的一些方法。)。

Proxy 是对一整个对象进行监听,同时 Proxy 的一大优势就是可以监听数组。触发 get方法时进行依赖收集,触发 set方法时进行触发涉及 effect 实现更新。

Proxy 可以监听数组更新的原理是进行数组的代理时,原理上会将其转化为一个类数组对象的形式,实现整个对象的代理,然后实现监听。因此在触发 set 方法时,我们需要区分当前的 target 对象是一个真正的对象还是一个数组,方便我们后续的操作。

4-2 更新类型判断和触发更新

4-2-1 判断数组/是否新增

我们先看曾经定义的 createSetter 方法,留了一个 TODO(通过反射器设置完变量的新值后,触发更新):

// weak-vue\packages\reactivity\src\baseHandlers.ts
// 代理-获取set()配置
function createSetter(shallow = false) {
  return function set(target, key, value, receiver) {
    const res = Reflect.set(target, key, value, receiver); // 获取最新的值,相当于target[key] = value

    // TODO:触发更新
    return res;
  };

上面的代码的意思是 proxy 监听到对target[key]=value 的操作,但这个操作可能是新增也可能是修改。因此对于值的更新操作,我们需要从两个维度考虑:(1)是数组还是对象;(2)添加值还是修改值。对于第一个判断,由于 target 已经是被代理过的对象了,所以数组所以要另写方法判断:

// weak-vue\packages\reactivity\src\baseHandlers.ts
// 代理-获取set()配置
function createSetter(shallow = false) {
  return function set(target, key, value, receiver) {
    // (1)获取老值
    const oldValue = target[key];

    // (2)判断target是数组还是对象,此时target已经是被代理过的对象了,所以要另写方法判断
    // 如果是数组,key的位置小于target.length,说明是修改值;如果是对象,则直接用hasOwn方法判断
    let hasKey = ((isArray(target) && isIntegerKey(key)) as unknown as boolean)
      ? Number(key) < target.length
      : hasOwn(target, key);

    // (3)设置新值
    const res = Reflect.set(target, key, value, receiver); // 获取最新的值,相当于target[key] = value,返回的res是布尔值,设置新值成功之后返回true

    // (4)触发更新
    if (!hasKey) {
      // 此时说明是新增
      trigger(target, TriggerOpType.ADD, key, value);
    } else if (hasChange(value, oldValue)) {
      // 修改的时候,要去判断新值和旧值是否相同
      trigger(target, TriggerOpType.SET, key, value, oldValue);
    }

    return res;
  };
}

其中用到的三个方法定义及解释如下:

// weak-vue\packages\shared\src\general.ts
// 判断对象是否有某个属性(两个参数,返回值为布尔型,key is keyof typeof val使用了ts的类型守卫语法)
const hasOwnProperty = Object.prototype.hasOwnProperty;
export const hasOwn = (
  val: object,
  key: string | symbol
): key is keyof typeof val => hasOwnProperty.call(val, key);

// 判断数组的key是否是整数
// 数组经过proxy代理之后,会变成对象的形式,如console.log(new Proxy([1,2,3],{})); ===》Proxy(Array) {'0': 1, '1': 2, '2': 3}(js对象的key类型为字符串),因此"" + parseInt(key, 10)这样是为了方便拿到正确的字符串key用于判断
// console.log(Array.isArray(new Proxy([1,2,3],{})))===》true
// 比如此时arr[2]=4,应该是
export const isIntegerKey = (key) => {
  isString(key) &&
    key !== "NaN" &&
    key[0] !== "-" &&
    "" + parseInt(key, 10) === key;
};

// 判断值是否更新
export const hasChange = (value, oldValue) => value !== oldValue;

4-2-2 判断之后进行更新

在上面我们用 hasKey 来判断添加值还是修改值,下面我们就要实现对应的触发更新方法 trigger

// weak-vue\packages\reactivity\src\effect.ts
// 触发更新
export function trigger(target, type, key?, newValue?, oldValue?) {
  console.log(target, type, key, newValue, oldValue);
}

此时去跑一下我们的测试用例:

<!-- weak-vue\packages\examples\2.effect.html -->
<script>
  let { reactive, effect } = VueReactivity;
  let state = reactive({ name: "张三", age: 20, sex: "男" });

  // 一秒后触发更新==>触发set,执行对应的effect,处理是新增还是修改
  setTimeout(() => {
    state.name = "李四"; // 更新
    state.hobby = "写代码"; // 新增
  }, 1000);
</script>

可以看到被打印了出来:手把手带你实现一个自己的简易版 Vue3(三)

此时我们具体实现一下 trigger 方法,其实就是触发对应的 effect 方法。在前面,我们已经用了 Track 方法收集了所有的 effect 依赖并存储在 targetMap 里面,因此现在我们在 trigger 方法里面需要做的就是通过 targetMap 找到对应的 effect 方法进行触发即可。

// weak-vue\packages\reactivity\src\effect.ts
// 触发更新
export function trigger(target, type, key?, newValue?, oldValue?) {
  // console.log(target, type, key, newValue, oldValue);

  // 已经收集好的依赖,是target=>Map(key=>Set(n) {effect1, effect2, ..., effectn})这种结构。
  // console.log(targetMap);

  // 获取对应的effect
  const depMap = targetMap.get(target);
  if (!depMap) {
    return;
  }
  const effects = depMap.get(key);

  // 不重复执行effect
  let effectSet = new Set();
  const addEffect = (effects) => {
    if (effects) {
      effects.forEach((effect) => effectSet.add(effect));
    }
  };
  addEffect(effects);
  effectSet.forEach((effect: any) => effect());
}

此时再去执行我们的测试用例:

<!-- weak-vue\packages\examples\2.effect.html -->
<script>
  let { reactive, effect } = VueReactivity;
  let state = reactive({
    name: "张三",
    age: 20,
    sex: "男",
    list: [1, 2, 3, 4],
  });
  effect(() => {
    console.log(state.name);
    effect(() => {
      console.log(state.age);
    });
    console.log(state);
  });

  // 一秒后触发更新==>触发set,执行对应的effect,处理是新增还是修改
  setTimeout(() => {
    console.log("这是一秒后更新的结果:");
    state.hobby = "写代码"; // 新增ADD
    state.name = "李四"; // 更新SET
    state.list[0] = 0;
  }, 1000);
</script>

可以看到结果符合我们的预期:!手把手带你实现一个自己的简易版 Vue3(三)

4-2-3 对数组进行特殊处理

但如果我们通过直接改变数组的长度的话,并且 effect 中又用到的话,像下面这样:

<!-- weak-vue\packages\examples\2.effect.html -->
<script>
  let { reactive, effect } = VueReactivity;
  let state = reactive({
    name: "张三",
    age: 20,
    sex: "男",
    list: [1, 2, 3, 4],
  });
  effect(() => {
    console.log(state);
    app.innerHTML = state.name + state.list[1];
  });

  // 一秒后触发更新==>触发set,执行对应的effect,处理是新增还是修改
  setTimeout(() => {
    console.log("这是一秒后更新的结果:");
    state.name = "李四";
    state.list.length = 1; // 此时state.list[1]应该是undefined,但屏幕依然显示2,因为没有对数组进行特殊处理,此时仅仅是触发了key为length的effect,key为1的effect没有被触发导致是旧的结果
  }, 1000);
</script>

说明此时需要对数组进行进一步处理,还有一种情况通过数组不存在的下标给数组赋值也需要特殊处理:

// weak-vue\packages\reactivity\src\effect.ts
// 对数组进行特殊处理,改变的key为length时(即直接修改数组的长度)时,要触发其它key的effect,否则其它key的effect不会被触发的,始终是旧的结果
if (isArray(target) && key === "length") {
  depMap.forEach((dep, key) => {
    // 此时拿到depMap包含target对象所有key(包含'length'等属性以及所有下标'0'、'1'等等)的所有涉及effect
    // 如果下标key大于等于新的长度值,则要执行length的effect和超出length的那些key的effect(再去执行指的是比如刚开始拿到state.list[100],
    // 现在将state.list.length直接改为1,重新触发state.list[100]这个语句,无法在内存中找到所以显示undefined)
    if (key === "length" || key >= newValue) {
      addEffect(dep);
    }
  });
} else {
  // 数组或对象都会进行的正常操作
  if (key !== undefined) {
    const effects = depMap.get(key);
    addEffect(effects);
  }

  switch (type) {
    case TriggerOpType.ADD:
      // 针对的是通过下标给数组不存在的key赋值,从而改变数组的长度的情况,此时要额外触发"length"的effect
      if (isArray(target) && (isIntegerKey(key) as unknown as boolean)) {
        addEffect(depMap.get("length"));
      }
  }
}

此时去执行我们的测试用例:

<!-- weak-vue\packages\examples\2.effect.html -->
<script>
  let { reactive, effect } = VueReactivity;
  let state = reactive({
    name: "张三",
    age: 20,
    sex: "男",
    list: [1, 2, 3, 4],
  });
  effect(() => {
    console.log(state);
    app.innerHTML = state.name + state.list[1];
  });

  // 一秒后触发更新==>触发set,执行对应的effect,处理是新增还是修改
  setTimeout(() => {
    console.log("这是一秒后更新的结果:");
    state.name = "李四";
    state.list.length = 1; // 此时state.list[1]应该是undefined,但屏幕依然显示2,因为没有对数组进行特殊处理,此时仅仅是触发了key为length的effect,key为1的effect没有被触发导致是旧的结果
    state.list[100] = 1; // 此时改变不存在的key,应该去触发key为length的effect,导致的效果是list中间插入空值补全长度
  }, 1000);
</script>

可以看到,结果符合预期:手把手带你实现一个自己的简易版 Vue3(三)


自此,我们已经了解触发更新的基本实现原理,到这里的代码请看提交记录:4、触发更新