Vue3响应式系统原理:副作用函数的遗留处理

938 阅读5分钟

问题

在代码运行时,副作用函数关联的响应式对象属性可能不同。比如下面的三元运算符。

const data = {
    obj: true,
    text1: "hello world 1",
    text2: "hello world 2",
};
const obj = new Proxy(data, ...)

function effectOk() {
    console.log("effectOk", obj.ok ? obj.text1: obj.text2);
}

代码一开始,由于obj.ok为true,副作用函数等价于:

function effectOk() {
    console.log("effectOk", obj.text1)
}

副作用函数effectOk和obj.text1相关联。

假设在2s之后,obj.ok变为false,这时候的副作用函数等价于

function effectOk() {
    console.log("effectOk", obj.text2)
}

副作用函数effectOk和obj.text2相关联;而和obj1.text1无关联。 我们期望,后续obj.text2的变化会导致副作用函数effectOk的运行;而obj.text1的变化不会导致副作用函数effectOk的运行。

但我们运行代码发现,结果不是这样的。

根据上一节,我们的effect.js的代码如下:

// effect.js

// 存放副作用函数的集合容器,用Set数据结构,是为了防止相同的副作用函数重复收集
const bucket = new WeakMap();

// 表示当前正在运行的副作用函数
let activeEffect = null;

// 用于执行副作用函数的函数
export function myEffect(fn) {
  activeEffect = fn;
  fn(); // 执行副作用函数
}

// 响应式对象。响应式对象为原始对象的Proxy代理
export const myReactive = (data) => new Proxy(data, {
  get(target, key) {
    if(!activeEffect) return target[key]
    track(target, key);
    return target[key];
  },
  set(target, key, val) {
    target[key] = val;
    trigger(target, key);
    return true;
  },
});

function track(target, key){
  if(!activeEffect) return
  let depsMap = bucket.get(target)
  if(!depsMap) {
    depsMap = new Map()
    bucket.set(target, depsMap)
  }
  let deps = depsMap.get(key)
  if(!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }
  deps.add(activeEffect)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if(!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

debug.js如下:

// debug.js

import { myEffect, myReactive } from './effect.js'
// 原始对象,包含两个属性
const data = {
  ok: true,
  text1: "hello world 1",
  text2: "hello world 2",
};

// 响应式对象
const obj = myReactive(data);

// 定义第一个副作用函数
function effectOk() {
  console.log("effectOk", obj.ok ? obj.text1: obj.text2);
}

// 初始化依次执行副作用函数,触发 get
myEffect(effectOk);

// 模拟2s后修改ok
setTimeout(() => {
  obj.ok = false;
}, 2000);

// 模拟4s后修改text1
setTimeout(() => {
  obj.text1 = 'HELLO WORLD 1';
}, 4000)


// 初始化输出如下:
// effectOk hello world 1
// 2s之后输出如下:
// effectOk hello world 2
// 4s之后输入如下:
// effectOk hello world 2

我们在2s之后将obj.ok的值由true变为false,然后我们再4s的时候观察,我们改变obj.text1的值的时候是否执行副作用函数。 结果发现obj.text1的变化,会导致运行副作用函数effectOk!

分析

简单梳理下为什么会出现上面的现象。

  1. 副作用函数初始化运行时,由于obj.ok为true,触发obj.text1的读操作。根据track代码,副作用函数effectOk会被塞入obj.text1关联的deps中(Set数据结构)。
  2. 后续obj.ok变为false,触发obj.text2的读操作。根据track代码,副作用函数effectOk会被塞入obj.text2关联的deps中。
  3. 最后obj.text1更新,触发trigger函数。根据trigger代码,会将obj.text1对应的deps里所有副作用函数逐一执行。

上述第2步,我们虽然将副作用函数的关联属性从之前的obj.text1变为ob.text2,但是没有将副作用函数从obj.text1对应的deps里移除,结果导致了在第3步中,也出发了副作用函数的运行。因此我们需要修改下相关代码。

修改的思路如下:

每次运行副作用函数的时候,将该副作用函数从与之关联的所有对象属性的deps中移除。这样,就不会存在副作用函数还会遗留在之前的对象属性的deps中了。

随之而来的第二个问题是,我们如何获取副作用函数的关联的对象属性呢? 为此我们可以将副作用函数也增加一个属性deps(为Array数据结构),这个deps用于保存所有关联的对象属性。

let activeEffect = null
function myEffect(fn) {
  const effectFn = () => {
    cleanup(effectFn); // 每次运行副作用函数,清空和其他对象属性的关联关系
    activeEffect = effectFn;
    fn();
  };
  effectFn.deps = []; // 用于保存该副作用函数所关联的对象属性
  effectFn();
}

cleanup函数定义如下:

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]; // deps为对象属性关联的deps
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0;
}

track函数修改如下:

function track(target, key) {
  if(!activeEffect) return
  let depsMap = bucket.get(target)
  if(!depsMap) {
    depsMap = new Map()
    bucket.set(target, depsMap)
  }
  let deps = depsMap.get(key)
  if(!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }
  deps.add(activeEffect)
  
  // 将deps增加到activeEffect的deps中
  activeEffect.deps.push(deps) 
}

trigger函数修改如下:

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if(!depsMap) return
  const effects = depsMap.get(key)
  
  const effectsToRun = new Set(effects) // 拷贝一份新的Set快照数据
  
  effectsToRun && effectsToRun.forEach(fn => fn())
}

trigger代码中,之所以拷贝一份effects,是因为在运行副作用函数的时候,会调用cleanup,将副作用函数从当前的effects中移除;但由于副作用函数的运行,又会触发track函数的执行,导致副作用函数又会被添加到effects中,导致Set的forEach的方法会无限运行。 解决方法就是在执行trigger的时候,拷贝一份当前的effect的快照effectsToRun,然后forEach这个快照。

完整代码见这里

总结

响应式系统中,副作用函数的遗留处理,主要的思路就是:在每次副作用函数运行时候,将副作用函数和之前的对象属性解除关联,然后将副作用函数和当前的对象属性重新关联。这样就能保证历史的关联关系不复存在了,而当前的关联关系是最新的。

代码

github.com/wdskuki/js-…

参考

  1. 《Vue.js设计与实现》,作者:霍春阳,ISBN: 9787115583864