关于 using-kea-disposables
面向仓库开发者的技能,介绍如何用 kea-disposables 管理 Kea logic 创建的计时器、监听器和订阅等资源。内容涵盖自动清理、页面标签隐藏时暂停工作,以及提前释放资源或采用其他清理方式的场景。
主要功能
- 01卸载时自动清理
- 02隐藏标签页时暂停后台工作
- 03按键替换资源
- 04按键提前释放
- 05异步清理指南
适用场景
- 01在 Kea logic 中添加计时器
- 02管理事件监听器
- 03清理订阅资源
- 04检查 beforeUnmount 清理逻辑
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ posthog/ posthog --skill using-kea-disposables安装后在 Claude Code 中输入/using-kea-disposables确认已加载。
详细介绍
使用 kea disposables
本仓库中的每个 kea logic 都通过 kea-disposables 包中的 disposablesPlugin 注入了 cache.disposables,该插件在 frontend/src/initKea.ts 和 frontend/src/toolbar/index.tsx 中全局注册。每当你创建需要显式清理的资源时,都可以使用它——该插件会在卸载时执行清理,并在标签页隐藏时自动暂停后台工作。
该包在本仓库之外维护,因此对插件本身的修改应提交到那里,而不是这里。类型也来自该包:import type { DisposablesManager } from 'kea-disposables'。
不要为了清理而添加 beforeUnmount。 当 logic 卸载时,插件会自动运行你从 setup 返回的清理函数(并在标签页可见性变化前后重新运行 setup/cleanup)。如果你发现自己写的 beforeUnmount 唯一的工作就是 clearInterval / clearTimeout / removeEventListener 同一个 logic 中先前注册的某个东西,请改为通过 cache.disposables.add(...) 注册该资源,并删除 beforeUnmount。beforeUnmount 只应保留给那些_不是_你控制的资源的清理工作(例如刷新状态、持久化到 localStorage、调用第三方的 dispose())。
适用场景
- 在
afterMount、监听器或订阅中添加setInterval或setTimeout - 添加
window.addEventListener、document.addEventListener或MediaQueryList.addEventListener - 添加任何需要显式清理的订阅(WebSocket、EventSource、ResizeObserver、IntersectionObserver 等)
- 审查或编辑带有裸
cache.<thing>以及配套beforeUnmount清理的 logic——将其转换 - 某个状态变更应提前清理先前注册的定时器或监听器
模式
cache.disposables.add(
setup, // () => () => void — runs immediately; MUST return a cleanup function
key?, // string — re-adding with the same key disposes the previous one first
options?, // { pauseOnPageHidden?: boolean } — default true: cleanup runs on hide, setup re-runs on show
)
典型示例(frontend/src/layout/navigation/noEventsBannerLogic.ts:14-21):
afterMount(({ actions, cache }) => {
cache.disposables.add(() => {
const pollTimer = window.setInterval(() => {
actions.loadCurrentTeam()
}, POLL_INTERVAL_MS)
return () => clearInterval(pollTimer)
})
}),
对于生命周期与 logic 完全相同的资源,disposables 构建器可以替代该 afterMount:
import { disposables } from 'kea-disposables'
disposables(({ actions }) => ({
pollTimer: () => {
const id = window.setInterval(() => actions.loadCurrentTeam(), POLL_INTERVAL_MS)
return () => clearInterval(id)
},
})),
对象键是普通的 disposable 键,因此 dispose('pollTimer') 仍可提前停止它。
传入 { setup, options } 而不是裸函数来设置 pauseOnPageHidden。
任何带条件的、或从监听器重新触发的资源,仍然需要使用 cache.disposables.add(...)。
选择键
- 无键——即发即忘;仅在卸载时清理。适用于在
afterMount中注册的一次性监听器。 - 命名键——在以下情况需要:
- 你之后会调用
cache.disposables.dispose(key)来提前停止它 - 同一个 setup 可能被重复添加,且每次调用都应替换前一个(防重复替换)
- 你之后会调用
pauseOnPageHidden
默认值(true)对几乎所有情况都是正确的——轮询、动画计时器、悬停计时器。后台标签页停止工作并在获得焦点时恢复,这能大幅降低 CPU 和网络开销。
仅当监听器必须在页面隐藏时仍持续触发时,才选择退出({ pauseOnPageHidden: false }):
- 在标签页隐藏时确实可能触发的事件的监听器——例如
storage(来自另一个标签页的写入)、online/offline、message(来自 web worker、service worker 或其他窗口) visibilitychange监听器本身——其全部意义就是观察隐藏/显示- 用户期望在标签页隐藏时继续运行的任何东西
注意:popstate 无法在隐藏的标签页上触发(它由用户驱动),因此在隐藏时暂停是没问题的——参见下面的工具栏示例。
调用 dispose() 提前停止
cache.disposables.dispose('key') 会清理某个特定资源,而无需卸载 logic。当状态转换应结束该资源时使用它——暂停/恢复轮询器、在 mouseleave 时停止仅悬停的计时器、关闭模态框作用域的监听器。
卸载后调用 manager
一旦 logic 已卸载,add() 和 dispose() 就是空操作,因此直接调用即可。
不要写 cache.disposables?.dispose(...) 或 if (!cache.disposables) return;挂载后 manager 永远不会是 null。
不过,异步延续通常不仅要跳过 disposable,因为在已销毁的 logic 上派发 action 或读取 values 本身就是 bug。
为此要根据 isDisposed 分支:
// The stream teardown aborts this request, so the catch can resume after the unmount
if (cache.disposables.isDisposed) {
return
}
actions.connectionErrored(reason)
这一点在 finally 中最为重要。
卸载时中止的请求会 reject,随后 finally 会在一个已不存在的 logic 上运行。
关于会再次挂载的 logic,有一个注意事项。
下一次挂载会在 cache 上放置一个全新的 manager,因此上一次生命遗留的延续可能访问到 cache.disposables 并发现一个仍然存活的 manager。
此时 isDisposed 读取为 false,而 dispose 一个共享 key 会拆掉新生命的资源。
当这很重要时,要在 logic 存活期间捕获延续所需的内容。
如果定时器回调会读取 values,不要仅用 isDisposed 来守护它。
该标志只在卸载时改变,而替换 kea context(storybook 在每次 story 挂载时都会这样做)会在不卸载 logic 的情况下将其从 store 中移除,因此在已不再拥有 store 的 logic 上 isDisposed 仍为 false。
当旧 context 关闭时,插件确实会运行所有清理,因此资源本身会消失。
但已经触发过的回调,或者其清理无法阻止的回调(正在进行的请求 resolve),仍需要自己的守护:将 getContext() 与资源建立时所在的 context 进行比较 —— 参见 frontend/src/scenes/notebooks/Notebook/notebookKernelInfoLogic.ts。
代码库中的示例
未命名的 setInterval 轮询器 —— 参见 The pattern 中的经典示例(frontend/src/layout/navigation/noEventsBannerLogic.ts:14-21)。
带 key 的 interval,在 hover-end / pause 时 dispose() —— frontend/src/lib/components/LiveUserCount/liveUserCountLogic.ts:94-118
setIsHovering: ({ isHovering }) => {
if (isHovering) {
actions.setNow(new Date())
cache.disposables.add(() => {
const intervalId = setInterval(() => actions.setNow(new Date()), 500)
return () => clearInterval(intervalId)
}, 'nowInterval')
} else {
cache.disposables.dispose('nowInterval')
}
},
pauseStream: () => {
cache.disposables.dispose('statsInterval')
},
resumeStream: () => {
actions.pollStats()
cache.disposables.add(() => {
const intervalId = setInterval(() => actions.pollStats(), props.pollIntervalMs ?? 30000)
return () => clearInterval(intervalId)
}, 'statsInterval')
},
带 key 的 setTimeout,用于 spam-replacement —— frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts:1837-1846
showSeekIndicator: () => {
// Same key auto-disposes the previous timer when spamming
cache.disposables.add(() => {
const timerId = setTimeout(() => actions.hideSeekIndicator(), 600)
return () => clearTimeout(timerId)
}, 'seekIndicatorTimer')
},
在一个 afterMount 中多个带 key 的 window 监听器 —— frontend/src/toolbar/bar/toolbarLogic.ts:655-688
cache.disposables.add(() => {
const clickListener = (e: MouseEvent): void => {
/* ... */
}
window.addEventListener('mousedown', clickListener)
return () => window.removeEventListener('mousedown', clickListener)
}, 'clickListener')
// popstate only fires on user-initiated back/forward, so a hidden tab won't
// generate events — pausing on hide (the default) is fine here. Opt out
// only if you must observe popstates while the tab is in the background.
cache.disposables.add(() => {
const popstateHandler = (): void => actions.maybeSendNavigationMessage()
window.addEventListener('popstate', popstateHandler)
return () => window.removeEventListener('popstate', popstateHandler)
}, 'popstateListener')
带 pauseOnPageHidden: false 的 visibilitychange 监听器 —— frontend/src/scenes/product-tours/productTourLogic.ts:647-663
openToolbarModal: () => {
cache.disposables.add(
() => {
const handler = (): void => {
if (document.visibilityState === 'hidden') {
actions.handleToolbarTabVisibility()
}
}
document.addEventListener('visibilitychange', handler)
return () => document.removeEventListener('visibilitychange', handler)
},
'toolbarModalVisibility',
{ pauseOnPageHidden: false }
)
},
closeToolbarModal: () => {
cache.disposables.dispose('toolbarModalVisibility')
},
events(afterMount) 中的 MediaQueryList 监听器 —— frontend/src/layout/navigation-3000/themeLogic.ts:108-118
events(({ cache, actions }) => ({
afterMount() {
cache.disposables.add(() => {
const prefersColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)')
const onPrefersColorSchemeChange = (e: MediaQueryListEvent): void =>
actions.syncDarkModePreference(e.matches)
prefersColorSchemeMedia.addEventListener('change', onPrefersColorSchemeChange)
return () => prefersColorSchemeMedia.removeEventListener('change', onPrefersColorSchemeChange)
}, 'prefersColorSchemeListener')
},
})),
需要转换的反模式
裸 cache.<thing> + beforeUnmount 清理就是本插件所取代的模式。见到这些就转换。
转换前(frontend/src/lib/components/HedgehogMode/hedgehogModeLogic.ts:205-215):
afterMount(({ actions, cache }) => {
cache.syncInterval = setInterval(() => actions.syncFromState(), 1000)
}),
beforeUnmount(({ cache }) => {
if (cache.syncInterval) {
clearInterval(cache.syncInterval)
cache.syncInterval = null
}
}),
转换后 —— 注意 beforeUnmount 块已完全消失;插件在卸载时运行的是从 setup 返回的清理函数:
afterMount(({ actions, cache }) => {
cache.disposables.add(() => {
const id = setInterval(() => actions.syncFromState(), 1000)
return () => clearInterval(id)
}, 'syncInterval')
}),
其他待转换目标:
frontend/src/scenes/welcome/welcomeDialogLogic.ts:325-345—— 裸window.addEventListener('storage', ...),并手动存放cache.storageHandlerproducts/signals/frontend/inbox/inboxSceneLogic.ts:260-267—— 裸setInterval,在每次状态变化时手动清除