十九、图解Effect


effect 方法的作用:就是将 函数数据 关联起来。

回忆 watchEffect

import { ref, watchEffect } from "vue";
const state = ref({ a: 1 });
const k = state.value;
const n = k.a;
// 这里就会整理出 state.value、state.value.a
watchEffect(() => {
  console.log("运行");
  state;
  state.value;
  state.value.a;
  n;
});
setTimeout(() => {
  state.value = { a: 3 }; // 要重新运行,因为是对 value 的写入操作
}, 500);

effect函数的设计:

// 原始对象
const data = {
  a: 1,
  b: 2,
  c: 3,
};
// 产生一个代理对象
const state = new Proxy(data, { ... });
effect(() => {
  console.log(state.a);
});

在上面的代码中,向 effect 方法传入的回调函数中,访问了 state 的 a 成员,然后我们期望 a 这个成员和这个回调函数建立关联。

第一版实现如下:

let activeEffect = null; // 记录当前的函数
const depsMap = new Map(); // 保存依赖关系

function track(target, key) {
  // 建立依赖关系
  if (activeEffect) {
    let deps = depsMap.get(key); // 根据属性值去拿依赖的函数集合
    if (!deps) {
      deps = new Set(); // 创建一个新的集合
      depsMap.set(key, deps); // 将集合存入 depsMap
    }
    // 将依赖的函数添加到集合里面
    deps.add(activeEffect);
  }
  console.log(depsMap);
}

function trigger(target, key) {
  // 这里面就需要运行依赖的函数
  const deps = depsMap.get(key);
  if (deps) {
    deps.forEach((effect) => effect());
  }
}

// 原始对象
const data = {
  a: 1,
  b: 2,
  c: 3,
};
// 代理对象
const state = new Proxy(data, {
  get(target, key) {
    track(target, key); // 进行依赖收集
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    trigger(target, key); // 派发更新
    return true;
  },
});

/**
 *
 * @param {*} fn 回调函数
 */
function effect(fn) {
  activeEffect = fn;
  fn();
  activeEffect = null;
}

effect(() => {
  // 这里在访问 a 成员时,会触发 get 方法,进行依赖收集
  console.log('执行函数')
  console.log(state.a);
});
state.a = 10;

第一版实现,每个属性对应一个 Set 集合,该集合里面是所依赖的函数,所有属性与其对应的依赖函数集合形成一个 map 结构,如下图所示:

activeEffect 起到一个中间变量的作用,临时存储这个回调函数,等依赖收集完成后,再将这个临时变量设置为空即可。


问题一:每一次运行回调函数的时候,都应该确定新的依赖关系。

稍作修改:

effect(() => {
  if (state.a === 1) {
    state.b;
  } else {
    state.c;
  }
  console.log("执行了函数");
});

在上面的代码中,两次运行回调函数,所建立的依赖关系应该是不一样的:

  • 第一次:a、b
  • 第二次:a、c

第一次运行依赖如下:

Map(1) { 'a' => Set(1) { [Function (anonymous)] } }
Map(2) {
  'a' => Set(1) { [Function (anonymous)] },
  'b' => Set(1) { [Function (anonymous)] }
}
执行了函数


执行 state.a = 100

依赖关系变为了:

Map(1) { 'a' => Set(1) { [Function (anonymous)] } }
Map(2) {
  'a' => Set(1) { [Function (anonymous)] },
  'b' => Set(1) { [Function (anonymous)] }
}
执行了函数
Map(2) {
  'a' => Set(1) { [Function (anonymous)] },
  'b' => Set(1) { [Function (anonymous)] }
}
Map(2) {
  'a' => Set(1) { [Function (anonymous)] },
  'b' => Set(1) { [Function (anonymous)] }
}
执行了函数

当 a 的值修改为 100 后,依赖关系应该重新建立,也就是说:

  • 第一次运行:建立 a、b 依赖
  • 第二次运行:建立 a、c 依赖

那么现在 a 的值明明已经变成 100 了,为什么重新执行回调函数的时候,没有重新建立依赖呢?

原因也很简单,如下图所示:


第一次建立依赖关系的时候,是将依赖函数赋值给 activeEffect,最终是通过 activeEffect 这个中间变量将依赖函数添加进依赖列表的。依赖函数执行完毕后,activeEffect 就设置为了 null,之后 a 成员的值发生改变,重新运行的是回调函数,但是 activeEffect 的值依然是 null,这就会导致 track 中依赖收集的代码根本进不去:

function track(target, key) {
  if (activeEffect) {
    // ...
  }
}

怎么办呢?也很简单,我们在收集依赖的时候,不再是仅仅收集回调函数,而是收集一个包含 activeEffect 的环境,继续改造 effect:

function effect(fn) {
  const environment = () => {
    activeEffect = environment;
    fn();
    activeEffect = null;
  };
  environment();
}

这里 activeEffect 对应的值,不再是像之前那样是回调函数,而是一整个 environment 包含环境信息的函数,这样当重新执行依赖的函数的时候,执行的也就是这个环境函数,而环境函数的第一行就是 activeEffect 赋值,这样就能够正常的进入到依赖收集环节。

如下图所示:


问题二:旧的依赖没有删除

解决方案:在执行 fn 方法之前,先调用了一个名为 cleanup 的方法,该方法的作用就是用来清除依赖。

该方法代码如下:

function cleanup(environment) {
  let deps = environment.deps; // 拿到当前环境函数的依赖(是个数组)
  if (deps.length) {
    deps.forEach((dep) => {
      dep.delete(environment);
      if (dep.size === 0) {
        for (let [key, value] of depsMap) {
          if (value === dep) {
            depsMap.delete(key);
          }
        }
      }
    });
    deps.length = 0;
  }
}

具体结构如下图所示:


测试多个依赖函数

effect(() => {
  if (state.a === 1) {
    state.b;
  } else {
    state.c;
  }
  console.log("执行了函数1");
});
effect(() => {
  console.log(state.c);
  console.log("执行了函数2");
});
state.a = 2;
effect(() => {
  if (state.a === 1) {
    state.b;
  } else {
    state.c;
  }
  console.log("执行了函数1");
});
effect(() => {
  console.log(state.a);
  console.log(state.c);
  console.log("执行了函数2");
});
state.a = 2;

解决无限循环问题:

在 track 函数中,每次 state.a 被访问时,都会重新添加当前的 activeEffect 到依赖集合中。而在 trigger 函数中,当 state.a 被修改时,会触发所有依赖 state.a 的 effect 函数,这些 effect 函数中又会重新访问 state.a,从而导致了无限循环。具体来讲:

  1. 初始执行 effect 时,state.a 的值为 1,因此第一个 effect 会访问 state.b,第二个 effect 会访问 state.a 和 state.c。
  2. state.a 被修改为 2 时,trigger 函数会触发所有依赖 state.a 的 effect 函数。
  3. 第二个 effect 函数被触发后,会访问 state.a,这时 track 函数又会把当前的 activeEffect 添加到 state.a 的依赖集合中。
  4. 因为 state.a 的值被修改,会再次触发 trigger,导致第二个 effect 函数再次执行,如此循环往复,导致无限循环。

要解决这个问题,可以在 trigger 函数中添加一些机制来防止重复触发同一个 effect 函数,比如使用一个 Set 来记录已经触发过的 effect 函数:

function trigger(target, key) {
  const deps = depsMap.get(key);
  if (deps) {
    const effectsToRun = new Set(deps); // 复制一份集合,防止在执行过程中修改原集合
    effectsToRun.forEach((effect) => effect());
  }
}

测试嵌套函数

effect(() => {
  effect(() => {
    state.a
    console.log("执行了函数2");
  });
  state.b;
  console.log("执行了函数1");
});

会发现所建立的依赖又不正常了:

Map(1) { 'a' => Set(1) { [Function: environment] { deps: [Array] } } }
执行了函数2
Map(1) { 'a' => Set(1) { [Function: environment] { deps: [Array] } } }
执行了函数1

究其原因,是目前的函数栈有问题,当执行到内部的 effect 函数时,会将 activeEffect 设置为 null,如下图所示:


解决方案:模拟函数栈的形式。


文章作者: 吴俊杰
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 吴俊杰 !
  目录