Vue.js 响应系统的核心概念:响应式数据与副作用函数

在 Vue.js 中,响应系统是实现数据动态更新的关键机制。它允许开发者通过简单的数据绑定实现视图的自动更新。为了更好地理解 Vue.js 的响应系统,我们需要从两个核心概念入手:响应式数据和副作用函数。

1. 副作用函数:理解其作用与影响

副作用函数是指那些在执行过程中会对外部环境产生影响的函数。例如,修改全局变量、操作 DOM 或发送网络请求等操作都属于副作用。以下是一个简单的副作用函数示例:

// 全局变量
let val = 1;

function effect() {
    val = 2; // 修改全局变量,产生副作用
}

在这个例子中,effect 函数通过修改全局变量 val 的值,对程序的外部状态产生了影响。这种修改是不可预测的,因为它可能会被其他函数读取或依赖。因此,副作用函数的执行需要谨慎处理,以避免潜在的冲突。

另一个常见的副作用函数是操作 DOM。例如:

function effect() {
    document.body.innerText = 'hello vue3'; // 修改 DOM 内容,产生副作用
}

effect 函数执行时,它会直接修改页面的文本内容。这种操作会影响页面的显示状态,从而对其他依赖 DOM 的代码产生潜在影响。

2. 响应式数据:实现数据驱动的自动更新

响应式数据是 Vue.js 的核心特性之一。它允许开发者将数据与视图绑定,当数据发生变化时,视图会自动更新。响应式数据的关键在于数据对象的“响应式化”,即当数据对象的属性值发生变化时,能够自动触发相关的副作用函数重新执行。

以下是一个简单的响应式数据示例:

const obj = { text: 'hello world' };

function effect() {
    document.body.innerText = obj.text; // 读取 obj.text 并设置 DOM 内容
}

在这个例子中,effect 函数读取了 obj.text 的值,并将其设置为页面的文本内容。然而,目前的 obj 是一个普通对象,当我们修改 obj.text 的值时,effect 函数并不会自动重新执行。为了实现响应式数据,我们需要让 obj 成为一个响应式对象。

3. 从普通对象到响应式数据

目前,obj 是一个普通对象,无法实现响应式更新。为了将其转变为响应式数据,我们需要借助 Proxy 或其他机制来拦截属性的读取和修改操作。例如:

const obj = { text: 'hello world' };

function effect() {
    document.body.innerText = obj.text; // 读取 obj.text 并设置 DOM 内容
}

obj.text = 'hello vue3'; // 修改 obj.text 的值,希望副作用函数重新执行

在上述代码中,我们希望当 obj.text 的值发生变化时,effect 函数能够自动重新执行。然而,目前的代码无法实现这一目标,因为 obj 是一个普通对象。在接下来的章节中,我们将深入探讨如何通过 Proxy 实现响应式数据,并解决上述提到的挑战。

响应式数据的基本实现:从原理到代码

在上一节中,我们介绍了响应式数据和副作用函数的概念,并探讨了它们在 Vue.js 中的重要性。接下来,我们将深入探讨如何通过代码实现一个简单的响应式数据系统。这一节将通过具体的代码示例,展示如何利用 JavaScript 的 Proxy 实现响应式数据的基本功能。

1. 响应式数据的核心原理

要实现响应式数据,我们需要解决两个关键问题:

  1. 如何在读取数据时记录副作用函数?
    当副作用函数读取某个数据时,我们需要记录这个副作用函数,以便在数据更新时能够重新执行它。

  2. 如何在更新数据时触发副作用函数的执行重新?
    当数据发生变化时,我们需要找到所有依赖该数据的副作用函数,并触发它们重新执行。

2. 使用 Proxy 拦截读取和设置操作

在 ES2015+ 中,Proxy 提供了一种强大的方式来拦截对象的读取和设置操作。通过 Proxy,我们可以在读取和设置属性时插入自定义逻辑,从而实现响应式数据。

以下是一个简单的响应式数据实现代码:

// 存储副作用函数的桶
const bucket = new Set();

// 原始数据
const data = { text: 'hello world' };

// 对原始数据的代理
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 将副作用函数 effect 添加到存储副作用函数的桶中
        bucket.add(effect);
        // 返回属性值
        return target[key];
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal;
        // 把副作用函数从桶里取出并执行
        bucket.forEach(fn => fn());
        // 返回 true 代表设置操作成功
        return true;
    }
});

3. 测试响应式数据

为了验证我们的响应式数据是否工作,可以编写一个简单的副作用函数,并在数据更新时观察其行为:

// 副作用函数
function effect() {
    document.body.innerText = obj.text;
}

// 执行副作用函数,触发读取
effect();

// 1 秒后修改响应式数据
setTimeout(() => {
    obj.text = 'hello vue3';
}, 1000);

当运行上述代码时,页面的文本内容会先显示为 "hello world",然后在 1 秒后自动更新为 "hello vue3"。这说明我们的响应式数据系统已经能够正确地记录副作用函数,并在数据更新时触发它们重新执行。

4. 当前实现的局限性

尽管上述代码已经实现了响应式数据的基本功能,但它仍然存在一些明显的局限性:

  1. 硬的编码副作用函数
    在代码中,我们直接通过 effect 函数的名称将其添加到 bucket 中。这种硬编码的方式缺乏灵活性。在实际应用中,副作用函数的名称可能是任意的,甚至可能是匿名函数。因此,我们需要一种更通用的方式来动态记录副作用函数。

  2. 全局副作用函数桶
    当前的实现中,所有副作用函数都被存储在一个全局的 Set 中。这在单个数据对象的场景下可以工作,但在更复杂的应用中(例如多个响应式对象或嵌套的副作用函数),这种简单的存储方式会导致冲突和错误。

  3. 缺乏对嵌套副作用函数的支持
    当前的实现仅支持单层的副作用函数。在实际应用中,副作用函数可能会嵌套调用,而我们的实现无法正确处理这种嵌套关系。

5. 下一步:改进响应式系统的灵活性和扩展性

在本节中,我们通过 Proxy 实现了一个简单的响应式数据系统,并展示了其基本工作原理。然而,当前的实现还存在许多问题。在接下来的章节中,我们将逐步改进这个系统,使其更加灵活和强大。我们将讨论如何动态记录副作用函数、如何处理嵌套的副作用函数,以及如何优化性能。

通过这一过程,我们将逐步构建一个完整的响应式系统,为实现类似 Vue.js 的数据绑定功能奠定基础。


通过本节的内容,我们不仅理解了响应式数据的基本实现原理,还通过代码示例展示了如何利用 Proxy 拦截对象操作。尽管当前的实现还存在局限性,但它为我们进一步探索响应式系统的设计和优化提供了重要的基础。帮我总结整理归纳一下这个文件中的内容, 并以博客的某一小节的形式输出, 要求内容必须总结输出完整, 内容清晰易懂, 结构明了, 附带上文中的代码, 结合代码来进行总结


构建完善的响应系统:从简单到复杂

在上一节中,我们通过 Proxy 实现了一个简单的响应式数据系统,但它的功能还很有限。在本节中,我们将进一步完善这个系统,使其能够处理更复杂的场景,例如动态注册副作用函数、支持匿名函数,以及正确处理未被副作用函数依赖的属性更新。

1. 响应系统的核心流程

一个完善的响应系统需要实现以下两个核心功能:

  1. 收集副作用函数
    当读取数据时,将副作用函数收集到一个“桶”中。
    例如:

    function effect(fn) {
        activeEffect = fn;
        fn();
    }
  2. 触发副作用函数的重新执行
    当数据更新时,从“桶”中取出与该数据相关的副作用函数并重新执行。
    例如:

    obj.text = "new value"; // 触发副作用函数重新执行

2. 动态注册副作用函数

在上一节的实现中,副作用函数的名字是硬编码的,这限制了系统的灵活性。为了支持动态注册副作用函数,我们引入了一个全局变量 activeEffect,并通过 effect 函数来注册副作用函数。这样,即使副作用函数是匿名的,也能被正确收集。

let activeEffect;

function effect(fn) {
    activeEffect = fn; // 将副作用函数存储到全局变量中
    fn(); // 执行副作用函数
}

使用方式如下:

effect(() => {
    document.body.innerText = obj.text; // 匿名副作用函数
});

3. 重新设计“桶”的数据结构

在上一节的实现中,我们使用了一个简单的 Set 来存储副作用函数。然而,这种方式无法区分不同属性对应的副作用函数,导致所有副作用函数在任何属性更新时都会被触发。为了解决这个问题,我们需要重新设计“桶”的数据结构。

我们引入了 WeakMapMap 来构建一个更复杂的关系:

  • WeakMap:存储目标对象(target)与属性映射(Map)的关系。

  • Map:存储属性名(key)与副作用函数集合(Set)的关系。

  • Set:存储与某个属性相关的副作用函数。

这种结构可以表示为:

target
├── key1
│   ├── effectFn1
│   ├── effectFn2
├── key2
│   ├── effectFn3

代码实现如下:

const bucket = new WeakMap();

function track(target, key) {
    if (!activeEffect) return; // 没有激活的副作用函数,直接返回
    let depsMap = bucket.get(target);
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()));
    }
    let deps = depsMap.get(key);
    if (!deps) {
        depsMap.set(key, (deps = new Set()));
    }
    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()); // 触发所有依赖该属性的副作用函数
}

4. 使用 Proxy 拦截读取和设置操作

结合上述逻辑,我们修改 Proxy 的拦截器,使其在读取操作时调用 track 函数,在设置操作时调用 trigger 函数:

const obj = new Proxy(data, {
    get(target, key) {
        track(target, key); // 收集副作用函数
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        trigger(target, key); // 触发副作用函数重新执行
        return true;
    }
});

5. 测试和完善

通过上述实现,我们可以测试以下场景:

effect(() => {
    console.log("Effect run");
    document.body.innerText = obj.text; // 触发收集
});

setTimeout(() => {
    obj.text = "Hello Vue3"; // 触发更新
}, 1000);

此外,我们还可以测试以下复杂场景:

effect(() => {
    console.log("Effect 1 run");
    document.body.innerText = obj.text1;
});

effect(() => {
    console.log("Effect 2 run");
    document.body.innerText = obj.text2;
});

setTimeout(() => {
    obj.text1 = "New Value"; // 只触发与 text1 相关的副作用函数
}, 1000);

6. 使用 WeakMap 的优势

在上述实现中,我们使用了 WeakMap 而不是普通的 MapWeakMap 的键是弱引用,不会阻止垃圾回收器回收对象。这意味着,当目标对象(target)不再被其他变量引用时,它会被自动回收,从而避免内存泄漏。

const map = new Map();
const weakmap = new WeakMap();

(function() {
    const foo = { foo: 1 };
    const bar = { bar: 2 };
    map.set(foo, 1);
    weakmap.set(bar, 2);
})();

在上述代码中,foo 会被 map 引用,因此不会被回收;而 bar 会被 weakmap 弱引用,因此可以被垃圾回收器回收。

7. 封装与灵活性

最后,我们将逻辑封装到 tracktrigger 函数中,这不仅使代码更加清晰,还为后续扩展提供了便利。例如,我们可以在 track 中添加日志记录,在 trigger 中添加防抖或节流功能。


通过本节的改进,我们的响应系统已经能够处理更复杂的场景,支持动态注册副作用函数,并正确区分不同属性的依赖关系。在后续章节中,我们将继续优化这个系统,例如支持嵌套副作用函数、处理数组和对象的嵌套更新等,逐步构建一个功能强大的响应式系统。


分支切换与副作用函数的清理

在响应式系统中,分支切换是一个常见的场景。例如,当一个副作用函数根据数据的不同状态执行不同的代码分支时,可能会导致一些副作用函数的依赖关系变得冗余。如果不正确处理这些冗余的依赖关系,可能会引发不必要的更新。本节将探讨如何解决分支切换带来的副作用函数清理问题。

1. 分支切换的定义与问题

分支切换是指副作用函数在执行时,根据数据的不同状态进入不同的代码分支。例如,以下代码展示了根据 obj.ok 的值切换分支的场景:

const data = { ok: true, text: 'hello world' };
const obj = new Proxy(data, { /* ... */ });

effect(function effectFn() {
    document.body.innerText = obj.ok ? obj.text : 'not';
});

在上述代码中,effectFn 根据 obj.ok 的值决定是否读取 obj.text。当 obj.ok 的值从 true 变为 false 时,原本依赖 obj.text 的副作用函数应该被清理,因为 obj.text 不再被读取。然而,如果不进行清理,obj.text 的更新仍然会触发副作用函数的执行,导致不必要的更新。

2. 副作用函数的清理机制

为了解决分支切换带来的副作用函数清理问题,我们需要在每次副作用函数执行时,先将其从所有依赖集合中移除,然后再重新建立依赖关系。以下是实现这一机制的步骤:

  1. 为副作用函数添加依赖集合的引用
    effect 函数中,为副作用函数添加一个 deps 属性,用于存储所有包含该副作用函数的依赖集合。

    let activeEffect;
    
    function effect(fn) {
        const effectFn = () => {
            cleanup(effectFn); // 清理旧的依赖关系
            activeEffect = effectFn;
            fn();
        };
        effectFn.deps = []; // 存储依赖集合
        effectFn();
    }
  2. track 函数中收集依赖集合
    当副作用函数读取数据时,将依赖集合添加到 effectFn.deps 中。

    function track(target, key) {
        if (!activeEffect) return;
        let depsMap = bucket.get(target);
        if (!depsMap) {
            bucket.set(target, (depsMap = new Map()));
        }
        let deps = depsMap.get(key);
        if (!deps) {
            depsMap.set(key, (deps = new Set()));
        }
        deps.add(activeEffect); // 将副作用函数添加到依赖集合
        activeEffect.deps.push(deps); // 收集依赖集合
    }
  3. 实现 cleanup 函数
    在副作用函数执行前,通过 cleanup 函数将其从所有依赖集合中移除。

    function cleanup(effectFn) {
        for (let i = 0; i < effectFn.deps.length; i++) {
            const deps = effectFn.deps[i];
            deps.delete(effectFn); // 从依赖集合中移除副作用函数
        }
        effectFn.deps.length = 0; // 重置依赖集合数组
    }

3. 避免无限循环

trigger 函数中,直接遍历依赖集合可能会导致无限循环。这是因为副作用函数在执行时会重新被收集到依赖集合中,而此时遍历尚未结束。为了避免这个问题,我们需要创建一个新的集合来遍历:

function trigger(target, key) {
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    const effects = depsMap.get(key);
    const effectsToRun = new Set(effects); // 创建新的集合
    effectsToRun.forEach(effectFn => effectFn());
}

4. 完整的代码实现

以下是完整的代码实现:

const bucket = new WeakMap();

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

function trigger(target, key) {
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    const effects = depsMap.get(key);
    const effectsToRun = new Set(effects);
    effectsToRun.forEach(effectFn => effectFn());
}

function cleanup(effectFn) {
    for (let i = 0; i < effectFn.deps.length; i++) {
        const deps = effectFn.deps[i];
        deps.delete(effectFn);
    }
    effectFn.deps.length = 0;
}

let activeEffect;

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn);
        activeEffect = effectFn;
        fn();
    };
    effectFn.deps = [];
    effectFn();
}

const data = { ok: true, text: 'hello world' };
const obj = new Proxy(data, {
    get(target, key) {
        track(target, key);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        trigger(target, key);
        return true;
    }
});

effect(function effectFn() {
    document.body.innerText = obj.ok ? obj.text : 'not';
});

const bucket = new WeakMap();

// 用一个全局变量存储被注册的副作用函数
let activeEffect

// 封装get拦截器中的副作用函数收集逻辑
const track = (target, key) => {
  if (!activeEffect) return
  // 在桶中找target是否存在map  key-->effects
  let depsMap = bucket.get(target)
  // 若不存在depsMap 创建一个新的map
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  // 在depsMap中找 是否存在key对应的effects
  // effect是一个Set结构 用于存储这个key对应的所有副作用函数
  let deps = depsMap.get(key)
  // 若不存在deps 创建一个新的Set
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  // 最后将当前激活的副作用函数添加到桶里
  deps.add(activeEffect)

  // --处理activeEffect.deps
  // deps就是一个与当前副作用函数存在联系的依赖集合
  // 将deps添加到activeEffect.deps中 所以activeEffect.deps是一个存储set的数组 Set[]
  activeEffect.deps.push(deps)
}

// 封装set拦截器中副作用函数调用逻辑
const trigger = (target, key) => {
  // 先从桶中找到对应的target的map
  const depsMap = bucket.get(target)
  if (!depsMap) return;
  // 再从depsMap中找到对应的key的set
  const deps = depsMap.get(key)
  // 声明一个新的deps来clone deps  防止在执行deps中的副作用函数时, deps集合发生变化 导致死循环
  const depsClone = new Set(deps)
  // 执行deps中的副作用函数
  depsClone && depsClone.forEach(fn => fn())
}

// cleanup函数用于清除副作用函数
const cleanup = (effectFn) => {
  // 遍历effectFn.deps数组
  for(let i = 0; i < effectFn.deps.length; i++) {
    // deps是一个Set结构 依赖集合
    const deps = effectFn.deps[i]
    // 将effectFn从依赖集合中删除
    deps.delete(effectFn)
  }
  // 最后需要重置effectFn.deps数组
  effectFn.deps.length = 0
}

// effect函数用于<注册>副作用函数 (解决依赖函数名的问题)
const effect = (fn) => {
  const effectFn = () => {
    // 调用cleanup函数完成清除工作
    cleanup(effectFn)
    // 当effectFn执行时, 将其设置为当前激活的副作用函数
    activeEffect = effectFn
    // 执行fn
    fn()
  }

  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

const data = { ok: true, text: "hello world" }

const obj = new Proxy(data, {
  get(target, key) {
    track(target, key)
    console.log(key + ' deps:', bucket)
    // 返回属性值
    return target[key]
  },

  set(target, key, newVal) {
    console.log('set run key:', key)
    target[key] = newVal
    trigger(target, key)
  }
})

// 直接通过匿名函数注册一个副作用函数
effect(function effectFn() {
  console.log("effect run")
  // 读取的时候会触发obj的get
  document.body.innerText = obj.ok? obj.text: 'not'
})

setTimeout(() => {
  // 改变obj的text属性的值
  // obj.text = "hello vue"
  obj.ok = false

  setTimeout(() => {
    obj.text = "hello vue"
  }, 2000)

  // 若改变一个不存在的属性的值
  // obj.notExist = "hello vue"
}, 1000)

5. 测试与验证

以下是测试代码:

obj.ok = false; // 分支切换,触发副作用函数重新执行
obj.text = 'hello vue3'; // 不应触发副作用函数,因为 obj.text 不再被依赖

在上述测试中,当 obj.ok 的值变为 false 时,副作用函数会重新执行,但此时不再依赖 obj.text。因此,后续修改 obj.text 不会触发副作用函数的执行。

6. 总结

通过引入 cleanup 函数和依赖集合的引用,我们成功解决了分支切换带来的副作用函数清理问题。同时,通过在 trigger 函数中创建新的集合来避免无限循环,确保了响应式系统的稳定运行。这一改进使得我们的响应式系统更加健壮,能够正确处理复杂的依赖关系和分支切换场景。

嵌套的 Effect 与 Effect 栈

在响应式系统中,effect 函数用于注册副作用函数,而副作用函数可能会嵌套调用其他 effect。例如,在 Vue.js 中,组件的渲染函数通常被包裹在一个 effect 中执行,而组件嵌套会导致 effect 的嵌套。本节将探讨如何支持嵌套的 effect,并解决嵌套带来的问题。

1. 嵌套 Effect 的场景

在实际开发中,嵌套的 effect 是常见的。例如,当一个组件渲染另一个组件时,就会产生嵌套的副作用函数。以下是一个简单的示例:

effect(function effectFn1() {
    console.log("effectFn1 执行");
    effect(function effectFn2() {
        console.log("effectFn2 执行");
    });
});

在这个例子中,effectFn1 内部嵌套了 effectFn2,并且 effectFn2 的执行依赖于 effectFn1 的执行。

2. 不支持嵌套时的问题

在之前的实现中,effect 函数使用了一个全局变量 activeEffect 来存储当前激活的副作用函数。然而,这种实现方式不支持嵌套的 effect,因为全局变量 activeEffect 在嵌套调用时会被覆盖。例如:

const data = { foo: true, bar: true };
const obj = new Proxy(data, { /* ... */ });

let temp1, temp2;

effect(function effectFn1() {
    console.log("effectFn1 执行");
    effect(function effectFn2() {
        console.log("effectFn2 执行");
        temp2 = obj.bar; // 读取 obj.bar
    });
    temp1 = obj.foo; // 读取 obj.foo
});

在上述代码中,effectFn2 的执行会覆盖 activeEffect 的值。当修改 obj.foo 时,我们希望触发 effectFn1 的重新执行,但实际上却触发了 effectFn2 的执行。这是因为 activeEffect 被覆盖后,obj.foo 的依赖关系被错误地绑定到了 effectFn2

3. 使用 Effect 栈解决嵌套问题

为了解决嵌套 effect 的问题,我们需要引入一个副作用函数栈(effectStack)。通过将当前执行的副作用函数压入栈中,并在执行完毕后弹出栈,我们可以确保 activeEffect 始终指向栈顶的副作用函数。以下是改进后的代码:

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect;

// effect 栈
const effectStack = [];

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn); // 清理旧的依赖关系
        try {
            // 将当前副作用函数压入栈中
            effectStack.push(effectFn);
            activeEffect = effectFn;
            fn(); // 执行副作用函数
        } finally {
            // 执行完毕后,将当前副作用函数弹出栈
            effectStack.pop();
            // 恢复之前的 activeEffect
            activeEffect = effectStack[effectStack.length - 1];
        }
    };
    effectFn.deps = []; // 存储依赖集合
    effectFn();
}

通过这种方式,我们确保了嵌套的 effect 能够正确处理依赖关系。当内层的 effect 执行完毕后,activeEffect 会恢复为外层的 effect,从而避免了依赖关系的混乱。

4. 测试与验证

以下是测试代码:

effect(function effectFn1() {
    console.log("effectFn1 执行");
    effect(function effectFn2() {
        console.log("effectFn2 执行");
        temp2 = obj.bar; // 读取 obj.bar
    });
    temp1 = obj.foo; // 读取 obj.foo
});

obj.foo = false; // 触发 effectFn1 重新执行
obj.bar = false; // 触发 effectFn2 重新执行

在上述测试中,修改 obj.foo 时,只触发了 effectFn1 的重新执行;修改 obj.bar 时,只触发了 effectFn2 的重新执行。这表明嵌套的 effect 已经能够正确处理依赖关系。

5. 总结

通过引入 effectStack,我们解决了嵌套 effect 的问题,使得响应式系统能够正确处理嵌套的副作用函数。这种实现方式不仅支持复杂的嵌套场景,还确保了依赖关系的准确性。在实际开发中,这种机制对于组件嵌套和复杂的数据依赖关系尤为重要。

避免无限递归循环:响应式系统中的陷阱与解决方案

在构建响应式系统时,无限递归循环是一个常见的问题。这种问题通常发生在副作用函数中同时包含读取和设置操作时。本节将探讨无限递归循环的成因,并提供一种有效的解决方案。

1. 无限递归循环的成因

假设我们有以下代码:

const data = { foo: 1 };
const obj = new Proxy(data, { /*...*/ });

effect(() => {
    obj.foo++; // 自增操作
});

在这段代码中,effect 注册的副作用函数中包含了一个自增操作 obj.foo++。这个操作实际上可以拆解为两步:

effect(() => {
    obj.foo = obj.foo + 1; // 读取并设置
});

问题出在 obj.foo 的读取和设置操作上:

  1. 当副作用函数执行时,obj.foo 的读取操作会触发 track,将副作用函数收集到依赖集合中。

  2. 随后,obj.foo 的设置操作会触发 trigger,导致依赖集合中的副作用函数重新执行。

  3. 由于副作用函数尚未执行完毕,trigger 又会触发副作用函数的重新执行,从而导致无限递归。

这种无限递归最终会导致栈溢出错误:

Uncaught RangeError: Maximum call stack size exceeded

2. 解决无限递归循环的方法

要解决这个问题,我们需要在 trigger 函数中增加一个条件判断:如果触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。以下是改进后的 trigger 函数实现:

function trigger(target, key) {
    const depsMap = bucket.get(target);
    if (!depsMap) return;

    const effects = depsMap.get(key);
    const effectsToRun = new Set();

    effects && effects.forEach(effectFn => {
        // 如果触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn);
        }
    });

    effectsToRun.forEach(effectFn => effectFn());
}

通过这种方式,我们避免了副作用函数在执行过程中触发自身的重新执行,从而解决了无限递归的问题。

3. 总结

在响应式系统中,无限递归循环是一个常见的问题,尤其是在副作用函数中同时包含读取和设置操作时。通过在 trigger 函数中增加条件判断,避免触发自身重新执行,我们可以有效解决这一问题。这种解决方案不仅简单高效,还能确保响应式系统的稳定运行。

文章作者: xxzz
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 xxzz
vuejs设计与实现 vue-study
喜欢就支持一下吧