Skip to content

Feat: Add dispose method to van.derive for explicit cleanup of derived states #441

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

Drswith
Copy link

@Drswith Drswith commented May 10, 2025

Problem Description

Currently, van.js's derive function creates reactive states that listen to their dependencies. While van.js has a garbage collection mechanism tied to DOM element connectivity, derived states that are not directly associated with a DOM element (or are associated with the global alwaysConnectedDom object) are never garbage collected. This is particularly problematic when van.derive is used to implement higher-level reactive primitives, such as watch or watchEffect in Vue-like adapters, where watchers need to be explicitly stopped during their lifecycle.

Without an explicit way to stop these derivations, listeners can accumulate, leading to potential memory leaks and incorrect behavior when a watcher is expected to cease its activity.

Proposed Solution

This pull request introduces an explicit dispose() method to the state object returned by van.derive().

The derive function has been modified as follows:

  1. It now tracks the specific listener object created for the derivation and the sourceStates (dependencies) that the derived function reads from.
  2. A dispose() method is attached to the state object s returned by van.derive().
  3. When s.dispose() is called:
    • It iterates through the tracked sourceStates.
    • For each sourceState, it removes the specific listener associated with this derive call from that sourceState's internal _listeners array.
    • It then clears its internal references to the sourceStates and the original listener's function and state to aid garbage collection and makes subsequent calls to dispose() a no-op.

This provides a clean and explicit mechanism for consumers of van.derive() to manually stop a derived state from listening to its dependencies, effectively cleaning up the reactive effect.

Benefits

  • Prevents Memory Leaks: Allows for explicit cleanup of derived states that are no longer needed, especially those not tied to the lifecycle of a specific DOM element.
  • Enables Correct Lifecycle Management: Crucial for building higher-level reactivity abstractions (e.g., watch, watchEffect) on top of van.js, allowing these abstractions to correctly stop their underlying reactive effects.
  • Improved Developer Control: Gives developers more fine-grained control over the reactive computations managed by van.derive().

Code Changes

The primary modification is within the derive function in van.js to:

  • Track the dependency states for the derived computation.
  • Add the dispose method to the returned state object, which uses this tracking to remove the listener.

Example Usage (Conceptual)

import van from "./van.js";

const a = van.state(1);
const b = van.state(2);

// Create a derived state
const sum = van.derive(() => {
  // This console.log will run when 'sum' is re-evaluated due to dependency changes,
  // typically batched and deferred by van.js's update cycle (updateDoms).
  console.log("Calculating sum:", a.val + b.val);
  return a.val + b.val;
});

// Accessing .val triggers the first calculation and log immediately.
console.log("Initial sum.val:", sum.val); // Expected: "Calculating sum: 3", "Initial sum.val: 3"

// Change dependencies. These changes are batched.
a.val = 10;
b.val = 20;

// At this point, "Calculating sum" for a=10, b=20 might not have been logged yet,
// as van.js batches updates. The re-calculation is deferred.
// If you were to log sum.val here, it might still show the old value (3)
// until the updateDoms cycle completes.

// To observe the intermediate update (a=10, b=20), one might need to wait for the
// next microtask or a short timeout for van.js's updateDoms to run.
// For this conceptual example, we'll assume updateDoms runs before the next set of changes.
// (In a real scenario, if sum.val was logged here *after* a microtask delay,
//  you would expect to see "Calculating sum: 30" and sum.val being 30).

// Now, explicitly stop the 'sum' derivation
sum.dispose();

// Further changes to dependencies
a.val = 100;
b.val = 200;

// Because sum.dispose() was called, the "Calculating sum..." console.log
// associated with the sum derivation will NOT be triggered anymore,
// regardless of changes to 'a' or 'b'.

// The value of sum.val will remain what it was before dispose,
// or what it was last set to by an updateDoms cycle prior to dispose.
// If updateDoms had run for a=10,b=20, sum.val would be 30.
// If it hadn't, it might still be 3.
console.log("sum.val after dispose and further changes:", sum.val);
// Expected: No "Calculating sum: 120" or "Calculating sum: 300".
// The logged value of sum.val depends on when the last updateDoms ran relative to dispose.

This change makes van.js more robust and flexible for a wider range of use cases, especially when integrating with or building other reactive systems.

@Drswith
Copy link
Author

Drswith commented May 10, 2025

问题描述

目前,van.jsderive 函数创建的响应式状态会监听其依赖项。虽然 van.js 具有与 DOM 元素连接相关的垃圾回收机制,但未直接与 DOM 元素关联(或与全局 alwaysConnectedDom 对象关联)的派生状态永远不会被垃圾回收。当 van.derive 用于实现更高级别的响应式原语(例如类似 Vue 的适配器中的 watchwatchEffect)时,这个问题尤其突出,因为在这些场景下,观察者需要在其生命周期内被显式停止。

如果没有明确的方法来停止这些派生,监听器会累积,当期望观察者停止活动时,可能导致潜在的内存泄漏和不正确的行为。

解决方案提议

此拉取请求为 van.derive() 返回的状态对象引入了一个显式的 dispose() 方法。

derive 函数已作如下修改:

  1. 它现在跟踪为派生创建的特定 listener 对象以及派生函数读取的 sourceStates(依赖项)。
  2. dispose() 方法附加到 van.derive() 返回的状态对象 s 上。
  3. 当调用 s.dispose() 时:
    • 它会遍历跟踪的 sourceStates
    • 对于每个 sourceState,它会从该 sourceState 的内部 _listeners 数组中移除与此 derive 调用关联的特定 listener
    • 然后,它清除对 sourceStates 和原始 listener 的函数及状态的内部引用,以帮助垃圾回收,并使后续对 dispose() 的调用成为空操作。

这为 van.derive() 的使用者提供了一种清晰且明确的机制,可以手动停止派生状态对其依赖项的监听,从而有效地清理响应式效果。

优点

  • 防止内存泄漏: 允许显式清理不再需要的派生状态,特别是那些与特定 DOM 元素的生命周期无关的状态。
  • 实现正确的生命周期管理: 对于在 van.js 之上构建更高级别的响应式抽象(例如 watchwatchEffect)至关重要,使这些抽象能够正确停止其底层的响应式效果。
  • 增强开发者控制: 使开发者能够更细致地控制由 van.derive() 管理的响应式计算。

代码变更

主要的修改在 van.jsderive 函数内部,目的是:

  • 跟踪派生计算的依赖状态。
  • dispose 方法添加到返回的状态对象,该方法使用此跟踪信息来移除监听器。

示例用法 (概念性)

import van from "./van.js";

const a = van.state(1);
const b = van.state(2);

// 创建一个派生状态
const sum = van.derive(() => {
  // 当依赖项发生变化导致 'sum' 重新计算时,此 console.log 将运行,
  // 通常由 van.js 的更新周期 (updateDoms) 进行批处理和延迟执行。
  console.log("计算 sum:", a.val + b.val);
  return a.val + b.val;
});

// 访问 .val 会立即触发第一次计算和日志输出。
console.log("初始 sum.val:", sum.val); // 预期: "计算 sum: 3", "初始 sum.val: 3"

// 更改依赖项。这些更改会被批处理。
a.val = 10;
b.val = 20;

// 此时,针对 a=10, b=20 的 "计算 sum" 可能尚未记录,
// 因为 van.js 会批处理更新。重新计算会被延迟。
// 如果你此时打印 sum.val,它可能仍显示旧值 (3)
// 直到 updateDoms 周期完成。

// 要观察中间更新 (a=10, b=20),可能需要等待下一个微任务
// 或短暂的超时,以便 van.js 的 updateDoms 运行。
// 在此概念性示例中,我们假设 updateDoms 在下一组更改之前运行。
// (在实际场景中,如果在此处 *经过微任务延迟后* 打印 sum.val,
//  你应预期看到 "计算 sum: 30" 并且 sum.val 为 30)。

// 现在,显式停止 'sum' 派生
sum.dispose();

// 进一步更改依赖项
a.val = 100;
b.val = 200;

// 因为调用了 sum.dispose(),与 sum 派生关联的 "计算 sum..." console.log
// 将不再被触发,无论 'a' 或 'b' 如何变化。

// sum.val 的值将保持在 dispose 调用之前的值,
// 或者是在 dispose 之前由 updateDoms 周期最后设置的值。
// 如果 updateDoms 针对 a=10,b=20 运行过, sum.val 将是 30。
// 如果没有,它可能仍然是 3。
console.log("dispose 后并进一步更改依赖后的 sum.val:", sum.val);
// 预期: 不会输出 "计算 sum: 120" 或 "计算 sum: 300"。
// sum.val 的打印值取决于最后一次 updateDoms 相对于 dispose 的运行时间。

此更改使 van.js 在更广泛的用例中更加健壮和灵活,尤其是在与其他响应式系统集成或构建其他响应式系统时。

@Tao-VanJS
Copy link
Member

Thanks a lot for your contribution! I will think about the inclusion of this feature in upcoming days.

One thing I'm thinking about is whether to use Symbol.dispose for the method as it's more forward looking and has good integration with using keyword in TypeScript. However, the current browser support for Symbol.dispose is somewhat mixed thus it might be better to wait for a while for it to be more mature.

Another thing is other than the toy example you have given for the usage of this feature, is there a more real-world example where this feature is super critical (in other words, lacking this feature is causing serious issues)? That kind of example is helpful for me to understand the importance of the feature.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants