顶层 setInterval 卡死 Vitepress SSR 的 CI 构建
前言
最近在维护我的自娱自乐组件库 Pixelium Design 时,遇到了一个非常折磨人的 CI 问题:GitHub Actions 的 Pages 部署任务,每次都会卡在 “Build with VitePress” 这一步,迟迟不结束。
日志的最后明明已经输出了:
- build complete in 48.34s.
看起来构建已经完成,任务却一直停在 loading 状态,直到超时。
因为没太多精力去死磕底层原理,这篇就记录一下排查的过程和最终结论,给遇到类似情况的自己或他人提个醒。
问题现象
Pixelium Design 的文档站点由 VitePress 构建,通过 GitHub Actions 部署到 Pages,工作流监听 docs 分支的推送。
某次调整分支之后把改动推上去,发现部署任务卡住了:
Build Lib步骤正常通过。Build with VitePress步骤输出build complete后一直不结束。- 直到 Action 超时)。
这个问题本地也能稳定复现:跑 vitepress build,输出 build complete in 154s 之后进程就一直挂着不退出,只能手动 Ctrl+C。
排查过程
在伟大的 AI 大人帮助下,我开始了排查问题。
猜测一:vitepress 2.0 alpha 的 bug?
我们用的 vitepress 是 2.0.0-alpha.12,一个还在快速迭代的 alpha 版本。第一反应就是它自己有问题,build 完成不退出,听起来太像上游 bug 了。
于是我搭了一个最小化 vitepress 项目(只有一个 index.md),跑 build,但是 6 秒构建完成,进程正常退出。
排除 vitepress 本身。
猜测二:markdown 插件、主题、组件库?
接着把仓库的 markdown 插件配置(demo-preview、katex)、自定义主题、以及组件库 @pixelium/web-vue 一步步加回最小项目:
- 加回 markdown 插件:正常退出。
- 加回主题和组件库:正常退出。
排除配置层面的因素。
用探针看看进程里到底有什么
在 vitepress build 的进程里用 process._getActiveHandles() 和 process._getActiveRequests() 打了点:
js- await build()
- console.log(process._getActiveHandles())
- console.log(process._getActiveRequests())
结果发现 build 完成后进程的事件循环里还挂着活跃句柄,让事件循环永远无法清空,进程自然就不退出了。
二分法排查
完整项目必现,站点的框架项目没有问题,问题应该出现在具体组件文档里面,那就用二分法逐步排除,直到复现:
最后发现,只要在页面里放一个 <px-typewriter> 组件,build 就会挂起。
看 typewriter 组件的源码,问题一目了然:
ts- watch(
- [() => props.caret, () => props.blinkSpeed],
- () => {
- caretOn.value = true
- if (props.caret) {
- startCaret() // → setInterval(...)
- } else {
- stopCaret()
- }
- },
- { immediate: true } // ← 关键
- )
- const startCaret = () => {
- stopCaret()
- if (!props.caret) return
- caretTimer = setInterval(() => {
- caretOn.value = !caretOn.value
- }, props.blinkSpeed)
- }
typewriter 的光标闪烁依赖一个 setInterval,而启动它的 watch 是 immediate: true ——也就是说,在组件 setup 阶段就会立即执行。
问题的原因
问题就出在这里:
VitePress 在构建文档时,会以 SSR 的方式渲染每个页面。SSR 渲染组件时,组件的 setup 会被真实执行,于是:
- 渲染到 typewriter demo 页面 →
setup执行 → immediate watch 触发 →setInterval注册。 - 这个
setInterval是 Node 环境里的定时器,构建明明完成了,但这个定时器还活着,每隔 500ms 触发一次。 - Node 发现事件循环里还有活跃的定时器 → 进程永远不退出。
SSR 构建一直在跑这个定时器的代码,然后成功地把整个 CI 卡住了。
修复很简单,给定时器的启动加一个浏览器环境判断:
js- const startCaret = () => {
- stopCaret()
- if (!props.caret) return
- if (!inBrowser()) return // SSR 环境不启动定时器
- caretTimer = setInterval(() => {
- caretOn.value = !caretOn.value
- }, props.blinkSpeed)
- }
完整构建验证:104 秒构建完成,进程正常退出。
反思
这个问题的根源,是组件副作用没有考虑 SSR 环境。
SSR 构建会真实执行组件的 setup,而 onMounted、onUnmounted 这类生命周期在服务端不会运行。immediate: true 的 watch 会在 setup 阶段立即触发,如果回调里启动了 setInterval 这类持续运行的资源,定时器就会残留到构建结束之后,导致 Node 进程无法退出。定时器、事件监听、长连接这类代码,应该放进 onMounted,或者至少用环境判断包裹。
排查过程也有两点可以复用:进程卡住时,先用 process._getActiveHandles() 确认是否有残留句柄,比对着日志猜原因高效得多;再通过最小化复现把问题从完整项目逐步缩小到单个组件,能很快定位到出问题的地方。