Clojure:无需 ClojureScript 的实时协作 Web 应用
Clojure: Realtime collaborative web apps without ClojureScript

原始链接: https://andersmurphy.com/2025/04/07/clojure-realtime-collaborative-web-apps-without-clojurescript.html

这篇博文介绍了 Datastar,一个轻量级的超媒体框架,它允许在无需客户端 JavaScript 或复杂架构的情况下构建交互式 web 应用。它利用服务器发送事件 (SSE) 每 200 毫秒从服务器向客户端流式传输整个 `

` 元素,并使用快速的 morph 算法仅更新更改的部分。尽管这种方法看似效率低下,但通过 SSE 的 Brotli 压缩实现了高压缩率,通常优于细粒度的更新。 作者认为,SSE 克服了与 WebSockets 相关的操作挑战,例如防火墙问题和连接管理问题。Datastar 允许开发者使用熟悉的 `view = f(state)` 模型,将视图保留在客户端,状态更新保留在服务器端。一个生命游戏 (Game of Life) 的例子演示了如何使用 Datastar 和一个名为 Hyperlith 的小型辅助框架构建多人应用程序。代码使用 Clojure 编写,强调简洁性,并避免了对 ClojureScript 的需求。

Hacker News 最新 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 Clojure:无需 ClojureScript 的实时协作 Web 应用 (andersmurphy.com) 4 分 bko 1 小时前 | 隐藏 | 过去 | 收藏 | 讨论 加入我们,参加 6 月 16-17 日在旧金山举办的 AI 初创公司学校! 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系我们 搜索:

原文


Last week I made a fun little multiplayer web app. I've embedded it below:

A few things to note about this web app:

  • It is streaming the whole <main> element of the page from the server to the client every 200ms over SSE (server sent events).
  • It has zero ClojureScript.
  • It has zero user written JS.
  • It uses a tiny 11.4kb (brotli compressed) hypermedia framework called Datastar.

What about performance?

Surely sending down the whole main body on every change is terrible for performance?!

event listener image

There's no canvas here. There's no SVG. There's just a 1600 cell grid, each cell with it's own on-click listener. This is an incredibly naive implementation. Partly, to show how well it performs. Your CRUD app will be fine.

Under the hood Datastar uses a very fast morph algorithm that merges the old <main> fragment with the new <main> fragment only updating what has changed.

Update: It was pointed out on the Datastar discord that there is no reason not to leverage HTML bubble up events. Honestly, I completely forgot you could do this (too much time with react I guess?). So now there's only one top level event listener. I've bumped the number of cells to 2500 to keep the volume of data over the wire roughly the same.

What about the network?

Surely sending down the whole main body on every change is terrible for bandwidth?!

Turns out streaming compression is really good. Brotli compression over SSE (server sent events) can give you a 100-230:1 compression ratio over a series of backend re-renders. The compression is so good that in my experience it's more network efficient and more performant that fine grained updates with diffing (without any of the additional complexity). This approach also avoids the additional challenges of view and session maintenance.

Isn't this just another Phoenix Live View clone?

No, it's much simpler than that. There's no connection state, server side diffing or web sockets. There's no reason the client has to connect/communicate with the same node. Effectively making it stateless.

Wait did you say SSE? Why not websockets?

Websockets sound great on paper. But, operationally they are a nightmare. I have had the misfortune of having to use them at scale (the author of Datastar had a similar experience). To list some of the challenges:

  • firewalls and proxies, blocked ports
  • unlimited connections non multiplexed (so bugs lead to ddos)
  • load balancing nightmare
  • no compression.
  • no automatic handling of disconnect/reconnect.
  • no cross site hijacking protection
  • Worse tooling (you can inspect SSE in the browser).
  • Nukes mobile battery because it hammers the duplex antenna.

You can fix some of these problems with websockets, but these fixes mostly boil down to sending more data... to send more data... to get you back to your own implementation of HTTP.

SSE on the other hand, by virtue of being regular HTTP, work out of the box with, headers, multiplexing, compression, disconnect/reconnect handling, h2/h3, etc.

If SSE is not performant enough for you then you should probably be rolling your own protocol on UDP rather than using websockets. Or wait until WebTransport is supported in Safari (any day now 😬).

Do I have to learn a new UI model?

With Datastar you can still use the same view = f (state) model that react uses. The difference is the view is on the client and f (state) stays on the server.

Show me the code!

In this example I'll be using hyperlith an experimental mini framework that builds on Datastar. It handles a few things for us that you'd normally have to manage yourself with Datastar (SSE, compression, connections, re-render rate, missed events, etc).

Note: Datastar itself is both backend language and framework agnostic.

Lets start with a minimal shim, this is for the initial page load:

(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"}])))

Then we have a hiccup render function with a separate component board-state component:

(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)]]]))

A user action:

(defn action-tap-cell [{:keys [sid db] {:strs [id]} :query-params}]
  (swap! db fill-cross (parse-long id) sid))

Some routes:

(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)}))

We pass the render-home and action-tap-cell functions into some helper functions that build handlers and we are good to go.

So how do you change this code to make it multiplayer?

That's the neat part, you don't. It already is multiplayer. The function we defined in render-home does not distinguish between users so everyone sees the same thing! If we wanted to render different views for different users, we would just generate user specific views in that function.

The function action-tap-cell does distinguish between users though. It picks a colour based on their sid.

If you've played around with Electric Clojure you might find this familiar.

Conclusion

Datastar pairs really well with Clojure and can make it trivial to implement highly interactive and collaborative web apps without ClojureScript. You should give it a go!

The full Datastar game of life source code can be found here.

Further Reading:

联系我们 contact @ memedata.com