首页 / 文章 / 快照值还是实时值?决定延迟执行的 JavaScript 回调会看到什么

快照值还是实时值?决定延迟执行的 JavaScript 回调会看到什么

了解为何闭包能保留对绑定变量的访问而非其副本,以及如何在定时器、React effects、监听器和异步代码中选择快照数据与实时数据。

3701 词

按钮会对上一次渲染产生的数据进行处理。计时器记录的数值在设定时间点时并不存在。通过循环创建的每个处理函数似乎都属于最后一个元素。响应缓慢会导致屏幕被用户已经离开过的页面的结果覆盖。这类错误通常被归类为“闭包问题”,但这样的标签很少能帮助人们解决它们。本文用一个更精确的模型来替代这一标签:闭包保留的是变量绑定的访问权,而非值的副本;而每一项延迟执行的任务都需要明确决定是查看快照还是实时值。

每个闭包错误背后的规律

闭包并非不可预测。JavaScript函数会保留其创建时的词法环境引用,每当函数体中提到外部变量时,就会在代码运行时的环境中查找该变量名。关键在于“访问”机制——函数并不会收到定义时所有可见内容的冻结副本,而是继续使用原有的绑定关系;如果这些绑定关系之后被重新赋值,函数将会读取新的值。

犯这种错误有两种相反的情况。第一种是期望得到一个快照,而代码实际上创建的是指向会变化的变量的实时链接。第二种在渲染框架中很常见,即期望得到最新值,但实际上回调是在一个绑定内容再也不会改变的旧作用域中创建的。这两种错误都源于同一个疏漏:没有人决定延迟执行的任务应该属于哪个时刻。

一旦明确了这一决策,这种行为就不再显得神秘了。回调所做的正是程序要求它做的。只是程序所说的与开发者的本意有所不同而已。

访问,而非照片

教材中的闭包示例中,内部函数在外部函数返回后仍继续使用该外部变量。这虽然说明了作用域会持续存在,但却容易让人产生一种错误的直觉:即内部函数保存的是其创建时的值。稍作改动就能揭示其中的差异——此处,日志记录器是在message的值为"Starting"时创建的,而在工厂函数返回之前该变量已被重新赋值。

function createLogger() {
  let message = "Starting";

  const logMessage = () => {
    console.log(message);
  };
  message = "Finished";
  return logMessage;
}
const logger = createLogger();
logger();

调用logger()会输出Finished。箭头函数从未持有过字符串"Starting";它持有的是message的绑定对象,而当该函数执行时,这个绑定对象指向的已经是另一条字符串了。

这是个特性,而非缺陷。计数器、工厂函数、记忆化缓存以及模块模式中的私有可变状态,都依赖于闭包能够看到对其自身变量的更新。问题只会在有人误以为回调函数绑定的是早期值而非变量本身时才会出现。

原始类型容易引发这种误解,因为字符串和数字看起来是自包含的,函数似乎只需保留“那个字符串”就足够了。但实际上函数体中存储的是名称而非值,而名称是在代码执行时才被解析的。如果你想更深入地了解这种查找是如何在嵌套作用域中进行的,JavaScript的作用域链究竟是如何解析变量的一文会详细解释相关机制。

接下来的实用习惯是:每当后续要执行回调且该回调需要引用外部数据时,只需问自己一个问题——应当使用创建回调时的值,还是使用回调执行时的值?答案会告诉你是应该截取快照、读取当前引用,还是将值作为参数传入。即便不考虑这个问题,代码仍然是正确的 JavaScript,但其行为属于偶然结果。

循环错误与标识绑定有关

最著名的闭包错误涉及使用 var 声明的 for 循环,这类循环会设置多个定时器。每个回调似乎都只与自身的迭代次数相关联。

for (var index = 0; index < 3; index++) {
  setTimeout(() => {
    console.log(index);
  }, 100);
}

它会打印三次3。由于var的声明具有函数作用域,整个循环只共享一个index绑定。三个回调都引用同一个绑定,而当定时器触发时,循环已经结束,index的值仍为3。

若将声明改为let,结果就会不同,因为使用let时语言会为for循环的每次迭代创建一个新的绑定,并将当前值复制到其中。

for (let index = 0; index < 3; index++) {
  setTimeout(() => {
    console.log(index);
  }, 100);
}

这个版本会打印0、1和2,因为现在每个回调引用的是不同的index。

常见的总结是“let 能解决闭包问题”,但这掩盖了真正的要点。在两种循环中,闭包的行为是完全一致的。在第一种循环中,三个回调函数被刻意赋予了一个共享的、会变化的变量;而在第二种循环中,每个回调函数都有自己独立的变量。真正发生变化的是变量的绑定方式,而非闭包的行为本身。

这一区别很重要,因为即使不使用 var,同样的错误依然存在。如果在循环外部用 let 声明一个可变变量,在每次迭代时更新它,然后在回调函数中读取该变量,每个回调函数仍然只会看到最终的值。如果代码仍让多个回调函数指向同一个可变变量,更换关键字并不能解决问题。

更好的做法是先判断这些回调函数是否需要共享状态。如果它们都需要观察同一个会变化的值,那么使用单一绑定即可。但如果每个回调都需要记住与其特定迭代相关的数据,那就需要为每个回调设置独立的绑定或传递显式参数。在代码中看到多个回调时,很容易误以为每个回调都拥有其命名的变量;实际上词法作用域仅决定了变量的查找位置,而非谁是这些变量的所有者。

当创建与执行之间存在时间间隔时

在函数创建与执行之间存在时间差时,闭包带来的意外情况会更加严重:可能是定时器触发、网络请求往返、用户操作,或是任务在队列中等待。函数从外部读取的任何数据都可能在这段时间内发生变化。以一个依赖模块级项目编号的保存函数为例。

let activeProjectId = 42;

async function saveChanges(changes) {
  await saveProject(activeProjectId, changes);
}

activeProjectId = 84;

是否正确取决于执行时机。参数会在函数被调用时进行求值,因此如果 saveChanges 在重新赋值之前执行,activeProjectId 会以同步方式被读取,此时传递给 saveProject 的数值就是 42,尽管后续的调用会处于等待状态。但任何在稍后时间点读取 activeProjectId 的回调函数,看到的都是该变量此时所持有的值,可能是 84,进而会将数据保存到完全不同的项目中。

这就是为什么“闭包捕获值”这种说法是一种危险的简写。在某些代码中,值会在暂停之前被复制到参数中;而在其他代码中,则是在暂停之后读取共享的绑定变量。两个函数看似几乎相同,但实际上遵循着不同的时间规则。

用户界面中充斥着这样的模式。想象一下为某条记录打开的确认对话框。用户切换到另一条记录,然后点击确认。如果处理程序读取的是“当前记录”变量,它就会删除屏幕上显示的这条记录,而非原本打开对话框时对应的记录。闭包功能本身没有问题;问题出在产品设计上,因为该操作本应保持其初始的上下文。

解决办法并非简单地“复制变量”。首先需要确定哪个时刻才拥有对该操作的掌控权:

  • 具有破坏性的操作通常属于其被发起的那一刻,应使用当时捕获的ID。
  • 实时状态指示器在每次更新时都需要最新的数值。
  • 计划稍后重试的操作则可以结合两者:使用原始操作ID,以及触发重试时有效的认证令牌。

闭包让 JavaScript 开发者必须明确考虑时间因素。重要的问题不仅在于回调函数读取的是哪个变量,还在于工作流程打算使用该变量的哪个版本。

即使内容发生变化,对象也能保持引用稳定

当捕获的绑定指向一个对象时,又会出现新的混淆点。将绑定设为 const 只能冻结绑定本身,无法冻结其他内容。如果该对象是可变的,延迟执行的回调仍然可以通过共享引用观察到所有更改。

const settings = {
  retries: 2,
};

setTimeout(() => {
  console.log(settings.retries);
}, 100);

settings.retries = 5;

计时器输出的是 5。settings 常量始终指向同一个对象,但在回调执行之前该对象的 retries 属性已经被修改了。

浏览器控制台可能会增加混淆。你在开始某些异步操作之前记录了一个对象,之后在开发者工具中展开该对象时,会看到在记录语句执行之后被修改过的字段。有些控制台在展开对象时会显示其实时状态,而非记录时的状态,这使得日志看起来仿佛是向前推移的。在调试时,使用 JSON.stringify(obj) 或结构化克隆可以快速获得真实的对象快照。

要在代码中生成真正的快照,仅创建一个新变量是不够的,而复制深度则取决于需要保护的数据结构。通过展开运算或Object.assign进行的浅层复制虽然会分离顶层属性,但嵌套的对象和数组仍会被共享。深度克隆虽能分离更多内容,但成本较高,还可能丢失类原型和方法,并导致本应共享的引用被重复创建。

通常更简洁的做法是根本不进行克隆,而是直接提取操作所需的小型不可变值。

const retryLimit = settings.retries;

setTimeout(() => {
  console.log(retryLimit);
}, 100);

该回调现在读取的是一个永远不会变化的绑定值,同样重要的是,代码明确了计时器所依赖的历史上下文部分。

对于那些涉及大型可变对象的延迟执行任务,应保持警惕。请求上下文、配置容器、组件状态对象以及共享缓存都是常见例子。稍后访问这些对象的回调函数会隐含地依赖于其间发生的所有修改。将结构明确的参数传递给延迟执行任务,能形成更清晰的契约关系。

React则表现出相反的问题

在React中,闭包错误通常指向另一个方向:回调函数并非读取了过新的值,而是持续读取了过旧的值。

每次渲染函数组件时都会进行一次新的函数调用,为 props、state 和派生值创建各自的局部绑定。在渲染过程中创建的回调函数会绑定到该次渲染的变量上。当 state 发生变化时,React 会再次调用该组件并创建新的绑定,但之前渲染中仍然存在的回调函数仍会引用旧的变量。

其表现症状很常见:

  • 在某次渲染中创建的定时器会一直记录过时的数值。
  • 一次性注册的事件监听器会持续使用第一次渲染时的 props 值。
  • 依赖列表不完整的效应函数会不断调用那些基于过时状态生成的函数。

这种情况被称为“过期闭包”,但实际上该闭包并未出错。它始终忠实于最初的渲染环境,而当 React 再次渲染时,语言机制也不会使其更新。

这似乎与之前的例子相矛盾,因为在那些例子中闭包能够顺利地感知到状态更新。两者的区别依然在于绑定身份。在日志记录器的例子中,有一个绑定被重新赋值了,闭包因此能看到新的值。而 React 并不会重新分配旧的绑定;它在每次渲染时都会创建全新的环境。旧的回调函数仍附着在旧的环境上,而该环境的值永远不会改变。

从这种角度来看,解决方案其实源于回调函数应有的功能:

  • 那些依赖于当前值的状态更新可以使用函数式写法,比如 setCount(c => c + 1),这样 React 就会提供最新的状态值。
  • 那些需要最新值的订阅机制可以在其依赖项发生变化时被重新创建,或者从专门维护的 ref 中读取数据。
  • 本应基于生成它的渲染结果来执行的回调函数,其现有写法可能已经正确。
  • 问题仍与之前相同:这种延迟执行的行为应当使用历史状态还是当前状态?许多 React 错误都是因为错误地通过修改依赖数组来回答这个问题,而非基于该功能应有的行为逻辑进行思考。较新版本的 React 还提供了 useEffectEvent,以便在无需重新运行效应的情况下读取其中的最新值;使用 useEffectEvent 替代 latest-value ref 文章介绍了这种模式。

    依赖数组描述的是现实,而非个人偏好

    解决闭包过时的常见方法是通过调整效果的依赖数组,直到其行为符合预期。某个值会因为代码检查工具的警告而被加入数组,又因为效果运行过于频繁而被移除,最终由于该效果“只需运行一次”,数组会变为空。

    这种做法把数组当作频率调节旋钮,但实际上它只是声明了效果的闭包会读取哪些渲染值。

    如果某个效果使用了周围渲染中的变量,那么无论该变量是否出现在数组中,它都是该效果闭包的一部分。不将其列入数组并不会消除依赖关系,只是确保效果会一直使用最后一次设置该变量的那个渲染版本。

    包含所有依赖项可能会引发不同问题:该效应会以远超预期的频率销毁并重新创建订阅、重启计时器或再次发送请求。这很容易被理解为代码检查工具过于严格。但实际上,这往往表明该效应承担了多项功能,或者它所依赖的某个对象或函数在每次渲染时都被无端重新创建。

    常见的解决方法包括:

    • 让回调函数保持稳定,仅在其输入发生变化时才改变
    • 将一个功能复杂的效应拆分为多个职责更明确的效应
    • 将辅助函数移入效应内部,使其不再成为外部依赖项
    • 使用函数式状态更新方式,而非直接读取状态
    • 思考该逻辑是否真的需要以效应的形式存在

    目标并非强迫 React 按照期望的频率执行效应函数,而是为该效应提供一个生命周期与其负责处理的行为相匹配的闭包。当某个效应需要获取最新值,但又不想在每次值变化时都重新创建该效应时,应为其提供获取这些值的明确方式,比如使用 ref 或效应事件。如果某个值变化时该效应需要重新启动,那么这个值就应该被放入数组中。而如果该效应实际上并不需要某个值,那就应移除对该值的读取方式,而非依赖关系本身。

    依赖问题其实是设计上的反馈信号。当代码为闭包指定了一种生命周期,而功能需求却需要另一种生命周期时,问题就会出现。

    事件监听器会比创建它们的上下文存在更久

    监听器通过设计在注册与执行之间创造了时间差。你只需绑定一次处理函数,每当事件触发时它就会运行,即便此时周围的变量早已发生变化。

    在普通的 JavaScript 中,读取模块级变量的监听器能够看到该变量的最新值。而在组件框架中,在早期渲染阶段绑定的监听器会保留那次渲染时的绑定状态。无论哪种情况,这种关联都很容易被忽视,因为处理函数只有在用户后续有所操作时才会运行。

    清理操作带来了另一个问题。如果每次渲染都添加新的监听器而不移除之前的监听器,那么多个闭包最终会响应同一个事件,而每个闭包持有的都是不同的状态版本。这样一来,一次点击就可能导致基于应用历史中不同时间点的多种结果出现。可见的后果可能是重复更新、旧值重新显现,或是处理程序在组件已卸载后仍在运行。所有这些情况的根本原因都是监听器的生命周期从未与其所依赖的状态的生命周期保持一致。

    可靠的监听器代码会明确体现所有权:

    • 保留你注册的函数的确切引用,因为removeEventListener需要相同的引用。
  • 当监听器所依赖的行为发生变化时,应重新创建该监听器;或者通过 ref 或 store 等专用通道让其读取当前值。
  • 当监听器的所属对象——无论是组件、模块还是功能——被移除时,也应同时删除该监听器。
  • 这些都不是形式上的操作。注册回调函数是为了在未来的事件与当前可用的环境之间建立联系。如果这种联系需要终止,就必须由代码来结束它。

    异步响应会将旧的状态信息带到新页面

    网络请求常常会导致代价高昂的“旧上下文”错误。当用户正在查看某个搜索查询、项目或路由时,请求开始执行;而在请求完成之前,用户可能已经切换到了其他页面。当响应到达时,其回调函数会使用最初捕获的上下文信息来修改共享状态。

    有时这种历史上下文是完全正确的。针对项目42提交的请求,即便当前活跃项目已变为84,也应仍与项目42相关联。危险在于让该结果去更新已切换到项目84的界面。记住最初的请求正是闭包在发挥作用;而假设已完成响应仍会被需要,才是逻辑出错的地方。

    这就是为什么闭包推理与异步所有权需要结合使用的原因。回调虽然可以保存完全正确的历史数据,却无权更新其目标界面。常见的保护措施包括:

    • 在应用结果之前先比对请求ID或序列号
    • 使用AbortController信号,在用户切换页面时取消相关操作
    • 检查当前路由或选择项是否仍与初始请求匹配
  • 将结果存储在对应资源的键下,而非单个“当前”槽位中
  • 简单地将回调函数改写为读取最新的活跃项目反而可能使情况更糟:项目42的响应会被存储在项目84下。读取当前状态并非万能解决方案。应保持请求的身份完整,并在写入结果之前确认目标端仍需要该结果。

    要将两种上下文分开。操作上下文属于请求所针对的数据,而界面上下文则属于用户当前正在查看的内容。只有当两者仍一致时才应更新屏幕。如果没有这种分离,回调函数就会像穿越时间一样,将早先的有效信息传递到已经发生变化的界面中。

    快照访问与实时访问的选择

    一旦团队明确了所需的关系,这些漏洞大多就能迎刃而解。

    快照意味着延迟执行的任务会使用任务创建时的数据。这适用于事务ID、选中的记录ID、提交的表单值、审计上下文,以及那些必须保持原有意图的命令。实现方式可以是将值作为参数传递、构建不可变的数据包,或仅复制操作所需的必要字段。

    实时访问意味着延迟执行的任务会使用其运行时的最新数据。这适用于连接状态、最新的配置、事件处理程序中的某些当前UI状态,以及可变的协调值。实现方式可以是通过共享绑定、引用、存储访问器,或其他明确标示为“当前”的数据源来实现。

    总体而言,两者并无绝对更安全之分。当代码采用其中一种方式而开发者却假设另一种方式时,错误就会出现。

    两种习惯能让这种选择在代码中清晰体现。首先,避免使用会随意访问作用域内所有内容的闭包;范围过大的闭包会掩盖其依赖关系,使得读者无法判断哪些依赖需要保持不变,哪些可以随时间更新。其次,尽量使用小型函数并限制其参数范围,这样可以减少需要考虑时序语义的变量数量。出于同样的原因,这两点都能提升异步操作的可靠性:即减少与时间相关的隐含关系。

    对于任何稍后执行的回调函数,可参考以下快速检查清单:

    • 它读取了哪些外部变量?
    • 对于每一个变量,它应该看到创建时的值还是执行时的值?
    • 其中是否有内容可能在执行期间发生变化的可变对象?
  • 谁拥有这个回调函数,它应在何时停止运行?
  • 如果它将结果写入某个地方,那个存储位置仍属于相同的上下文吗?
  • 总结

    JavaScript中的闭包是确定性的。它们遵循词法作用域,保留绑定关系,并在可访问的函数需要时维持环境状态。让人觉得它们“纠缠不清”的原因,是人们将变量视为在时间上始终具有唯一确定值的实体。

    上述每种情况其实都是同一个问题的不同表现:这个回调函数应属于哪个时刻?循环可能会为所有回调提供相同的绑定对象;定时器可能在对象被修改后仍读取它;React回调可能会与之前的渲染状态保持关联;监听器可能比其原本监控的状态存在更久;异步响应则可能将历史上的正确意图传递到已经发生变化的界面中。

    经验丰富的开发者仍会遇到这些错误,因为现代应用程序不断推迟任务的执行。超时、Promise链、DOM事件、订阅机制、重新渲染、任务队列以及HTTP响应,都会在定义函数与实际执行之间增加间隔,而这个间隔越长,周围的环境变化就越大。解决之道并非死记闭包的另一种定义方式,而是明确时间和所有权:有意识地选择快照访问或实时访问,仅保留任务所需的歷史数据,为当前状态指定唯一的来源,将每个回调的生命周期与其所属对象绑定,并防止过时的任务写入其已不再控制的范围。当这些选择在代码中清晰可见时,闭包就不会再让你感到意外,因为它们终于与你预期的执行时刻紧密相连了。