取消按钮挡不住时间旅行
一句话总结
取消不再需要的工作,与阻止迟到的结果改变界面,是两项不同的职责。要把 effect 的设置与清理设计成一对。
为什么需要它
在发出鲸鱼搜索之前,先对猫的请求调用了 abort。现在安全了吗?即使网络请求停下了,已经完成的响应的后续处理也可能还在,所使用的异步工具也可能不支持取消信号。如果仅凭“对方会遵守取消”这样的期待,就把界面的正确性托付出去,问题只是转移到了别的地方。
本实验的传输模型会留下已收到取消信号的记录。勾选复选框后,在那之后也可以送达响应。这并不是假装成真实的 HTTP 实现,而是为了能够区分“取消”与“忽略结果”,特意加以控制的 Promise 模型。即使不等着网络环境碰巧变慢,也能重复出现同样的顺序。
工作原理
当为请求 A 设置 effect 时,A 专属的有效期就开始了。在因依赖变化而设置请求 B 的 effect 之前,会先运行 A 的清理。组件消失时也需要清理。在清理中要回答两个问题。
- 这个 effect 所产生的结果,今后还可以反映到界面上吗?
- 这个 effect 启动的外部工作,还有必要继续执行吗?
第一个问题可以用 active 标志或请求标识符来处理。第二个问题则用 AbortController 和支持取消信号的工具来处理。无论选择哪种实现,请求之间都不能错误地共享状态。如果旧请求的清理取消了最新的请求,用户每进行一次新搜索,就会丢失一次响应。
只堵住成功路径是不够的。如果 A 很晚才失败,而 B 已经成功,A 的 catch 就可能在 B 的界面上弹出错误。反过来,在 B 最新的失败之后,即使 A 的成功到达了,最新的错误说明也不能消失。成功与失败都必须遵循同一套有效性策略。
| 清理方式 | 防止过去的响应覆盖 | 取消不必要的工作 |
|---|---|---|
| 只忽略结果 | 可以防止 | 需要另行处理 |
| 只请求取消 | 依赖工具的取消保证 | 在支持的工具中可以做到 |
| 有效性检查加取消 | 明确处理了两项职责 | 明确处理了两项职责 |
再来想想空搜索词。如果用户清空输入后提交,新的界面就是 idle。如果在此之前发出的响应到达并恢复了结果,就违背了用户最后的意图。再次提交相同的词时,只要提交序号变了,也必须能够重试。
active 要放在哪里
如果把所有请求共享的一个变量叫作 active,名字虽然对,生命周期却可能是错的。清理 A 时把它改成 false,启动 B 时又把同一个变量改成 true,那么迟到的 A 读到的也是 true。等于 B 的启动抹掉了 A 的结束标记。需要让每次 effect 设置都捕获自己的变量,或者采用把响应带来的请求序号与当前请求序号进行比较的方式。
来亲手追踪这个差别。A 的 then 和 catch 读取的是在 A 的设置中创建的标志。A 的清理只把那个标志改成 false。B 的设置会另外创建一个标志,B 的回调读取的是它。即使两个回调使用同名的变量,它们也并不是同一个存储空间。要看的是声明位置和闭包所捕获的对象,而不是代码中的变量名。
在请求序号方式中也有陷阱。如果不让当前序号递增,而只比较相同的搜索词字符串,就无法区分同一个词被提交了两次。第一个请求的旧结果,可能会被当成第二次重试的结果进来。检查时,不仅要包含不同词的逆序响应,还要包含连续提交同一个词的情况。
在确认清理的效果时,不要一味地等待响应完成。取消信号即使在请求没有结束时也可以确认。故意让过去的响应稍后完成,以观察对界面的保护,另外读取 aborted 来确认资源的清理。把观察对象分开,也能更准确地说明缺少的是哪一项职责。
在现场相遇的样子
在开发环境的 StrictMode 中,可以观察到 effect 的设置、清理和重新设置。如果第一次设置的工作没有被清理,它就会和第二次设置一起留下来。与其为了避免这一点而去掉清理检查,不如确认在反复设置与清理之后,是不是只有必要的工作还活着。不能假定生产构建中的运行次数与开发检查时相同。
没有内存泄漏警告,并不意味着已经清理干净。即使界面消失之后看不到状态更新,请求和事件订阅也可能仍然活着。检查时,不仅要观察 DOM 结果,还要观察所传入 signal 的 aborted 状态。用户关闭窗口这一事件,必须与资源使用的结束联系起来。
要亲自确认的内容
用忽略取消的模式,分别把成功和失败的响应逆序送达。然后在遵守取消的模式下,卸载搜索界面。分别确认结果是否正确,以及取消信号是否已传递,并找出只实现了其中一项的错误答案会在哪项检查中失败。
原理请参考 React 对 effect 清理的说明。本实验的手动传输模型和事件编号是为学习而设计的。