Clojure:无需 ClojureScript 实现实时协作 Web 应用
anders murphy
Clojure:无需 ClojureScript 实现实时协作 Web 应用
2025 年 4 月 7 日
上周我做了一个有趣的多人 Web 应用。我已经把它嵌入在下面:
关于这个 Web 应用,需要注意以下几点:
- 它通过 SSE (server sent events) 每 200 毫秒将页面的整个
<main>
元素从服务器流式传输到客户端。 - 它没有使用任何 ClojureScript。
- 它没有使用任何用户编写的 JS。
- 它使用了一个名为 Datastar 的小型 11.4kb(经过 Brotli 压缩)的超媒体框架。
性能如何?
每次更改都发送整个主体的做法,性能肯定很糟糕吧?!
这里没有 canvas,也没有 SVG。只有一个 1600 个单元格的网格,每个单元格都有自己的 onclick 事件监听器。这是一个非常简单粗暴的实现。部分原因是为了展示它的性能表现。你的 CRUD 应用将会表现良好。
在底层,Datastar 使用了一种非常快速的 morph 算法,它将旧的 <main>
片段与新的 <main>
片段合并,仅更新已更改的内容。
更新:在 Datastar 的 Discord 上有人指出,没有理由不利用 HTML 冒泡事件。老实说,我完全忘记了你可以这样做(可能是在 React 上花费了太多时间?)。所以现在只有一个顶级事件监听器。我已经将单元格的数量增加到 2500 个,以保持在线传输的数据量大致相同。
网络怎么样?
每次更改都发送整个主体的做法,带宽肯定很糟糕吧?!
事实证明,流式压缩效果非常好。在服务器端重新渲染的一系列操作中,SSE (server sent events) 上的 Brotli 压缩可以提供 100-230:1 的压缩率。压缩效果非常好,以至于根据我的经验,它比使用差异进行细粒度更新更有效率,性能也更好(而且没有任何额外的复杂性)。这种方法还避免了视图和会话维护的额外挑战。
这不就是另一个 Phoenix Live View 克隆吗?
不,它比那简单得多。没有连接状态、服务器端差异或 WebSockets。客户端无需连接/与同一节点通信。有效地使其成为无状态的。
等等,你说的是 SSE?为什么不是 WebSockets?
WebSockets 在理论上听起来很棒。但是,在实际操作中,它们却是一场噩梦。我很不幸地必须大规模地使用它们(Datastar 的作者也有类似的经历)。以下是一些挑战:
- 防火墙和代理,端口被阻止
- 无限制的非多路复用连接(因此 Bug 会导致 DDoS)
- 负载均衡的噩梦
- 没有压缩。
- 无法自动处理断开/重新连接。
- 没有跨站劫持保护
- 更糟糕的工具(你可以在浏览器中检查 SSE)。
- 耗尽移动设备的电池,因为它会频繁使用双工天线。
你可以通过一些方法修复 WebSockets 的这些问题,但这些修复方法大多归结为发送更多数据...发送更多数据...让你回到你自己的 HTTP 实现。
另一方面,SSE 凭借其作为常规 HTTP 的特性,可以开箱即用地与 headers、多路复用、压缩、断开/重新连接处理、h2/h3 等一起工作。
如果 SSE 的性能不足以满足你的需求,那么你可能应该在 UDP 上滚动你自己的协议,而不是使用 WebSockets。或者等到 WebTransport 在 Safari 中得到支持(可能很快了 😬)。
我必须学习新的 UI 模型吗?
使用 Datastar,你仍然可以使用 React 使用的相同的 view = f (state)
模型。不同之处在于 view
在客户端,而 f (state)
留在服务器上。
展示一下代码!
在这个例子中,我将使用 hyperlith,这是一个构建在 Datastar 之上的实验性迷你框架。它为我们处理了一些通常需要你自己使用 Datastar 管理的事情(SSE、压缩、连接、重新渲染速率、丢失的事件等)。
注意:Datastar 本身与后端语言和框架无关。
让我们从一个最小的 shim 开始,这是用于初始页面加载的:
(def **default-shim-handler**
(h/shim-handler
(h/html
[:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}]
[:title nil "Game of Life"]
[:meta {:content "Conway's Game of Life" :name "description"}])))
然后我们有一个带有单独的 component board-state
component 的 Hiccup 渲染函数:
(def **board-state**
(h/cache
(fn [db]
(map-indexed
(fn [id color-class]
(h/html
[:div.tile
{:class color-class
:id id}]))
(:board @db)))))
(defn **render-home** [{:keys [db] :as _req}]
(h/html
[:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}]
[:main#morph.main
[:h1 "Game of Life (multiplayer)"]
[:div
[:div.board {:data-on-click (format "@post('/tap?id=%s')" id)}
(board-state db)]]]))
一个用户操作:
(defn **action-tap-cell** [{:keys [sid db] {:strs [id]} :query-params}]
(swap! db fill-cross (parse-long id) sid))
一些路由:
(def **router**
(h/router
{[:get (css :path)] (css :handler)
[:get "/"] default-shim-handler
[:post "/"] (h/render-handler #'render-home)
[:post "/tap"] (h/action-handler #'action-tap-cell)}))
我们将 render-home
和 action-tap-cell
函数传递到一些辅助函数中来构建 handlers,一切就绪。
那么如何更改此代码以使其成为多人游戏?
这正是妙处所在,你无需更改。它已经是多人游戏了。我们在 render-home
中定义的函数不会区分用户,因此每个人看到的内容都相同!如果我们想为不同的用户渲染不同的视图,我们只需在该函数中生成特定于用户的视图。
但是,函数 action-tap-cell
确实区分了用户。它根据用户的 sid
选择颜色。
如果你玩过 Electric Clojure,你可能会觉得这很熟悉。
结论
Datastar 与 Clojure 配合得非常好,并且可以使实现高度交互和协作的 Web 应用变得非常简单,而无需 ClojureScript。你应该试一试!
完整的 Datastar 生命游戏源代码可以在这里找到。
进一步阅读:
© 2015-2025 Anders Murphy