# JS 进阶资源管理技巧

如果你在 Web 开发中,有需要构建复杂的应用、长时间运行的应用、遇到过内存溢出(OOM)错误,那么你可能需要了解本文 JS 资源管理 API。

Web 平台近些年开放了一些底层的 API,可以帮助我们进行细致地资源管理,从而在网页中构建复杂、健壮的 APP。

# 从一个简单例子开始

Web 页面使用 DOM 来构建 UI,功能模块的具体实现依赖 DOM 向用户展现内容,如果 DOM 被意外移除,则可能触发资源泄露。

假设有一个时钟程序,将时间数值渲染到 DOM 中,然后使用定时器(setInterval)每秒更新时钟数值。

const clock = document.getElementById("clock");
const timer = setInterval(() => {
  clock.textContent = new Date().toLocaleTimeString();
}, 1000);

如果时钟 DOM(clock 节点)被意外移除(如页面跳转),定时器(setInterval)永远不会停止,后台会持续做无意义的计算,就算触发了资源泄露。

有人说,我们可以通过 DOM API 检测 clock 节点是否存在于 DOM 树中,如果不存在就清除定时器;
但这种实现方法,无法兼容 clock 节点被临时移除,后续再重新插入的情况。

现在,我们可以借助一些新 API(WeakRef (opens new window) , FinalizationRegistry (opens new window)),检测 clock DOM 节点对象是否被 js 引擎的 GC 回收掉,如果是,就清除定时器;
这个实现方式有时会严谨一些。

下面通过真实场景,介绍 JS 中的资源管理 API 或语法。

# FinalizationRegistry(对象被 GC 触发事件)

假设,你需要实现一个创建临时文件的函数,当返回的文件对象被 GC 回收后,删除硬盘中的临时文件。
没错,现在 Web 已经拥有底层文件 API 了,参考 OPFS (opens new window)。

const registry = new FinalizationRegistry(async (path) => {
  // 临时文件对象被回收时可以拿到它的 path
  await file(path).remove();
});

function tmpfile() {
  const f = file(`/tmp/${uuid()}`);
  registry.register(f, f.path);
  return f;
}

# WeakRef(弱引用)

因为文件在硬盘上只会存在一份,我们希望采用单例模式实现上个例子中的 file 函数,相同的 path 共享同一个文件对象。

我们可以根据 path 缓存文件对象,优先返回缓存文件对象。

const fileCache = new Map();
function file(path) {
  const cacheF = fileCache.get(filePath);
  if (cacheF) return cacheF;
  // OTFile 是方便创建 OPFS 文件对象的工具
  const f = new OTFile(filePath);
  fileCache.set(filePath, f);
  return f;
}

但上述实现会导致 tmpfile 出现 bug,因为 fileCache 持有了文件对象,导致文件对象永远不会被 GC 回收,无法自动清除临时文件。

可以借助 WeakRef (opens new window)(弱引用)来实现,持有对象,但不阻碍文件对象被 GC 回收。

const fileCache = new Map();
function file(path) {
  // deref 返回弱引用持有的源对象,如果被回收则返回 undefined
  const cacheF = fileCache.get(filePath)?.deref();
  if (cacheF) return cacheF;
  const f = new OTFile(filePath);
  // 弱引用持有对象
  fileCache.set(filePath, new WeakRef(f));
  return f;
}

# 全局锁管理竞争资源

我们可以创建一个 writer = file(path).createWriter(),持续向文件中写入数据;
安全的文件句柄一般是独占的,如果多个 tab,对同一个文件同时创建 writer,就会报错。

我们可以使用 Navigator.locks (opens new window) API 来管理此类竞态资源。

async function appendToFile(path, data) {
  // 同源下所有 tab、Worker 共享同一个锁名称空间
  await navigator.locks.request(`file:${path}`, async () => {
    // 拿到锁后才会执行该回调,同一时刻只有一个回调在运行
    const writer = await file(path).createWriter();
    await writer.write(data);
    await writer.close();
  });
  // 回调返回的 Promise 结束后,锁自动释放
}

locks API 有几个特点,让它比自己实现的锁更可靠:

  • 跨上下文:同源的多个 tab、iframe、Worker 共享锁,天然解决多 tab 竞争问题
  • 自动释放:回调结束(包括抛出异常)后释放;持有锁的 tab 被关闭或崩溃,锁也会被释放,不会出现死锁残留
  • 灵活的获取策略:
    • ifAvailable: true 拿不到锁时立即返回 null,不排队等待
    • mode: 'shared' 共享锁,适合多读单写的场景(默认 exclusive 独占)
    • signal 传入 AbortSignal,可超时取消排队
    • steal: true 强制抢占锁,原持有者的锁会被释放

例如,只允许一个 tab 写文件,其他 tab 直接放弃:

await navigator.locks.request(
  `file:${path}`,
  { ifAvailable: true },
  async (lock) => {
    if (lock == null) {
      console.warn("文件正在被其他页面写入");
      return;
    }
    // ...写入文件
  },
);

navigator.locks.query() 可以查看当前持有、等待中的锁,方便调试。

# using 自动清理

像文件句柄、数据库事务、网络连接此类资源,创建之后必须安全回收,否则会造成资源泄露。

以前我们会用 try-catch-finally 语法来手动回收资源

async function writeLog(path, data) {
  const writer = await file(path).createWriter();
  try {
    await writer.write(data);
  } finally {
    // 无论成功与否,都要关闭 writer
    await writer.close();
  }
}

当需要同时管理多个资源时,代码会嵌套多层 try-finally,可读性很差,且很容易遗漏。

现在 JS 引入了 using (opens new window) 语法(显式资源管理),变量离开作用域时自动回收资源。
只需要给资源对象实现 Symbol.dispose(同步)或 Symbol.asyncDispose(异步)方法:

async function createWriter(path) {
  const writer = await file(path).createWriter();
  return {
    write: (data) => writer.write(data),
    // await using 变量离开作用域时自动调用
    [Symbol.asyncDispose]: () => writer.close(),
  };
}

async function writeLog(path, data) {
  await using writer = await createWriter(path);
  await writer.write(data);
  // 函数结束(包括抛出异常)时,自动执行 writer[Symbol.asyncDispose]()
}

多个 using 变量会按声明的相反顺序依次回收,与 try-finally 嵌套的效果一致。

对于没有实现 dispose 方法的资源,可以借助 DisposableStack / AsyncDisposableStack 统一托管:

function startClock(el) {
  using stack = new DisposableStack();
  const timer = setInterval(() => {
    el.textContent = new Date().toLocaleTimeString();
  }, 1000);
  // 注册清理函数
  stack.defer(() => clearInterval(timer));
  // ...其他可能抛出异常的初始化逻辑

  // 初始化成功,将资源所有权转移出去,由调用方决定何时回收
  const owned = stack.move();
  return { [Symbol.dispose]: () => owned.dispose() };
}

using clock = startClock(document.getElementById("clock"));

using 语法较新,使用前请检查目标环境兼容性;TypeScript 5.2+ 已支持该语法。

# 附录

💗 博主正处于裸辞待业状态,欢迎 商务合作 💗

相关文章

视频编辑,如何选择技术方案

以下是视频文字稿,观看视频请移步 B 站、抖音 开场 大家好! 我是风痕, 开源项目 WebAV(音视频编辑 SDK )的作者, 在我的博客有许多 Web 音视频入门的技术文章。 随着 AI 视频技 ...

从 React 看前端 UI 代码范式革命

alt text 前言 本来打算写的主题是“我为什么讨厌 React Hooks API”,展开聊聊“小甜甜”是如何变成“牛夫人”的,没想到越写越严肃:) React 是两次前端范式革命的引领者,至今仍有繁荣的社区和旺盛的创造力; React 多次天才又激进的创新,一些想法被借鉴改良、一些引发广泛质疑,大部分是被认同和接受的; ...

WebCodecs 性能表现及优化思路

ZingAI.video 字影是采用 WebCodecs 构建的视频剪辑工具,可在这里体验视频导出的性能 笔者开源 WebAV 已经一年半,还写了系列文章帮助初学者入门 Web 音视频。 之前一直隐隐担心在 Web 平台处理音视频与 Native APP 会有明显性能差距,因 ...

Web 网页自集成 HTTP 代理方案

前言 大部分程序员,想必都有会一个常用的抓包代理工具; 但在座的各位,可曾见过这样一款集成在 Web 应用中的代理工具? 它是明显区别于传统代理工具的,有以下特性: 零安装,零配置,Web 点击即用、APP 扫码即用;_(不 ...

Web 文件系统(OPFS 及工具)介绍

文件系统是往往是构建大型软件的基石之一,很长一段时间 Web 平台因缺失成熟的文件系统成为构建大型软件的阻碍,如今 OPFS 可弥补这一缺憾。 本文介绍 OPFS 背景和基本使用方法、使用过程中的注意事项,及如何配合笔者开源的 opfs-tools、opfs-tools-explorer 两个项目,充分发挥 OPFS 的性能与开发效率。 Web 存储 A ...

opfs-tools (文件系统 API) 项目介绍

文件系统是许多领域程序的基石,所有通用编程语言都会内置完备的文件系统 API。 Web 很长一段时间没有提供完善的访问文件系统的规范,使得需要高频读写文件、大文件处理软件在 Web 端都会受到一些限制,比如音视频剪辑、游戏、数据库等等。 之前在浏览器中实现视频裁剪、截帧等相关功能时,发现缺少基本的操作文件的 API,比如读写、移动、复制文件。 而 [OPFS] ...

Web 终极拦截技巧(全是骚操作)

拦截的价值 > 计算机科学领域的任何问题都可以通过增加一个中间层来解决。 —— Butler Lampson 如果系统的控制权、代码完全被掌控,很容易添加中间层; 现实情况我们往往无法控制系统的所有环节,所以需要使用一些 “非常规”(拦截) 手段来增加中间层。 拦截的方法 拦截/覆写 浏览器 API 最常见的场景有通过拦截 console ...

Web 音视频(六)图像素材处理

Web 音视频目录 前序章节介绍了如何在浏览器中解析、创建视频,以及给视频添加一些自定义素材(图片、音频、文字...); 本章介绍如何给图像素材加特效、加动画,实现转场、移动水印、图像滤镜美化等功能。 你可以跳过原理介绍,直接查看 WebAV 示例 素材动画 在视频制作中实现动画跟其他场景略有不同,因为视频 ...