portrait

anders murphy

Clojure:无需 ClojureScript 实现实时协作 Web 应用

2025 年 4 月 7 日

上周我做了一个有趣的多人 Web 应用。我已经把它嵌入在下面:

关于这个 Web 应用,需要注意以下几点:

性能如何?

每次更改都发送整个主体的做法,性能肯定很糟糕吧?!

event listener image

这里没有 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 的作者也有类似的经历)。以下是一些挑战:

你可以通过一些方法修复 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-homeaction-tap-cell 函数传递到一些辅助函数中来构建 handlers,一切就绪。

那么如何更改此代码以使其成为多人游戏?

这正是妙处所在,你无需更改。它已经是多人游戏了。我们在 render-home 中定义的函数不会区分用户,因此每个人看到的内容都相同!如果我们想为不同的用户渲染不同的视图,我们只需在该函数中生成特定于用户的视图。

但是,函数 action-tap-cell 确实区分了用户。它根据用户的 sid 选择颜色。

如果你玩过 Electric Clojure,你可能会觉得这很熟悉。

结论

Datastar 与 Clojure 配合得非常好,并且可以使实现高度交互和协作的 Web 应用变得非常简单,而无需 ClojureScript。你应该试一试!

完整的 Datastar 生命游戏源代码可以在这里找到

进一步阅读:

© 2015-2025 Anders Murphy