原文链接:It’s about to get a lot easier for your JavaScript to clean up after itself
作者:Mat Marquis
按照布偶角色的统一分类理论,JavaScript 开发者分两类:混乱型布偶开发者和秩序型布偶开发者。
我本质上就是个混乱型布偶。我仿佛生在 “床上吃饼干” 的星象之下,上升星座还是 “飞鱼回旋镖”。倘若我有半点音乐天赋,我想我会更像牙博士,而非罗夫;更像刚佐,而非科米蛙。这一点,从我会在一篇讲 JavaScript 标准提案的文章里聊布偶秀,就能体现得淋漓尽致。
但说到 JavaScript 本身,我可以告诉你,我严谨到了极致,甚至可以说是吹毛求疵。我默认用 const 声明变量,只在极少数确定变量值需要修改的情况下,才会用 let;我绝不容忍 “随心所欲修改变量绑定” 这种荒唐事。这又不是爵士乐,岂能随心所欲?我对作用域和资源清理的事一丝不苟 —— 释放内存、关闭连接,诸如此类。尽管在现实中做饭时,我会把一大堆厨具弄得厨房到处都是,最后全堆进洗手池里。可一碰到 JavaScript,我就像变了个人,突然就成了那种 “你们这帮人都太离谱了” 的样子。
正是这种与我本性相悖的、对 JavaScript 代码整洁的执念,让我看到显式资源管理提案的持续推进时,打心底里感到开心(还唱着调,音量也不会吵到周围的器官)。这个提案不仅为我们提供了几种新方式,能让我们在用完对象后妥善 “收尾”,还将我们早已在使用的部分方式进行了规范化。首先,它为一个我们如今可能正在用、却未必知道名字的概念正名 —— 而这个概念,恰好精准契合我的 “吹毛求疵”。如果你用过 WeakSet 或 WeakMap,那你大概率对隐式资源管理的原理并不陌生,只是未必知道这个名字。
隐式资源管理
WeakSet 和 WeakMap 中的 “弱(Weak)”,意味着它们对值的引用是弱持有的。这就表示,这些数据结构不会阻止其引用的值被垃圾回收 —— 一旦这些值不再被代码中的任何地方使用,就会被从内存中移除。因此,WeakSet 或 WeakMap 只能存储可被垃圾回收的值:也就是对象引用,以及未加入全局 Symbol 注册表的 Symbol 类型。如果尝试添加对象引用或未注册 Symbol 之外的任何值,都会抛出错误:
const theWeakSet = new WeakSet( [ true ] );
当 WeakSet 中引用的对象或 Symbol,在代码中不再有其他任何引用时,这个值不仅会成为垃圾回收的候选对象,WeakSet 内部对它的弱引用,也会被标记为可移除。如果你觉得这个使用场景很小众,那确实如此:只有当你需要一个集合来存储唯一的引用值,同时又不想让这个集合阻止这些引用值被垃圾回收(当代码中其他地方不再使用它们时),且希望它们被回收后自动从集合中移除时,才会用到 WeakSet。这确实是个非常特定的使用场景。
WeakMap 的键也遵循同样的规则:只能是对象引用或未注册的 Symbol 值。当这些键在代码中不再有其他任何引用时,它们会被标记为可垃圾回收,对应的键值对也会被标记为可从 WeakMap 中移除。借助 WeakMap,我们可以将任意值与一个对象关联,既不用把这些值存在对象内部,也不会阻止这个对象被垃圾回收:
const theObject = {};
const theWeakMap = new WeakMap([
[ theObject, "比如,一段描述这个对象的字符串。" ]
]);
console.log( theWeakMap.get( theObject ) );
你看 —— 这就是能自己完成清理的 JavaScript 代码!尽管使用场景依旧小众,但这种整洁、有序的感觉,实在太让人舒心了!说实话,我真希望能更频繁地用到这个特性。
就像现实生活一样:垃圾回收可能会延迟
需要说明的是,在上面的例子中,我们无法确定
theObject究竟会不会被垃圾回收,又会在何时被回收。我们只知道,当 WeakSet 中的值或 WeakMap 中的键不再有其他引用时,它们可以被垃圾回收,但我们无法确定回收的具体时机,也就无法确定它们何时会真正从 WeakSet 或 WeakMap 中被移除。事实上,上面的代码片段并没有展现全部真相 —— 如果再往下执行一点,你会发现,即便
theObject不再有其他任何引用,WeakMap 里依然会保留我们定义的那个字符串:let theObject = {}; const theWeakMap = new WeakMap([ [ theObject, "比如,一段描述这个对象的字符串。" ] ]); console.log( theWeakMap.get( theObject ) ); // 结果:"比如,一段描述这个对象的字符串。" theObject = true; console.log( theWeakMap ); // 结果:WeakMap { {} → "比如,一段描述这个对象的字符串。" }无论我们多追求秩序,JavaScript 本身始终会带点 “混乱”。让 JavaScript 自行管理内存的缺点是,我们几乎无法干预这个过程;但优点也很明显 —— 我们根本无需干预。
正如你理所应当会想到的,这份 “显式资源管理提案” 的意义,远不止为我们早已在使用的隐式资源管理起个名字。它引入了一套统一的方法 —— 这个 “方法” 的双关语是我故意用的 —— 来实现显式的资源管理,还为 JavaScript 带来了自 2015 年以来最重磅的语言特性更新。
显式资源管理
显式资源管理并非直接管理内存 —— 垃圾回收依旧是 JavaScript 负责的事,而非开发者的事。显式资源管理的核心,是让开发者主动做好资源清理。提案的这一部分,允许我们通过命令式或声明式的方式,指定当一个资源(拥有明确 “完成状态” 的对象)达到生命周期终点时,执行我们定义的一系列清理操作。
我知道,单看文字描述,这个使用场景会显得很抽象,但从概念上来说,这并不是什么新东西。我们早已拥有 “拥有明确完成状态的资源” 模型,那就是生成器对象:
function * generatorFunction() {
yield true;
yield false;
};
const generatorObject = generatorFunction();
console.log( generatorObject.next() );
// 结果:Object { value: true, done: false }
console.log( generatorObject.next() );
// 结果:Object { value: false, done: false }
console.log( generatorObject.next() );
// 结果:Object { value: undefined, done: true }
只有当调用 next() 方法时,生成器尝试返回超出其最终产出值的内容时,done 属性的值才会变为 true—— 这意味着这个生成器对象的生命周期走到了终点。
如果在生成器自然结束前,调用它的 return() 方法,就能提前终止这个生成器对象:
function * generatorFunction() {
yield true;
yield false;
};
const generatorObject = generatorFunction();
console.log( generatorObject.next() );
// 结果:Object { value: true, done: false }
console.log( generatorObject.return() );
// 结果:Object { value: undefined, done: true }
而如果在生成器函数内部使用 try … finally 语句,我们就能指定在生成器对象生命周期结束时执行的代码 —— 无论它是自然结束:
function * generatorFunction() {
try {
yield true;
yield false;
} finally {
console.log( "全部执行完毕。" );
}
};
const generatorObject = generatorFunction();
console.log( generatorObject.next() );
// 结果:Object { value: true, done: false }
console.log( generatorObject.next() );
// 结果:Object { value: false, done: false }
console.log( generatorObject.next() );
/* 结果:
全部执行完毕。
Object { value: undefined, done: true }
*/
还是通过 return() 方法提前结束:
function * generatorFunction() {
try {
yield true;
yield false;
} finally {
console.log( "全部执行完毕。" );
}
};
const generatorObject = generatorFunction();
console.log( generatorObject.next() );
// 结果:Object { value: true, done: false }
console.log( generatorObject.return() );
/* 结果:
全部执行完毕。
Object { value: undefined, done: true }
*/
对生成器对象调用 return() 方法,是命令式资源管理的一种方式 —— 相当于毫不含糊地告诉 JavaScript:“现在,执行这些清理操作,然后终止这个对象。” 调用 close() 关闭 WebSocket 连接、调用 abort() 终止文件请求、调用 disconnect() 注销交叉观察器(IntersectionObserver),这些都是命令式资源管理的典型例子。
你应该能发现,实现这些清理操作的语法 —— 严格来说 —— 杂乱无章。完成 “关闭并清理” 这个极其常见的操作,需要用到无数命名不统一的方法,这一下,我这个 “秩序型布偶” 的脾气就上来了。我们是要做严谨的开发工作,是有既定流程的。能不能把这些杂乱的东西清理干净?吉尔达・拉德纳三十秒后就要登场了。
为了解决这个问题,显式资源管理提案将 [Symbol.dispose] 清理方法标准化,把它作为迭代器 return() 方法的统一封装:
function * generatorFunction() {
try {
yield true;
yield false;
} finally {
console.log( "全部执行完毕。" );
}
};
const generatorObject = generatorFunction();
console.log( generatorObject.next() );
// 结果:Object { value: true, done: false }
console.log( generatorObject[Symbol.dispose]() );
// 结果:全部执行完毕。
在这个场景下,这看似只是个小改动,但背后的意义却无比重大:[Symbol.dispose]() 的引入,为所有需要清理的 API,提供了一套有序、可预测的命令式资源管理语法。无论你要关闭文件、套接字、流,还是其他任何资源,只需对该资源调用 [Symbol.dispose]() 方法,它就会触发该资源专属的清理逻辑。我和其他人一样,喜欢 “野兽” 的架子鼓独奏,但说到 JavaScript 开发,我永远选择可预测的统一语法。
尽管这已经够让人兴奋了 —— 当然,是从开发者的角度来说 —— 但这种新的语法统一性,为 JavaScript 带来了一个更重磅的特性。要知道,当我们用命令式的方式做显式资源管理时,依旧需要开发者手动调用 [Symbol.dispose]()(或 return())来完成清理 —— 而一旦代码执行离开了资源引用所在的作用域,一切就都来不及了:
{
function * generatorFunction() {
console.log( "打开文件。" );
try {
yield true;
yield false;
} finally {
console.log( "关闭你还开着的文件。" );
}
};
const generatorObject = generatorFunction();
console.log( generatorObject.next() );
/* 结果:
打开文件。
Object { value: true, done: false }
*/
};
console.log( generatorObject[Symbol.dispose]() );
// 未捕获引用错误:generatorObject 未定义
隐式资源管理是 JavaScript 的事,但显式资源管理是开发者的事。Node 或许会尝试帮我们关闭这个文件,但它并不会做出任何承诺。如果这是一个 WebSocket 连接,它会一直保持打开状态;如果是 Web Worker 流,它会一直处于锁定状态。最好的情况,是闲置的资源造成毫无必要的性能消耗;最坏的情况,是成为代码未来出现错误的根源。
因此,统一的 [Symbol.dispose] 方法带来了语法的可预测性,也让我们能从命令式的显式资源管理,转向声明式的显式资源管理。既然无论处理哪种资源,我们都能通过调用 [Symbol.dispose]() 告诉 JavaScript“清理好当前资源,准备执行下一个操作”,那么只要我们稍加引导,JavaScript 就能可靠地完成自我清理。
using 关键字
显式资源管理提案为 JavaScript 带来了一个新特性 —— 这种级别的语言更新,上一次出现还是在 2015 年:一种全新的变量声明方式。
用 using 关键字声明的变量,和 const、let 一样,是块级作用域的 —— 而且和 const 类似,using 声明的变量值不能被重新赋值。从表面上看,它的声明语法也没什么特别的:
{
using theVariable = null;
console.log( theVariable );
// 结果:null
};
二者的核心区别在于,using 声明的是一个可释放的资源,其生命周期与变量所在的作用域深度绑定。当变量被首次声明时,会从赋值的对象中获取一个释放器—— 也就是 [Symbol.dispose] 属性对应的方法,并将这个释放器保存在变量的作用域中。一旦代码执行退出该变量的定义作用域,这个释放器就会被自动调用,执行资源清理:
{
using theObject = {
[Symbol.dispose]() {
console.log( "全部执行完毕。" );
}
};
// 即将离开 theObject 所在的作用域,然后……
};
需要牢记的是,using 并不是 “能自动调用 [Symbol.dispose]() 的炫酷版 const”。using 声明的变量,其赋值表达式可以是任何合法的表达式,但表达式的结果必须是 null、undefined,或者一个拥有 [Symbol.dispose]() 方法的对象 —— 它的用途非常明确,也只能用于这个用途:
{
using theObject = {};
};
此外,你只能在块级作用域(块语句、函数体、静态初始化块,或 for、for … of、for await … of 循环的头部初始化器中)或模块作用域中使用 using:
using theVariable = null;
否则,如果在没有封闭作用域的地方使用 using,其声明的变量永远不会触发 [Symbol.dispose]() 方法,这样的声明也就毫无意义。
了解了这些之后,再回头看我们那个把文件敞着、像在谷仓里做事一样随意的生成器例子。如果我们用 using 而非 const 声明 generatorObject 变量,那么当变量一出作用域,[Symbol.dispose]() 方法 —— 最终也就是 return() 方法 —— 就会被自动调用:
{
function * generatorFunction() {
console.log( "打开文件。" );
try {
yield true;
yield false;
} finally {
console.log( "关闭文件。" );
}
};
using generatorObject = generatorFunction();
console.log( generatorObject.next() );
};
这感觉也太妙了!当然,如果需要,我们依旧可以手动调用 [Symbol.dispose]()(或 return())方法 —— 这个方法并不会消失 —— 但我们再也不用被迫这么做了。我们通过这种方式封装生成器对象,就能确保,当这个变量不再被需要时,相关的资源一定会被彻底清理干净。
如果我们要写一个类,希望它的实例在离开作用域时能自动完成自我清理,这也变得轻而易举:我们只需为这个类自定义一个释放器,把所有需要的清理操作都写进去就行:
class TheClass {
theFile;
constructor( theFile ) {
this.theFile = theFile;
console.log( `打开文件:${ theFile }` );
}
[Symbol.dispose]() {
console.log( `关闭文件:${ this.theFile }` );
}
};
const theFile = "./some-file";
if( theFile ) {
using fileOpener = new TheClass( theFile );
console.log( `使用 ${ fileOpener.constructor.name } 的实例执行操作,然后……` );
};
一旦这个提案正式落地 —— 而且在大多数浏览器中,它已经实现了 —— 我们就能告别繁琐的手动清理,也有望不再出现资源永远被困在 “悬空状态” 的问题。目前,该提案已经进入了标准制定的第三阶段,也就是 “建议实现阶段”—— 而且各大浏览器厂商也已经开始实现。你在本文中看到的所有特性,除了 Safari 之外,在所有主流浏览器中都能正常运行。当然,严格来说,它目前依旧只是一份提案。作为一名遵守规则的 “秩序型布偶” JavaScript 开发者,我必须提醒大家:我们在本文中讨论的部分语法,在正式纳入 ECMAScript 标准之前,仍有可能发生变化。毕竟,浏览器对这类特性(以及 Temporal 等提案)的提前实现,目的就是让开发者在实际场景中试用后,帮助进一步完善标准。
所以,别犹豫了 —— 是时候开始尝试了。你可以自己动手试试这些特性。它或许还没有正式成为标准,但没关系,就这一次,我们可以稍微 “无视” 一下规范。我们不一定要永远遵守所有规则,对吧?
只是,别在生产环境中这么做。
别太过分了,各位 “混乱的开发者” 们。
特别感谢特邀嘉宾罗恩・巴克顿 —— 不仅为本文做了审核,还作为核心推动者,打造了这份显式资源管理提案!