PostHog 前端 kea-disposables 使用指南:定时器与事件监听的自动清理与后台自动暂停
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
本篇技术指南以 PostHog 仓库内.agents/skills/using-kea-disposables/SKILL.md为核心,讲解前端 kea 逻辑(logic)中资源清理的最佳实践。凡是你在 kea logic 里注册setInterval、setTimeout、window.addEventListener、MediaQueryList.addEventListener或任何需要显式销毁的订阅(WebSocket、EventSource、ResizeObserver、IntersectionObserver 等),都应通过全局注册的disposablesPlugin注入的cache.disposables来管理。读完本文你将掌握cache.disposables.add(...)/dispose(key)的完整用法、pauseOnPageHidden后台自动暂停机制、isDisposed的异步续期防护,以及如何把代码库中现存的「裸cache.<thing>+beforeUnmount」反模式安全迁移过来。
为什么需要 disposables:取代裸cache存句柄 +beforeUnmount清理
PostHog 前端基于 kea 状态管理框架组织业务逻辑。在过去,一个常见的写法是在afterMount里创建定时器,把句柄塞进cache,再在beforeUnmount里手动clearInterval。这种模式存在三个痛点:
- 清理代码重复:每个逻辑都要写两段配套代码,容易遗漏;
- 后台标签页空耗资源:即使页面被隐藏,轮询与动画定时器仍在运行,白白消耗 CPU 与网络;
- 生命周期边界易错:手动清理时机、条件判断(
if (cache.xxx))都容易写错。
仓库通过一个本地 kea 插件解决了这些问题:disposablesPlugin(定义于 frontend/src/kea-disposables.ts,类型为KeaPlugin,通过events.afterMount与events.beforeUnmount挂钩逻辑生命周期),并在 frontend/src/initKea.ts 中被全局注册(plugins数组中的disposablesPlugin)。因此,仓库内每一个 kea logic 的cache上都有cache.disposables,无需任何额外初始化。
插件的行为可以概括为:
- 自动清理:你在
setup函数中返回的 cleanup 函数,会在逻辑卸载(unmount)时自动执行; - 后台自动暂停:默认情况下,页面隐藏时所有 disposables 的 cleanup 会被执行(暂停),页面重新可见时 setup 会重新运行(恢复),大幅降低后台标签页的 CPU 与网络开销;
- 安全执行:setup 与 cleanup 中的异常都会被捕获并打印
[KEA] Disposable setup/cleanup failed in logic <path>错误,不会污染其他逻辑(见 frontend/src/kea-disposables.ts 的safeCleanup/safeSetup)。
不要在清理场景中使用beforeUnmount。插件会在逻辑卸载时自动运行你在 setup 里返回的 cleanup,并在标签页可见性变化时重新执行 setup/cleanup。如果你发现某个beforeUnmount的唯一职责就是clearInterval/clearTimeout/removeEventListener清理前面注册过的资源,请把这些资源改由cache.disposables.add(...)注册并删除该beforeUnmount。beforeUnmount只应保留给「非自管资源」的收尾工作,例如刷新状态、持久化到 localStorage、调用第三方库的dispose()。
核心模式:setup 返回 cleanup
cache.disposables.add的签名与useEffect的 cleanup 模式极其相似——传入的 setup 立即执行,且必须返回一个清理函数:
cache.disposables.add( setup, // () => () => void — 立即执行;MUST return a cleanup function key?, // string — 以相同 key 重复添加会先销毁前一个 options?, // { pauseOnPageHidden?: boolean } — 默认 true:隐藏时执行 cleanup,可见时重新执行 setup )规范示例位于 frontend/src/layout/navigation/noEventsBannerLogic.ts,一个无 key 的setInterval轮询器:
afterMount(({ actions, cache }) => { cache.disposables.add(() => { const pollTimer = window.setInterval(() => { actions.loadCurrentTeam() }, POLL_INTERVAL_MS) return () => clearInterval(pollTimer) }) }),这段代码创建了一个每 30 秒(POLL_INTERVAL_MS = 30_000,见 noEventsBannerLogic.ts)轮询一次loadCurrentTeam的定时器,并返回清理函数。插件会在逻辑卸载时调用clearInterval,无需任何beforeUnmount。
从底层实现看(frontend/src/kea-disposables.ts),add做了几件关键的事:
- 未传 key 时自动生成
__auto_<n>递增 key; options默认合并为{ pauseOnPageHidden: true };- 若传入 key 且该 key 已存在,先
safeCleanup旧的 entry 再注册新的(这是「防抖替换」语义的实现基础); - 若页面当前处于隐藏状态且该 disposable 未选择退出暂停,则只注册 setup、不立即执行(cleanup 置为 no-op),等页面可见时再由
resumeAllDisposables补跑 setup——这样即使在隐藏期间从异步回调里add()新的轮询,也不会产生一个应当被暂停却活跃运行的定时器。
何时选择 key
- 不传 key——即「一次性发射(fire-and-forget)」,只在逻辑卸载时清理。适用于在
afterMount中注册的一次性监听器。 - 传入命名 key——当出现以下需求时必须使用:
- 之后要调用
cache.disposables.dispose(key)提前停止; - 同一 setup 可能被重复添加,每次调用都应替换前一个(防抖/防重复场景)。
- 之后要调用
dispose的实现(frontend/src/kea-disposables.ts)会在注册表中查找 key,找到则执行 cleanup 并从注册表删除,返回true;找不到或已卸载返回false。
场景一:hover 暂停/恢复的键控轮询
frontend/src/lib/components/LiveUserCount/liveUserCountLogic.ts 展示了「hover 时启动、离开时销毁」的键控 interval,以及暂停/恢复流的完整模式:
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') },注意这里add与dispose成对出现:状态切换时用dispose(key)精确销毁某个资源,而无需卸载整个逻辑。
场景二:setTimeout 防抖(spam-replacement)
用户连续触发showSeekIndicator时,旧定时器必须被替换而非叠加。frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts 利用「同 key 先销毁旧 entry」的特性实现了 600ms 的防抖隐藏:
showSeekIndicator: () => { // Same key auto-disposes the previous timer when spamming cache.disposables.add(() => { const timerId = setTimeout(() => { actions.hideSeekIndicator() }, 600) return () => clearTimeout(timerId) }, 'seekIndicatorTimer') },场景三:一个afterMount内注册多个 keyed 窗口监听器
frontend/src/toolbar/bar/toolbarLogic.ts 在挂载时一次性注册多个全局监听器,每个都有自己的 key:
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')场景四:events(afterMount)中的 MediaQueryList 监听
kea 的events构建器同样可用,frontend/src/layout/navigation-3000/themeLogic.ts(该文件是lib/logic/themeLogic的 re-export,实际逻辑实现位于frontend/src/lib/logic/themeLogic.ts)中的暗色模式监听如下:
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') }, })),pauseOnPageHidden:后台标签页自动暂停
默认值true适用于几乎一切场景——轮询、动画 ticker、hover 定时器。页面隐藏时这些资源会被暂停,恢复可见时重新执行 setup,从而大幅降低后台标签页的 CPU 与网络消耗。底层实现由 frontend/src/kea-disposables.ts 的pauseAllDisposables/resumeAllDisposables完成:全局维护一个allManagers集合,页面visibilitychange到hidden时对所有pauseOnPageHidden !== false的 entry 执行 cleanup;回到可见时对它们重新执行 setup 并更新 cleanup 引用(若 setup 失败则替换为 no-op cleanup,防止执行过期的旧 cleanup)。全局监听器是惰性挂载的——第一个 manager 注册时attachGlobalVisibilityListener,最后一个 manager 卸载时detachGlobalVisibilityListener(见 frontend/src/kea-disposables.ts)。
仅在监听器必须于页面隐藏时持续触发时才退出暂停(传{ pauseOnPageHidden: false }):
- 监听可能真实发生在隐藏标签页中的事件:
storage(来自其他标签页的写入)、online/offline、message(来自 web worker、service worker 或其他 window); visibilitychange监听器本身——它的意义就是观察隐藏/显示;- 任何用户期望在隐藏时继续运行的功能。
一个反直觉但正确的判断:popstate只能由用户操作触发,隐藏标签页不会产生事件,所以默认的隐藏时暂停对它毫无影响(见上方 toolbar 示例中的注释)。
pauseOnPageHidden: false的典型场景是visibilitychange监听器本身,frontend/src/scenes/product-tours/productTourLogic.ts 中的工具栏模态框可见性处理:
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') },提前停止:dispose(key)的适用场景
cache.disposables.dispose('key')在不卸载逻辑的前提下拆毁某一个具体资源。适合的状态迁移场景包括:
- 暂停/恢复轮询器(如
liveUserCountLogic的pauseStream/resumeStream); - 鼠标移出时停止 hover 专属 ticker;
- 关闭模态框时销毁其作用域内的监听器(如上面
closeToolbarModal的做法)。
卸载之后的调用:add/dispose是安全的,isDisposed用于异步续期
逻辑卸载后,add()与dispose()会成为no-op,因此可以放心地直接调用,不要写cache.disposables?.dispose(...)或if (!cache.disposables) return——manager 在挂载后永不为空。
但一个异步续期通常需要跳过的远不止 disposable 本身:对已拆毁逻辑派发 action 或读取values是另一类 bug。此时应分支判断cache.disposables.isDisposed:
// The stream teardown aborts this request, so the catch can resume after the unmount if (cache.disposables.isDisposed) { return } actions.connectionErrored(reason)isDisposed在逻辑开始最终卸载时被置为true,且先于所有注册的 cleanup 执行——这样 cleanup 唤醒的异步续期(例如被中止的请求在finally中恢复)会看到一个惰性 manager,而不会在即将消亡的逻辑上重新注册资源。这一点在finally中最关键:卸载中止的请求会 reject,finally随后会对着一个已不存在的逻辑执行。
底层行为(frontend/src/kea-disposables.ts):beforeUnmount中只有当!typedLogic.isMounted()且 manager 尚未 disposed 时才执行完整清理——先把 manager 从全局可见性跟踪中移除、置isDisposed = true、遍历 registry 执行所有 cleanup 并清空,最后按需摘除全局 visibilitychange 监听。manager 本身保留在 cache 上而非置空,因为比卸载更长寿的异步代码仍会触达cache.disposables.dispose(...),若此处为 null 会在续期代码里抛出 TypeError,而不是安静地什么都不做。
重新挂载的陷阱
同一逻辑再次挂载时,cache 上会放一个全新的 manager。于是上一世遗留的异步续期可能触达cache.disposables并发现一个存活的 manager,此时isDisposed读到false,若续期里 dispose 了共享 key,会拆毁新一世的资源。因此:当续期需要的关键数据,务必在逻辑存活时捕获(如 frontend/src/scenes/notebooks/Notebook/notebookKernelInfoLogic.ts 在afterMount里把getContext()的结果捕获到闭包外层)。
不要只用isDisposed守卫定时器回调
如果定时器回调还要读取values,仅用isDisposed守卫是不够的:该标志只在真正卸载时翻转,而替换 kea context(Storybook 每次挂载 story 都会做)会把逻辑从 store 中丢弃但不会触发卸载,于是 cleanup 永不执行。正确做法是拿getContext()与资源创建时所在的 context 做比较。notebookKernelInfoLogic.ts的实现即为范本:
const mountedIn = getContext() const isLive = (): boolean => getContext() === mountedIn注意这里把getContext()捕获在 setup 闭包之外而非 setup 内部——因为插件在模块级持有每个 manager,页面可见时会重跑 setup,若在 setup 内读 context,重跑时的 context 与自身恒等,起不到守卫作用。回调中if (!isLive()) return即可安全跳过已脱离 store 的逻辑。
反模式迁移:把裸cache+beforeUnmount转换为 disposables
「裸cache.<thing>+beforeUnmount清理」正是本插件要取代的模式,遇到即可转换。
Before(frontend/src/lib/components/HedgehogMode/hedgehogModeLogic.ts):
afterMount(({ actions, cache }) => { cache.syncInterval = setInterval(() => actions.syncFromState(), 1000) }), beforeUnmount(({ cache }) => { if (cache.syncInterval) { clearInterval(cache.syncInterval) cache.syncInterval = null } }),After——beforeUnmount整块消失,改由 setup 返回的 cleanup 承担卸载清理:
afterMount(({ actions, cache }) => { cache.disposables.add(() => { const id = setInterval(() => actions.syncFromState(), 1000) return () => clearInterval(id) }, 'syncInterval') }),仓库中还有两个已知的开放转换目标:
- frontend/src/scenes/welcome/welcomeDialogLogic.ts(约 L325-L345)——手动把
window.addEventListener('storage', ...)的 handler 塞进cache.storageHandler,可迁移为 keyed disposable; - products/signals/frontend/inbox/inboxSceneLogic.ts(约 L260-L267)——裸
setInterval在每次状态变更时手动清除,可迁移为同 key 自动替换或显式dispose(key)。
迁移要点回顾:转换后逻辑不再需要手写beforeUnmount清理自管资源;add的 key 让「重复注册自动替换」与「提前销毁」开箱即用;默认开启的后台暂停还会顺带消灭隐藏标签页的空转开销。
小结:何时用 disposables,何时保留 beforeUnmount
| 场景 | 做法 |
|---|---|
setInterval/setTimeout(afterMount、listener、subscription 内) | cache.disposables.add(setup, key?, options?) |
window/document/MediaQueryList.addEventListener | cache.disposables.add(...),返回对应removeEventListenercleanup |
| 需要显式销毁的订阅(WebSocket、EventSource、ResizeObserver、IntersectionObserver 等) | cache.disposables.add(...) |
| 状态变化需提前结束已注册资源 | cache.disposables.dispose(key) |
页面隐藏时仍需运行的监听(storage、online/offline、message、visibilitychange自身等) | cache.disposables.add(setup, key, { pauseOnPageHidden: false }) |
异步续期(finally等)在卸载后要继续执行 | 先判cache.disposables.isDisposed,跳过对已拆毁逻辑的 action/values 访问 |
非自管资源的收尾(flush 状态、localStorage 持久化、第三方dispose()) | 保留在beforeUnmount |
这套机制贯穿 PostHog 前端大量场景——顶部导航轮询(noEventsBannerLogic)、实时人数(LiveUserCount)、录制播放器防抖(sessionRecordingPlayerLogic)、工具栏全局监听(toolbarLogic)、产品引导弹窗(productTourLogic)与暗色模式(themeLogic)。在新增任何需要销毁的资源时,优先查阅这些示例文件并遵循同一模式,即可保证内存安全、后台省电且逻辑可读。
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考