2023 年 11 月 4 日

WeakRef 和 FinalizationRegistry

语言的“隐藏”特性

本文涵盖了一个非常狭窄的主题,大多数开发人员在实践中极少遇到(甚至可能不知道它的存在)。

如果你刚开始学习 JavaScript,我们建议跳过本章。

回顾一下 垃圾回收 章节中可达性原则的基本概念,我们可以注意到,JavaScript 引擎保证将可访问或正在使用的值保存在内存中。

例如

//  the user variable holds a strong reference to the object
let user = { name: "John" };

// let's overwrite the value of the user variable
user = null;

// the reference is lost and the object will be deleted from memory

或者类似的,但稍微复杂一些的代码,其中包含两个强引用

//  the user variable holds a strong reference to the object
let user = { name: "John" };

// copied the strong reference to the object into the admin variable
let admin = user;

// let's overwrite the value of the user variable
user = null;

// the object is still reachable through the admin variable

如果不存在对对象 { name: "John" } 的强引用(如果我们还覆盖了 admin 变量的值),那么该对象才会从内存中删除。

在 JavaScript 中,有一个称为 WeakRef 的概念,在这种情况下,其行为略有不同。

术语:“强引用”、“弱引用”

强引用 – 是对对象或值的引用,可防止垃圾回收器将其删除。从而将对象或值保存在内存中,指向该对象或值。

这意味着,只要存在对对象或值的活动强引用,该对象或值就会保留在内存中,并且不会被垃圾回收器收集。

在 JavaScript 中,对对象的普通引用是强引用。例如

// the user variable holds a strong reference to this object
let user = { name: "John" };

弱引用 – 是对对象或值的引用,不会阻止垃圾回收器将其删除。如果对对象或值的唯一剩余引用是弱引用,则垃圾回收器可以删除该对象或值。

WeakRef

注意

在我们深入研究之前,值得注意的是,正确使用本文中讨论的结构需要非常仔细的思考,并且如果可能的话,最好避免使用它们。

WeakRef – 是一个对象,其中包含对另一个对象的弱引用,称为 targetreferent

WeakRef 的特点是它不会阻止垃圾回收器删除其引用对象。换句话说,WeakRef 对象不会使 referent 对象保持活动状态。

现在,让我们将 user 变量作为“引用对象”,并从中创建一个弱引用到 admin 变量。要创建弱引用,您需要使用 WeakRef 构造函数,并传入目标对象(您希望弱引用到的对象)。

在我们的例子中——这是 user 变量

//  the user variable holds a strong reference to the object
let user = { name: "John" };

//  the admin variable holds a weak reference to the object
let admin = new WeakRef(user);

下图描绘了两种类型的引用:使用 user 变量的强引用和使用 admin 变量的弱引用

然后,在某个时候,我们停止使用 user 变量——它被覆盖、超出范围等,同时将 WeakRef 实例保留在 admin 变量中

// let's overwrite the value of the user variable
user = null;

对对象的弱引用不足以使其“存活”。当对引用对象的唯一剩余引用是弱引用时,垃圾回收器可以自由地销毁此对象并将其内存用于其他用途。

但是,在对象实际被销毁之前,弱引用可能会返回它,即使不再有对该对象的强引用。也就是说,我们的对象变成了一种“薛定谔的猫”——我们无法确定它是否“活着”或“死了”

此时,要从 WeakRef 实例中获取对象,我们将使用其 deref() 方法。

如果对象仍在内存中,deref() 方法将返回 WeakRef 指向的引用对象。如果对象已被垃圾回收器删除,则 deref() 方法将返回 undefined

let ref = admin.deref();

if (ref) {
  // the object is still accessible: we can perform any manipulations with it
} else {
  // the object has been collected by the garbage collector
}

WeakRef 用例

WeakRef 通常用于创建缓存或 关联数组,用于存储资源密集型对象。这允许避免仅基于缓存或关联数组中的存在而阻止垃圾回收器收集这些对象。

主要示例之一 - 是当我们有大量二进制图像对象(例如,表示为 ArrayBufferBlob)时,并且我们希望将名称或路径与每个图像关联起来。现有的数据结构不太适合这些目的

  • 使用 Map 在名称和图像之间创建关联,反之亦然,将使图像对象保留在内存中,因为它们作为键或值存在于 Map 中。
  • WeakMap 也不能达到此目标:因为表示为 WeakMap 键的对象使用弱引用,并且不受垃圾回收器的删除保护。

但是,在这种情况下,我们需要一个在其值中使用弱引用的数据结构。

为此,我们可以使用 Map 集合,其值是引用我们需要的较大对象的 WeakRef 实例。因此,我们不会将这些大而无用的对象保留在内存中超过应有的时间。

否则,这是一种从缓存中获取图像对象的方法(如果它仍然可访问)。如果它已被垃圾回收,我们将重新生成或重新下载它。

这样,在某些情况下使用的内存更少。

示例 1:将 WeakRef 用于缓存

以下是演示使用 WeakRef 技术的代码片段。

简而言之,我们使用一个 Map,其中字符串键及其值是 WeakRef 对象。如果 WeakRef 对象尚未被垃圾回收器收集,我们从缓存中获取它。否则,我们重新下载它并将其放入缓存中以供将来可能重用

function fetchImg() {
    // abstract function for downloading images...
}

function weakRefCache(fetchImg) { // (1)
    const imgCache = new Map(); // (2)

    return (imgName) => { // (3)
        const cachedImg = imgCache.get(imgName); // (4)

        if (cachedImg?.deref()) { // (5)
            return cachedImg?.deref();
        }

        const newImg = fetchImg(imgName); // (6)
        imgCache.set(imgName, new WeakRef(newImg)); // (7)

        return newImg;
    };
}

const getCachedImg = weakRefCache(fetchImg);

让我们深入了解这里发生的事情

  1. weakRefCache – 是一个高阶函数,它将另一个函数 fetchImg 作为参数。在此示例中,我们可以忽略 fetchImg 函数的详细说明,因为它可以是任何下载图像的逻辑。
  2. imgCache – 是一个图像缓存,它以字符串键(图像名称)和 WeakRef 对象作为其值的形式存储 fetchImg 函数的缓存结果。
  3. 返回一个匿名函数,它将图像名称作为参数。此参数将用作缓存图像的键。
  4. 尝试使用提供的键(图像名称)从缓存中获取缓存结果。
  5. 如果缓存包含指定键的值,并且 WeakRef 对象尚未被垃圾回收器删除,则返回缓存结果。
  6. 如果缓存中没有具有请求键的条目,或者 deref() 方法返回 undefined(表示 WeakRef 对象已被垃圾回收),则 fetchImg 函数再次下载图像。
  7. 将下载的图像作为 WeakRef 对象放入缓存中。

现在我们有一个 Map 集合,其中键是字符串形式的图像名称,而值是包含图像本身的 WeakRef 对象。

此技术有助于避免为不再使用的资源密集型对象分配大量内存。在重用缓存对象的情况下,它还可以节省内存和时间。

以下是此代码的外观可视化表示

但是,此实现有其缺点:随着时间的推移,Map 将填充为字符串作为键,这些键指向 WeakRef,其引用对象已被垃圾回收

处理此问题的一种方法是定期清除缓存并清除“死”条目。另一种方法是使用终结器,我们将在下面探讨。

示例 2:使用 WeakRef 跟踪 DOM 对象

WeakRef 的另一个用例是跟踪 DOM 对象。

让我们想象一个场景,其中一些第三方代码或库与我们页面上的元素交互,只要它们存在于 DOM 中。例如,它可以是用于监视和通知系统状态的外部实用程序(通常称为“记录器”——发送称为“日志”的信息消息的程序)。

交互式示例

结果
index.js
index.css
index.html
const startMessagesBtn = document.querySelector('.start-messages'); // (1)
const closeWindowBtn = document.querySelector('.window__button'); // (2)
const windowElementRef = new WeakRef(document.querySelector(".window__body")); // (3)

startMessagesBtn.addEventListener('click', () => { // (4)
    startMessages(windowElementRef);
    startMessagesBtn.disabled = true;
});

closeWindowBtn.addEventListener('click', () =>  document.querySelector(".window__body").remove()); // (5)


const startMessages = (element) => {
    const timerId = setInterval(() => { // (6)
        if (element.deref()) { // (7)
            const payload = document.createElement("p");
            payload.textContent = `Message: System status OK: ${new Date().toLocaleTimeString()}`;
            element.deref().append(payload);
        } else { // (8)
            alert("The element has been deleted."); // (9)
            clearInterval(timerId);
        }
    }, 1000);
};
.app {
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.start-messages {
    width: fit-content;
}

.window {
    width: 100%;
    border: 2px solid #464154;
    overflow: hidden;
}

.window__header {
    position: sticky;
    padding: 8px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    background-color: #736e7e;
}

.window__title {
    margin: 0;
    font-size: 24px;
    font-weight: 700;
    color: white;
    letter-spacing: 1px;
}

.window__button {
    padding: 4px;
    background: #4f495c;
    outline: none;
    border: 2px solid #464154;
    color: white;
    font-size: 16px;
    cursor: pointer;
}

.window__body {
    height: 250px;
    padding: 16px;
    overflow: scroll;
    background-color: #736e7e33;
}
<!DOCTYPE HTML>
<html lang="en">

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="index.css">
  <title>WeakRef DOM Logger</title>
</head>

<body>

<div class="app">
  <button class="start-messages">Start sending messages</button>
  <div class="window">
    <div class="window__header">
      <p class="window__title">Messages:</p>
      <button class="window__button">Close</button>
    </div>
    <div class="window__body">
      No messages.
    </div>
  </div>
</div>


<script type="module" src="index.js"></script>
</body>
</html>

当单击“开始发送消息”按钮时,在所谓的“日志显示窗口”(一个具有 .window__body 类的元素)中,消息(日志)将开始显示。

但是,一旦从 DOM 中删除此元素,记录器就应停止发送消息。要重现此元素的移除,只需单击右上角的“关闭”按钮即可。

为了不使我们的工作复杂化,也不必在每次我们的 DOM 元素可用和不可用时通知第三方代码,只需使用 WeakRef 创建对它的弱引用即可。

一旦从 DOM 中移除元素,记录器将注意到它并停止发送消息。

现在让我们仔细看看源代码(选项卡 index.js

  1. 获取“开始发送消息”按钮的 DOM 元素。

  2. 获取“关闭”按钮的 DOM 元素。

  3. 使用 new WeakRef() 构造函数获取日志显示窗口的 DOM 元素。这样,windowElementRef 变量会持有对 DOM 元素的弱引用。

  4. 在“开始发送消息”按钮上添加一个事件侦听器,负责在单击时启动记录器。

  5. 在“关闭”按钮上添加一个事件侦听器,负责在单击时关闭日志显示窗口。

  6. 使用 setInterval 每秒开始显示一条新消息。

  7. 如果日志显示窗口的 DOM 元素仍然可访问并保存在内存中,则创建并发送一条新消息。

  8. 如果 deref() 方法返回 undefined,则表示已从内存中删除了 DOM 元素。在这种情况下,记录器将停止显示消息并清除计时器。

  9. alert,将在从内存中删除日志显示窗口的 DOM 元素后调用(即单击“关闭”按钮后)。请注意,从内存中删除可能不会立即发生,因为它仅取决于垃圾回收器的内部机制。

    我们无法直接从代码控制此过程。然而,尽管如此,我们仍然可以选择强制浏览器进行垃圾回收。

    例如,在 Google Chrome 中,要执行此操作,您需要打开开发者工具(在 Windows/Linux 上按 Ctrl + Shift + J,在 macOS 上按 Option + + J),转到“性能”选项卡,然后单击垃圾桶图标按钮 - “收集垃圾”


    大多数现代浏览器都支持此功能。执行操作后,alert 将立即触发。

FinalizationRegistry

现在是时候讨论终结器了。在我们继续之前,让我们澄清一下术语

清理回调(终结器) – 是一个函数,当注册在 FinalizationRegistry 中的对象被垃圾回收器从内存中删除时执行。

它的目的是 – 提供在对象最终从内存中删除后执行与对象相关的其他操作的能力。

注册表(或 FinalizationRegistry) – 是 JavaScript 中的一个特殊对象,用于管理对象及其清理回调的注册和注销。

此机制允许注册一个对象以跟踪并关联一个清理回调。从本质上讲,它是一个存储有关已注册对象及其清理回调的信息的结构,然后在从内存中删除对象时自动调用这些回调。

要创建 FinalizationRegistry 的实例,它需要调用其构造函数,该构造函数接受一个参数 – 清理回调(终结器)。

语法

function cleanupCallback(heldValue) {
  // cleanup callback code
}

const registry = new FinalizationRegistry(cleanupCallback);

此处

  • cleanupCallback – 当注册的对象从内存中删除时将自动调用的清理回调。
  • heldValue – 作为清理回调的参数传递的值。如果 heldValue 是一个对象,则注册表会保留对它的强引用。
  • registryFinalizationRegistry 的一个实例。

FinalizationRegistry 方法

  • register(target, heldValue [, unregisterToken]) – 用于在注册表中注册对象。

    target – 正在注册以进行跟踪的对象。如果 target 是垃圾回收的,则将使用 heldValue 作为其参数调用清理回调。

    可选的 unregisterToken – 注销令牌。它可以传递给在垃圾回收器删除对象之前注销对象。通常,target 对象用作 unregisterToken,这是标准做法。

  • unregister(unregisterToken)unregister 方法用于从注册表中注销对象。它接受一个参数 – unregisterToken(在注册对象时获得的注销令牌)。

现在让我们看一个简单的例子。让我们使用已经知道的 user 对象并创建一个 FinalizationRegistry 实例

let user = { name: "John" };

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`${heldValue} has been collected by the garbage collector.`);
});

然后,我们将注册对象,该对象需要一个清理回调,方法是调用 register 方法

registry.register(user, user.name);

注册表不会保留对正在注册的对象的强引用,因为这会违背其目的。如果注册表保留强引用,那么对象将永远不会被垃圾回收。

如果对象被垃圾回收器删除,我们的清理回调可能会在未来的某个时间点被调用,并向其传递 heldValue

// When the user object is deleted by the garbage collector, the following message will be printed in the console:
"John has been collected by the garbage collector."

还有一些情况,即使在使用清理回调的实现中,也有可能不会调用它。

例如

  • 当程序完全终止其操作时(例如,在浏览器中关闭标签页时)。
  • FinalizationRegistry 实例本身不再可供 JavaScript 代码访问时。如果创建 FinalizationRegistry 实例的对象超出范围或被删除,则在该注册表中注册的清理回调也可能不会被调用。

使用 FinalizationRegistry 进行缓存

回到我们的缓存示例,我们可以注意到以下内容

  • 即使 WeakRef 中包装的值已被垃圾回收器回收,但仍存在“内存泄漏”问题,表现为剩余的键,其值已被垃圾回收器回收。

下面是一个使用 FinalizationRegistry 改进的缓存示例

function fetchImg() {
  // abstract function for downloading images...
}

function weakRefCache(fetchImg) {
  const imgCache = new Map();

  const registry = new FinalizationRegistry((imgName) => { // (1)
    const cachedImg = imgCache.get(imgName);
    if (cachedImg && !cachedImg.deref()) imgCache.delete(imgName);
  });

  return (imgName) => {
    const cachedImg = imgCache.get(imgName);

    if (cachedImg?.deref()) {
      return cachedImg?.deref();
    }

    const newImg = fetchImg(imgName);
    imgCache.set(imgName, new WeakRef(newImg));
    registry.register(newImg, imgName); // (2)

    return newImg;
  };
}

const getCachedImg = weakRefCache(fetchImg);
  1. 为了管理“死”缓存条目的清理,当关联的 WeakRef 对象被垃圾回收器回收时,我们创建一个 FinalizationRegistry 清理注册表。

    这里的重要一点是,在清理回调中,应该检查条目是否已被垃圾回收器删除且未重新添加,以免删除“活动”条目。

  2. 一旦新值(图像)下载并放入缓存,我们就在终结器注册表中注册它以跟踪 WeakRef 对象。

此实现仅包含实际或“活动”键/值对。在这种情况下,每个 WeakRef 对象都注册在 FinalizationRegistry 中。在对象被垃圾回收器清理后,清理回调将删除所有 undefined 值。

以下是更新后的代码的可视化表示

更新后的实现的一个关键方面是,终结器允许在“主”程序和清理回调之间创建并行进程。在 JavaScript 的上下文中,“主”程序——是我们的 JavaScript 代码,在我们的应用程序或网页中运行并执行。

因此,从垃圾回收器标记对象以进行删除到实际执行清理回调的那一刻,可能存在一定的时间差。重要的是要理解,在此时间差期间,主程序可以对对象进行任何更改,甚至可以将其带回内存。

这就是为什么在清理回调中,我们必须检查主程序是否已将条目添加回缓存,以避免删除“活动”条目。类似地,在缓存中搜索键时,垃圾回收器可能已删除该值,但尚未执行清理回调。

如果您正在使用 FinalizationRegistry,此类情况需要特别注意。

在实践中使用 WeakRef 和 FinalizationRegistry

从理论到实践,想象一个现实生活中的场景,其中用户将移动设备上的照片与某些云服务(例如 iCloudGoogle Photos)同步,并希望从其他设备查看它们。除了查看照片的基本功能外,此类服务还提供许多附加功能,例如

  • 照片编辑和视频效果。
  • 创建“回忆”和相册。
  • 一系列照片的视频蒙太奇。
  • ……还有更多。

在此,作为一个示例,我们将使用此类服务的一个相当原始的实现。要点——是要展示在现实生活中同时使用 WeakRefFinalizationRegistry 的一个可能场景。

它看起来像这样


在左侧,有一个照片云库(它们显示为缩略图)。我们可以选择所需的图像并通过单击页面右侧的“创建拼贴”按钮创建拼贴。然后,可以将生成的拼贴下载为图像。

为了提高页面加载速度,以压缩质量下载和显示照片缩略图是合理的。但是,要从选定的照片创建拼贴,请下载并以全尺寸质量使用它们。

在下面,我们可以看到,缩略图的固有尺寸为 240x240 像素。该尺寸是故意选择的,以提高加载速度。此外,我们在预览模式下不需要全尺寸照片。


假设我们需要创建 4 张照片的拼贴画:我们选择它们,然后点击“创建拼贴画”按钮。在这个阶段,我们已经了解的 weakRefCache 函数会检查所需的图像是否在缓存中。如果没有,它会从云端下载图像并将其放入缓存中以供将来使用。对于每张选定的图像都会发生这种情况


注意控制台中的输出,你可以看到哪些照片是从云端下载的——这由 FETCHED_IMAGE 指示。由于这是第一次尝试创建拼贴画,这意味着在这个阶段“弱缓存”仍然是空的,并且所有照片都从云端下载并放入其中。

但是,除了下载图像的过程之外,还有垃圾回收器清理内存的过程。这意味着,我们使用弱引用引用的存储在缓存中的对象会被垃圾回收器删除。并且我们的终结器会成功执行,从而删除存储图像的缓存中的键。 CLEANED_IMAGE 会通知我们


接下来,我们意识到我们不喜欢生成的拼贴画,并决定更改其中一张图像并创建一个新的拼贴画。为此,只需取消选择不需要的图像,选择另一张图像,然后再次点击“创建拼贴画”按钮


但这次并非所有图像都从网络下载,其中一张图像取自弱缓存:CACHED_IMAGE 消息告诉了我们。这意味着在创建拼贴画时,垃圾回收器尚未删除我们的图像,并且我们大胆地从缓存中获取了它,从而减少了网络请求的数量并加快了拼贴画创建过程的整体时间


让我们再“玩一玩”,再次替换其中一张图像并创建一个新的拼贴画


这次的结果更加令人印象深刻。在选定的 4 张图像中,有 3 张取自弱缓存,只有一张需要从网络下载。网络负载减少了约 75%。令人印象深刻,不是吗?


当然,重要的是要记住,这种行为并不能得到保证,并且取决于垃圾回收器的具体实现和操作。

基于此,立即出现了一个完全合乎逻辑的问题:为什么我们不使用普通缓存,在其中我们可以自己管理其实体,而不是依赖垃圾回收器?没错,在绝大多数情况下,无需使用 WeakRefFinalizationRegistry

在这里,我们只是演示了使用非平凡方法和有趣的语言特性来实现类似功能的替代实现。不过,如果我们需要一个恒定且可预测的结果,我们不能依赖此示例。

您可以在沙盒中打开此示例

摘要

WeakRef – 旨在创建对象的弱引用,如果不再有对它们的强引用,则允许垃圾回收器从内存中删除它们。这有利于解决过度的内存使用问题,并优化应用程序中系统资源的利用。

FinalizationRegistry – 是一种用于注册回调的工具,当不再强引用对象时执行这些回调,这些对象将被销毁。这允许在从内存中删除对象之前释放与对象关联的资源或执行其他必要的操作。

教程地图

评论

在评论之前阅读此内容…
  • 如果您有改进建议 - 请提交 GitHub 问题或提交拉取请求,而不是评论。
  • 如果您无法理解文章中的某些内容 - 请详细说明。
  • 要插入几行代码,请使用 <code> 标记,对于多行 - 将它们包装在 <pre> 标记中,对于 10 行以上 - 使用沙盒 (plnkrjsbincodepen…)