语言的“隐藏”特征
本文涵盖的主题非常狭窄,大多数开发人员在实践中很少遇到(甚至可能不知道它的存在)。
如果您刚刚开始学习 JavaScript,我们建议您跳过本章。
回顾垃圾收集章节中可达性原则的基本概念,我们可以注意到 JavaScript 引擎保证将可访问或正在使用的值保留在内存中。
例如:
// 用户变量持有对该对象的强引用 让用户= { 名称:“约翰” }; // 让我们覆盖用户变量的值 用户=空; // 引用丢失,对象将从内存中删除
或者类似但稍微复杂的代码,有两个强引用:
// 用户变量持有对该对象的强引用 让用户= { 名称:“约翰” }; // 将对对象的强引用复制到 admin 变量中 让管理员=用户; // 让我们覆盖用户变量的值 用户=空; // 该对象仍然可以通过 admin 变量访问
仅当没有对对象的强引用时(如果我们还覆盖了admin
变量的值),对象{ name: "John" }
才会从内存中删除。
在 JavaScript 中,有一个称为WeakRef
的概念,在这种情况下它的行为略有不同。
术语:“强引用”、“弱引用”
强引用– 是对对象或值的引用,可防止垃圾收集器删除它们。因此,将对象或值保存在它所指向的内存中。
这意味着,对象或值保留在内存中,并且不会被垃圾收集器收集,因为存在对其的活动强引用。
在 JavaScript 中,对对象的普通引用是强引用。例如:
// 用户变量持有对此对象的强引用 让用户= { 名称:“约翰” };
弱引用– 是对对象或值的引用,这不会阻止它们被垃圾收集器删除。如果对象或值的唯一剩余引用是弱引用,则垃圾收集器可以删除它们。
注意事项
在我们深入探讨之前,值得注意的是,正确使用本文中讨论的结构需要非常仔细的思考,并且如果可能的话最好避免使用它们。
WeakRef
– 是一个对象,包含对另一个对象的弱引用,称为target
或referent
。
WeakRef
的特点是它不会阻止垃圾收集器删除其引用对象。换句话说, WeakRef
对象不会使referent
对象保持活动状态。
现在让我们将user
变量作为“引用对象”,并创建一个从它到admin
变量的弱引用。要创建弱引用,您需要使用WeakRef
构造函数,传入目标对象(您想要弱引用的对象)。
在我们的例子中 - 这是user
变量:
// 用户变量持有对该对象的强引用 让用户= { 名称:“约翰” }; // admin 变量持有对象的弱引用 让 admin = new WeakRef(用户);
下图描述了两种类型的引用:使用user
变量的强引用和使用admin
变量的弱引用:
然后,在某个时刻,我们停止使用user
变量——它会被覆盖、超出范围等,同时将WeakRef
实例保留在admin
变量中:
// 让我们覆盖用户变量的值 用户=空;
对对象的弱引用不足以使其保持“活动”。当对引用对象的唯一剩余引用是弱引用时,垃圾收集器可以自由地销毁该对象并将其内存用于其他用途。
但是,在对象实际销毁之前,弱引用可能会返回该对象,即使不再有对此对象的强引用。也就是说,我们的对象变成了一种“薛定谔的猫”——我们无法确定它是“活着”还是“死了”:
此时,要从WeakRef
实例获取对象,我们将使用其deref()
方法。
deref()
方法返回WeakRef
指向的引用对象(如果该对象仍在内存中)。如果该对象已被垃圾收集器删除,则deref()
方法将返回undefined
:
让 ref = admin.deref(); 如果(参考){ // 该对象仍然可以访问:我们可以用它执行任何操作 } 别的 { // 该对象已被垃圾收集器收集 }
WeakRef
通常用于创建存储资源密集型对象的缓存或关联数组。这允许人们避免仅根据它们在缓存或关联数组中的存在来阻止垃圾收集器收集这些对象。
主要示例之一是当我们有大量二进制图像对象(例如,表示为ArrayBuffer
或Blob
)时的情况,并且我们希望将名称或路径与每个图像相关联。现有的数据结构不太适合这些目的:
使用Map
在名称和图像之间创建关联(反之亦然)会将图像对象保留在内存中,因为它们作为键或值出现在Map
中。
WeakMap
也不符合此目标:因为表示为WeakMap
键的对象使用弱引用,并且不受垃圾收集器的删除保护。
但是,在这种情况下,我们需要一个在其值中使用弱引用的数据结构。
为此,我们可以使用Map
集合,其值是引用我们需要的大对象的WeakRef
实例。因此,我们不会将这些大的和不必要的对象在内存中保留的时间超过应有的时间。
否则,这是一种从缓存中获取图像对象(如果仍然可访问)的方法。如果它已被垃圾收集,我们将重新生成或重新下载它。
这样,在某些情况下可以使用更少的内存。
下面的代码片段演示了使用WeakRef
的技术。
简而言之,我们使用带有字符串键和WeakRef
对象作为值的Map
。如果WeakRef
对象还没有被垃圾收集器收集,我们就从缓存中获取它。否则,我们再次重新下载它并将其放入缓存中以供进一步重用:
函数 fetchImg() { // 下载图像的抽象函数... } 函数weakRefCache(fetchImg) { // (1) const imgCache = new Map(); // (2) return (imgName) => { // (3) const cachedImg = imgCache.get(imgName); // (4) if (cachedImg?.deref()) { // (5) 返回cachedImg?.deref(); } const newImg = fetchImg(imgName); // (6) imgCache.set(imgName, new WeakRef(newImg)); // (7) 返回新图像; }; } const getCachedImg =weakRefCache(fetchImg);
让我们深入研究一下这里发生的事情的细节:
weakRefCache
– 是一个高阶函数,它接受另一个函数fetchImg
作为参数。在这个例子中,我们可以忽略对fetchImg
函数的详细描述,因为它可以是任何用于下载图像的逻辑。
imgCache
– 是图像缓存,以字符串键(图像名称)和WeakRef
对象作为其值的形式存储fetchImg
函数的缓存结果。
返回一个以图像名称作为参数的匿名函数。该参数将用作缓存图像的键。
尝试使用提供的键(图像名称)从缓存中获取缓存结果。
如果缓存包含指定键的值,并且WeakRef
对象尚未被垃圾收集器删除,则返回缓存结果。
如果缓存中没有包含所请求键的条目,或者deref()
方法返回undefined
(意味着WeakRef
对象已被垃圾收集),则fetchImg
函数将再次下载图像。
将下载的图像作为WeakRef
对象放入缓存中。
现在我们有一个Map
集合,其中键是字符串形式的图像名称,值是包含图像本身的WeakRef
对象。
此技术有助于避免为没有人再使用的资源密集型对象分配大量内存。在重用缓存对象的情况下,它还可以节省内存和时间。
以下是此代码的直观表示:
但是,这种实现有其缺点:随着时间的推移, Map
将填充字符串作为键,这些字符串指向WeakRef
,其所指对象已被垃圾收集:
处理这个问题的一种方法是定期清理缓存并清除“死”条目。另一种方法是使用终结器,我们接下来将探讨它。
WeakRef
的另一个用例是跟踪 DOM 对象。
让我们想象一个场景,其中一些第三方代码或库与我们页面上的元素交互,只要它们存在于 DOM 中。例如,它可以是用于监视和通知系统状态的外部实用程序(通常称为“记录器”——发送称为“日志”的信息消息的程序)。
交互示例:
结果
索引.js
索引.css
索引.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('点击', () => { // (4) startMessages(windowElementRef); startMessagesBtn.disabled = true; }); closeWindowBtn.addEventListener('click', () => document.querySelector(".window__body").remove()); // (5) const startMessages = (元素) => { const timeId = setInterval(() => { // (6) if (element.deref()) { // (7) const 负载 = document.createElement("p"); Payload.textContent = `消息:系统状态正常:${new Date().toLocaleTimeString()}`; element.deref().append(有效负载); } 其他 { // (8) alert("该元素已被删除。"); // (9) 清除间隔(timerId); } }, 1000); };
。应用程序 { 显示:柔性; 弯曲方向:列; 间隙:16px; } .start-messages { 宽度:适合内容; } 。窗户 { 宽度:100%; 边框:2px实线#464154; 溢出:隐藏; } .window__header { 位置:粘性; 内边距:8px; 显示:柔性; justify-content:空间之间; 对齐项目:居中; 背景颜色:#736e7e; } .window__title { 保证金:0; 字体大小:24px; 字体粗细:700; 颜色: 白色; 字母间距:1px; } .window__button { 内边距:4px; 背景:#4f495c; 概要:无; 边框:2px实线#464154; 颜色: 白色; 字体大小:16px; 光标:指针; } .window__body { 高度:250 像素; 内边距:16px; 溢出:滚动; 背景颜色:#736e7e33; }
<!DOCTYPE HTML> <html lang="en"> <头> <元字符集=“utf-8”> <link rel="stylesheet" href="index.css"> <title>WeakRef DOM 记录器</title> </头> <正文> <div类=“应用程序”> <button class="start-messages">开始发送消息</button> <div 类=“窗口”> <div class="window__header"> <p class="window__title">消息:</p> <button class="window__button">关闭</button> </div> <div class="window__body"> 没有消息。 </div> </div> </div> <脚本类型=“模块”src=“index.js”></脚本> </正文> </html>
当单击“开始发送消息”按钮时,在所谓的“日志显示窗口”(具有.window__body
类的元素)中,消息(日志)开始出现。
但是,一旦从 DOM 中删除该元素,记录器就应该停止发送消息。要重现删除此元素,只需单击右上角的“关闭”按钮即可。
为了不使我们的工作复杂化,并且不在每次 DOM 元素可用时通知第三方代码,或者当 DOM 元素不可用时,使用WeakRef
创建对其的弱引用就足够了。
一旦元素从 DOM 中删除,记录器就会注意到它并停止发送消息。
现在让我们仔细看看源代码(选项卡index.js
):
获取“开始发送消息”按钮的 DOM 元素。
获取“关闭”按钮的 DOM 元素。
使用new WeakRef()
构造函数获取日志显示窗口的 DOM 元素。这样, windowElementRef
变量就保存了对 DOM 元素的弱引用。
在“开始发送消息”按钮上添加事件监听器,负责在单击时启动记录器。
在“关闭”按钮上添加事件监听器,负责在单击时关闭日志显示窗口。
使用setInterval
开始每秒显示一条新消息。
如果日志显示窗口的 DOM 元素仍然可访问并保留在内存中,则创建并发送新消息。
如果deref()
方法返回undefined
,则意味着 DOM 元素已从内存中删除。在这种情况下,记录器停止显示消息并清除计时器。
alert
,在日志显示窗口的 DOM 元素从内存中删除后(即单击“关闭”按钮后),将调用该函数。请注意,从内存中删除可能不会立即发生,因为它仅取决于垃圾收集器的内部机制。
我们无法直接从代码中控制这个过程。然而,尽管如此,我们仍然可以选择强制浏览器进行垃圾回收。
例如,在 Google Chrome 中,要执行此操作,您需要打开开发人员工具(Windows/Linux 上为Ctrl+Shift+J或 macOS 上为Option+⌘+J),转到“性能”选项卡,然后单击垃圾箱图标按钮 - “收集垃圾”:
大多数现代浏览器都支持此功能。采取行动后, alert
将立即触发。
现在是时候讨论终结器了。在继续之前,让我们先澄清一下术语:
清理回调(终结器) ——是当垃圾收集器从内存中删除在FinalizationRegistry
中注册的对象时执行的函数。
它的目的是在对象最终从内存中删除后,提供执行与对象相关的附加操作的能力。
Registry (或FinalizationRegistry
)——是 JavaScript 中的一个特殊对象,用于管理对象的注册和取消注册及其清理回调。
此机制允许注册一个对象来跟踪清理回调并将其与其关联。本质上,它是一个结构,用于存储有关已注册对象及其清理回调的信息,然后在从内存中删除对象时自动调用这些回调。
要创建FinalizationRegistry
的实例,它需要调用其构造函数,该构造函数采用单个参数 - 清理回调(终结器)。
句法:
函数 cleanupCallback(heldValue) { // 清理回调代码 } const 注册表 = new FinalizationRegistry(cleanupCallback);
这里:
cleanupCallback
– 当从内存中删除注册对象时将自动调用的清理回调。
heldValue
– 作为参数传递给清理回调的值。如果heldValue
是一个对象,则注册表会保留对其的强引用。
registry
– FinalizationRegistry
的一个实例。
FinalizationRegistry
方法:
register(target, heldValue [, unregisterToken])
– 用于在注册表中注册对象。
target
– 被注册用于跟踪的对象。如果target
被垃圾收集,则将调用清理回调,并以heldValue
作为其参数。
可选的unregisterToken
– 取消注册令牌。可以传递它来在垃圾收集器删除对象之前注销该对象。通常, target
对象用作unregisterToken
,这是标准做法。
unregister(unregisterToken)
– unregister
方法用于从注册表中取消注册对象。它需要一个参数 - unregisterToken
(注册对象时获得的注销令牌)。
现在让我们继续看一个简单的例子。让我们使用已知的user
对象并创建FinalizationRegistry
的实例:
让用户= { 名称:“约翰” }; const 注册表 = 新 FinalizationRegistry((heldValue) => { console.log(`${heldValue} 已被垃圾收集器收集。`); });
然后,我们将通过调用register
方法来注册需要清理回调的对象:
registry.register(用户, 用户名);
注册表不会保留对正在注册的对象的强引用,因为这会违背其目的。如果注册表保留强引用,则该对象永远不会被垃圾收集。
如果该对象被垃圾收集器删除,我们的清理回调可能会在将来的某个时刻被调用,并将heldValue
传递给它:
// 当用户对象被垃圾收集器删除时,控制台中将打印以下消息: “约翰已被垃圾收集器收集。”
还有一些情况,即使在使用清理回调的实现中,也有可能不会调用它。
例如:
当程序完全终止其操作时(例如,关闭浏览器中的选项卡时)。
当 JavaScript 代码无法再访问FinalizationRegistry
实例本身时。如果创建FinalizationRegistry
实例的对象超出范围或被删除,则也可能不会调用在该注册表中注册的清理回调。
回到我们的弱缓存示例,我们可以注意到以下内容:
即使WeakRef
中包装的值已被垃圾收集器收集,但剩余键(其值已被垃圾收集器收集)的形式仍然存在“内存泄漏”问题。
以下是使用FinalizationRegistry
改进的缓存示例:
函数 fetchImg() { // 下载图像的抽象函数... } 函数weakRefCache(fetchImg) { const imgCache = new Map(); constregistry = new FinalizationRegistry((imgName) => { // (1) const cachedImg = imgCache.get(imgName); if (cachedImg && !cachedImg.deref()) imgCache.delete(imgName); }); 返回 (imgName) => { const cachedImg = imgCache.get(imgName); if (cachedImg?.deref()) { 返回cachedImg?.deref(); } const newImg = fetchImg(imgName); imgCache.set(imgName, new WeakRef(newImg)); 注册表.注册(newImg,imgName); // (2) 返回新图像; }; } const getCachedImg =weakRefCache(fetchImg);
为了管理“死”缓存条目的清理,当垃圾收集器收集关联的WeakRef
对象时,我们创建一个FinalizationRegistry
清理注册表。
这里重要的一点是,在清理回调中,应该检查该条目是否被垃圾收集器删除并且没有重新添加,以免删除“活动”条目。
下载新值(图像)并将其放入缓存后,我们将其注册到终结器注册表中以跟踪WeakRef
对象。
此实现仅包含实际或“实时”键/值对。在这种情况下,每个WeakRef
对象都在FinalizationRegistry
中注册。在垃圾收集器清理对象后,清理回调将删除所有undefined
值。
以下是更新后的代码的直观表示:
更新实现的一个关键方面是终结器允许在“主”程序和清理回调之间创建并行进程。在 JavaScript 的上下文中,“主”程序是我们的 JavaScript 代码,它在我们的应用程序或网页中运行和执行。
因此,从对象被垃圾收集器标记为删除的那一刻起,到清理回调的实际执行,可能存在一定的时间间隔。重要的是要理解,在这段时间内,主程序可以对对象进行任何更改,甚至将其带回内存。
这就是为什么在清理回调中,我们必须检查主程序是否已将条目添加回缓存,以避免删除“活动”条目。同样,当在缓存中搜索某个键时,有可能该值已被垃圾收集器删除,但清理回调尚未执行。
如果您使用FinalizationRegistry
则需要特别注意这种情况。
从理论转向实践,想象一个现实生活场景,用户将移动设备上的照片与某些云服务(例如 iCloud 或 Google Photos)同步,并希望从其他设备上查看它们。除了查看照片的基本功能外,此类服务还提供许多附加功能,例如:
照片编辑和视频效果。
创建“回忆”和专辑。
一系列照片的视频蒙太奇。
……还有更多。
在这里,作为示例,我们将使用此类服务的相当原始的实现。要点是展示在现实生活中一起使用WeakRef
和FinalizationRegistry
的可能场景。
它看起来是这样的:
左侧有一个云照片库(它们显示为缩略图)。我们可以选择我们需要的图像并通过单击页面右侧的“创建拼贴”按钮来创建拼贴。然后,生成的拼贴画可以作为图像下载。
为了提高页面加载速度,以压缩质量下载和显示照片缩略图是合理的。但是,要从选定的照片创建拼贴画,请以全尺寸质量下载并使用它们。
从下面我们可以看到,缩略图的固有大小是 240x240 像素。选择尺寸的目的是为了提高加载速度。此外,我们不需要预览模式下的全尺寸照片。
假设我们需要创建 4 张照片的拼贴画:选择它们,然后单击“创建拼贴画”按钮。在这个阶段,我们已经知道weakRefCache
函数检查所需的图像是否在缓存中。如果没有,它会从云端下载并将其放入缓存中以供进一步使用。对于每个选定的图像都会发生这种情况:
注意控制台中的输出,您可以看到哪些照片是从云端下载的 - 这由FETCHED_IMAGE指示。由于这是第一次尝试创建拼贴画,这意味着,在这个阶段“弱缓存”仍然是空的,所有照片都是从云端下载并放入其中的。
但是,除了下载图像的过程之外,还有垃圾收集器清理内存的过程。这意味着,我们使用弱引用引用的存储在缓存中的对象将被垃圾收集器删除。我们的终结器成功执行,从而删除了将图像存储在缓存中的密钥。 CLEANED_IMAGE通知我们:
接下来,我们意识到我们不喜欢最终的拼贴画,并决定更改其中一张图像并创建一张新图像。为此,只需取消选择不需要的图像,选择另一张图像,然后再次单击“创建拼贴”按钮:
但这一次并非所有图像都是从网络下载的,其中一张图像是从弱缓存中获取的: CACHED_IMAGE消息告诉我们这一点。这意味着在拼贴创建时,垃圾收集器还没有删除我们的图像,我们大胆地从缓存中取出它,从而减少了网络请求数量并加快了拼贴创建过程的整体时间:
让我们再“玩一玩”,再次替换其中一张图像并创建一个新的拼贴画:
这次的结果更加令人印象深刻。所选的 4 张图片中,有 3 张是从弱缓存中获取的,只有一张需要从网络下载。网络负载减少约75%。令人印象深刻,不是吗?
当然,重要的是要记住,这种行为是不能保证的,并且取决于垃圾收集器的具体实现和操作。
基于此,一个完全合乎逻辑的问题立即出现:为什么我们不使用普通的缓存,我们可以自己管理其实体,而不是依赖垃圾收集器?没错,绝大多数情况下不需要使用WeakRef
和FinalizationRegistry
。
在这里,我们简单地演示了类似功能的替代实现,使用具有有趣语言功能的非平凡方法。尽管如此,如果我们需要一个恒定且可预测的结果,我们就不能依赖这个例子。
您可以在沙箱中打开此示例。
WeakRef
– 旨在创建对对象的弱引用,如果不再有对它们的强引用,则允许垃圾收集器从内存中删除它们。这有利于解决内存使用过多的问题并优化应用程序中系统资源的利用率。
FinalizationRegistry
– 是一个用于注册回调的工具,当不再强引用的对象被销毁时执行该回调。这允许在从内存中删除对象之前释放与对象关联的资源或执行其他必要的操作。