shadow-cljs-vite-plugin v0.0.7–v0.0.9: CLJS + React 混合 HMR 与稳定性改进

shadow-cljs-vite-pluginv0.0.6 之后又经历了几个版本的迭代,主要解决了 CLJS 与 React/TypeScript 混合项目的 HMR 问题。

CLJS + React/TypeScript 混合 HMR

之前,纯 ClojureScript 项目的 HMR 通过 shadow-cljs 的原生 eval 工作正常。但如果 TypeScript/React 代码通过 virtual:shadow-cljs/app 导入 CLJS 函数,热更新后 ES module 的导出值不会刷新——编辑 .cljs 文件后,shadow-cljs 会 eval 新代码,但 React 仍然渲染旧值。

现在这个问题已经修复,导出值会自动刷新,React 也会自动重新渲染:

import { greet } from "virtual:shadow-cljs/app";

export default function App() {
  return <p>{greet("World")}</p>;
}

不需要事件监听、hooks 或其他样板代码。

实现这个功能比预想中复杂。shadow-cljs 和 Vite 使用各自独立的 WebSocket 连接,两者之间的时序无法保证,不能简单地在 stdout 出现 "Build completed" 时发送 Vite HMR 更新(eval 可能还没完成)。此外,import.meta.hot.invalidate() 对虚拟模块会静默失败(虚拟模块没有磁盘文件,服务端无法解析 invalidation)。

最终方案:客户端在收到构建完成信号后轮询全局命名空间,检测 shadow-cljs eval 是否已实际修改全局变量,刷新 ES module 的 live bindings,再通知服务端触发 React Fast Refresh。确定性执行,没有固定延迟,没有 monkey-patching。

修复:Ctrl+C 后 shadow-cljs JVM 残留

按下 Ctrl+C 后,shadow-cljs JVM 进程有时不会退出,导致下次 vite dev 时报 "server already running" 错误。原因是 pnpm 在 Ctrl+C 后很快向 Vite 发送 SIGTERM,Vite 在来不及向 JVM 发送 SIGKILL 之前就被终止了。修复方式是在信号处理器中使用同步 SIGKILL,不用 await,不会被中断。

新增示例

  • cljs-react — CLJS 业务逻辑 + React UI,支持自动 HMR
  • cljs-ts-mixed — CLJS + TypeScript 混合项目,支持 HMR
  • cljs-reagent — 纯 Reagent (ClojureScript) 应用,支持 HMR
  • cljs-cloudflare-worker — CLJS 同时用于浏览器 (React) 和 Cloudflare Worker (SSR + JSON API),使用 @cloudflare/vite-plugin,参考了我的博客的架构

其他改进

  • Vite 的文件监听现在会忽略 shadow-cljs 的输出目录,避免不必要的 HMR 处理

发现 Vite Bug 并提交 PR

开发过程中发现 import.meta.hot.invalidate() 对虚拟模块静默失败。客户端发送了 invalidation,服务端也收到了,但没有任何效果——因为浏览器端的 URL(/@id/__x00__virtual:...)和服务端的模块 URL(virtual:...)不匹配。已提交修复:vitejs/vite#22098

合并后,插件的 HMR 实现可以大幅简化——当前的 client→server→client 往返将被替换为一次 invalidate() 调用,由 Vite 内部处理传播。

试试看

插件已在 blog.c4605.com 的 Cloudflare Workers 生产环境稳定运行。

npm install shadow-cljs-vite-plugin

欢迎在 GitHub 上反馈和贡献。