coverPiccoverPic

顶层 setInterval 卡死 Vitepress SSR 的 CI 构建

前言

最近在维护我的自娱自乐组件库 Pixelium Design 时,遇到了一个非常折磨人的 CI 问题:GitHub Actions 的 Pages 部署任务,每次都会卡在 “Build with VitePress” 这一步,迟迟不结束。

日志的最后明明已经输出了:

  1. build complete in 48.34s.

看起来构建已经完成,任务却一直停在 loading 状态,直到超时。

因为没太多精力去死磕底层原理,这篇就记录一下排查的过程和最终结论,给遇到类似情况的自己或他人提个醒。

Pixelium Design

问题现象

Pixelium Design 的文档站点由 VitePress 构建,通过 GitHub Actions 部署到 Pages,工作流监听 docs 分支的推送。

某次调整分支之后把改动推上去,发现部署任务卡住了:

  1. Build Lib 步骤正常通过。
  2. Build with VitePress 步骤输出 build complete 后一直不结束。
  3. 直到 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
  1. await build()
  2. console.log(process._getActiveHandles())
  3. console.log(process._getActiveRequests())

结果发现 build 完成后进程的事件循环里还挂着活跃句柄,让事件循环永远无法清空,进程自然就不退出了。

二分法排查

完整项目必现,站点的框架项目没有问题,问题应该出现在具体组件文档里面,那就用二分法逐步排除,直到复现:

最后发现,只要在页面里放一个 <px-typewriter> 组件,build 就会挂起。

typewriter 组件的源码,问题一目了然:

ts
  1. watch(
  2. [() => props.caret, () => props.blinkSpeed],
  3. () => {
  4. caretOn.value = true
  5. if (props.caret) {
  6. startCaret() // → setInterval(...)
  7. } else {
  8. stopCaret()
  9. }
  10. },
  11. { immediate: true } // ← 关键
  12. )
  13. const startCaret = () => {
  14. stopCaret()
  15. if (!props.caret) return
  16. caretTimer = setInterval(() => {
  17. caretOn.value = !caretOn.value
  18. }, props.blinkSpeed)
  19. }

typewriter 的光标闪烁依赖一个 setInterval,而启动它的 watchimmediate: true ——也就是说,在组件 setup 阶段就会立即执行。

问题的原因

问题就出在这里:

VitePress 在构建文档时,会以 SSR 的方式渲染每个页面。SSR 渲染组件时,组件的 setup 会被真实执行,于是:

  1. 渲染到 typewriter demo 页面 → setup 执行 → immediate watch 触发 → setInterval 注册。
  2. 这个 setInterval 是 Node 环境里的定时器,构建明明完成了,但这个定时器还活着,每隔 500ms 触发一次。
  3. Node 发现事件循环里还有活跃的定时器 → 进程永远不退出。

SSR 构建一直在跑这个定时器的代码,然后成功地把整个 CI 卡住了。

修复很简单,给定时器的启动加一个浏览器环境判断:

js
  1. const startCaret = () => {
  2. stopCaret()
  3. if (!props.caret) return
  4. if (!inBrowser()) return // SSR 环境不启动定时器
  5. caretTimer = setInterval(() => {
  6. caretOn.value = !caretOn.value
  7. }, props.blinkSpeed)
  8. }

完整构建验证:104 秒构建完成,进程正常退出。

反思

这个问题的根源,是组件副作用没有考虑 SSR 环境。

SSR 构建会真实执行组件的 setup,而 onMountedonUnmounted 这类生命周期在服务端不会运行。immediate: truewatch 会在 setup 阶段立即触发,如果回调里启动了 setInterval 这类持续运行的资源,定时器就会残留到构建结束之后,导致 Node 进程无法退出。定时器、事件监听、长连接这类代码,应该放进 onMounted,或者至少用环境判断包裹。

排查过程也有两点可以复用:进程卡住时,先用 process._getActiveHandles() 确认是否有残留句柄,比对着日志猜原因高效得多;再通过最小化复现把问题从完整项目逐步缩小到单个组件,能很快定位到出问题的地方。

0 条评论未登录用户
Ctrl or + Enter 评论
© 2023-2025 LittleRangiferTarandus, All rights reserved.
🌸 Run