Luna UI - JS/Moonbit のための宣言的UI. 軽量、高速、そして WebComponents First
UIライブラリのオタクとして、React に始まり、様々なUIライブラリを試してきましたが、ついに自作することにしました。
何年経っても不満は既存のライブラリで解決できないか解決困難なままなので、今こそ自分が本当に欲しいものを作ります。
- 軽量ランタイムによるポータビリティ
- Signalによる細粒度リアクティビティ
- 十分に小さいのでコンパイル時最適化が不要
- WebComponents SSR + Hydration に対応(おそらく世界で最初)
というわけで、作ったのが Luna です。ドキュメントサイトも作りました。
Moonbit+Luna自体で書いて、SSGから自作しています。
GitHub
既存のUIライブラリへの不満
- React: でかい。既存資産との互換性で動きが遅い。RSC 実装の方向性が好きになれない
- Qwik/Solid: コンパイル時展開が邪魔
- svelte/vue: SFC はエコシステム統合が難しい
- preact: 一番筋がいいと思ってるが、signal が後付。エコシステムは微妙
- 共通の問題: 相互運用性が低い。Qwik以外は SSR 速度に不満。WebComponents First なものはない
これらを踏まえて preact/signal を参考に、WebComponents SSR + Hydration を前提に設計します。
自分の Qwik + preact + svelte の経験からすると、 Qwik の最適化は過剰に複雑であり、十分にコアが軽量ならば preact の軽量コアで十分と考えます。
preact を拡張するのも考えましたが、後述する SSR Hydration の統合、そして Native SSR を見据えると、SSR最適化には垂直統合が必要なので、自作することにしました。
成果物
🌕 Luna UI - MoonBit で書かれたシグナルベースの宣言的UIライブラリです。Moonbit/JS で使えます。
後述するサンプルコードで luna と preact で同じ実装をして比較したところ、preact が 20kb なのに対して、 luna が 6.7kb でした。実際にはtreeshake でどの機能を使うかで変わるんですが、これ自体は大成功と言えそうです。
サンプルコード: tsx
Moobit JSバックエンドの生成物を jsx-runtime でラップしたので, js ビルドでは JSX がそのまま使えます。
$ npm add @luna_ui/luna
で導入できます。
import { createSignal, createMemo, render, For, Show } from '@luna_ui/luna';
function Counter() {
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
const isEven = createMemo(() => count() % 2 === 0);
return (
<div>
<h1>Luna Counter Example</h1>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<p>{() => isEven() ? 'Even' : 'Odd'}</p>
<div class="buttons">
<button onClick={() => setCount(c => c - 1)}>-</button>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
</div>
);
}
//...
const app = document.getElementById('app');
if (app) {
render(app, <App />);
}
tsx を前提としたサンプルプロジェクト
サンプルコード: Moonbit
Moonbit 版です。
fn main {
let doc = @js_dom.document()
guard doc.getElementById("app") is Some(el)
let count = @signal.signal(0)
let app = @dom.div([
p([@dom.text_dyn(fn() { "Count: " + count.get().to_string() })]),
button(
on=@events().click(fn(_) { count.update(fn(n) { n + 1 }) }),
[text("Click me")],
),
])
@dom.render(el |> @dom.DomElement::from_jsdom, app)
}
vite と moonbit という二つのビルドを繋ぎ合わせるのが複雑に感じたので、それをいい感じに統合する vite-plugin-moonbit も自作しました。
導入するとこういうことができます。
import { greet } from 'mbt:username/app';
moon build --target js は .d.tsを生成します。vite 側からはMoonbit内の名前空間からシンボルを解決して、型付きで解決します。便利じゃないですか?
エラーレポーターも統合していて、 moon build --watch の結果を vite のエラーとして表示して、ビルドエラーの内容を出力しています。これがないと型エラーで出力されない時が大変でした。(一応オプションで手動にすることもできます)
Moonbit にはまだ JSX がないので、関数DSLスタイルで記述します。
まだ、というのは今まさにそのプロポーサルが動いていて、実装中だからです。
これがあるとだいぶ自然になるでしょう。現状は関数によるDSLになっていますが、これでも書き味は悪くないつもりです。
実際に動いているデモ
実際に動いてるデモで凄さを伝えたい。
デモ: シューティングゲーム
ベンチマーク用に作ったゲームです

これは HTML Canvas ではなく、100x100 の DOM を生成していて、それを毎フレームリアルタイムに書き換えています。
DevTools で試したところ、JS負荷はほとんどなく、60FPS を維持しています。手元のスマホで試してもヌルヌルでした

React で同等のものを試作したところ、12FPS ぐらいしか出ません。
バンドルサイズも6.4kb と軽量なのを保てています。
ソースコードはこちら
デモ: TodoMVC
最近は見なくなった気がしますが、フレームワークを作った時にベンチマークとして作られる TodoMVC を作ってみました。

リアルワールドな事例としてはいいお題だと思いました。
ソースコード
デモ: Sol Framework
Next.js 相当の Sol というフレームワークを作っています。Luna と Sol で、月と太陽ですね。
これはまだ設計を詰め切ってないPoCなのですが、Cloudflare Worker にデプロイしたデモが動いています。
- トップページではサーバーから払い出された乱数が表示されています。つまり動的SSRです。
- Declared Shadow DOM で SSR して、その Hydration をしています。
これは知る限り他に実装できてるフレームワークを知りません。
昨今の React の脆弱性を踏まえて、セキュアな設計を考えて実装を進めます。使えるようになったら、また別途記事を書きます。
Astra SSG: ドキュメントのためのSSG
常々思ってたんですが、フレームワークやUIライブラリのドキュメントが、自身ではなく他のツールで書かれているのがダサいな、と思っていました。
なので、Lunaによる静的サイトジェネレータを自作しました
名前は astra です。astro と紛らわしい?後述する sol フレームワークと合わせて、 luna, sol, astra とラテン語の天体関連に用語を合わせたらこうなりました...
ドキュメント: https://luna.mizchi.workers.dev/
このドキュメントサイトは、 luna を使ったSSGを実装して、その上で実装しました。枠組みとしては、docusaurus を参考に実装していますが、 00_ のようなプレフィックスで自動でドキュメントを自動で並び替えるなどの自分が欲しい機能を追加しています。
$ npx @luna_ui/astra new mydocs
$ cd mydocs
$ npx @luna_ui/astra dev
$ npx @luna_ui/astra build # dist-docs/* を生成
ただし、まだ色々ときめうちの実装で、設定できる要素が少ないです。これは今後の課題です。
実のところ先日作った markdown コンパイラは、このために作っています。
なぜ MoonBit で実装したか
昔話ですが、クライアントとサーバーで同じテンプレートを記述して、クライアントでロジックを引き継ぐという「二重テンプレート問題」が古来よりありました。
この問題に、現実的に初めて対処できたのが Next.js です。SSRとは単にサーバーでHTMLを生成することではありません。JSの稼働部をクライアントに注入することで初めて実現します。
自分の Next.js と Qwik の経験からすると、ハイドレーションと SSR 時の最適化を行うなら、クライアントランタイムとサーバー SSR の垂直統合を行う必要があります。これは非常に大変な作業で、Node.js とブラウザという、同じ言語による冪等性の保証という手段で、なんとか実現されています。Node.js以外でほぼ SSR + Hydration が実現されないのは、これが理由です。
でも複数の言語にクロスコンパイルできる Moonbitなら、異なるバックエンドだとしてもこれを実現できるのでは?
MoonBit は複数のターゲットにコンパイルできます:
MoonBit → JavaScript (ブラウザ)
→ Native (SSR サーバー)
→ Wasm-GC (WasmEdge)
Moonbit の強みは、Hydrationのような複雑な設計に耐えうる言語としての表現力、そして JS Backend でほぼJSと等価な軽量なJSが生成できる事です。そして wasm-gc バックエンドも軽量で、高速な native バックエンドがあります。手元のベンチマークでは、ネイティブビルドで約 5 倍高速になることがわかっています。
ただし、現状は SSR を行う Sol Framework では、サーバーの大部分を Hono へのバインディングとして実装しているので、JSのみでしか動きません。一旦は Cloudflare Workers の JS バックエンドで動かすのをゴールとしています。
ぜひ使ってみてください
Moonbit の可能性を表現できたんじゃないでしょうか。
Astra と Sol の完成度はイマイチですが、 Luna 本体はかなりテストしていて、おそらく結構使えるはずです。
自分は次にこういうテーマに取り組みたいと思っています。
- WebComponents と loader のサンプルを揃える
- リアルタイムでプレビューできるエディターを作る
- radix-ui 相当のヘッドレスUIフレームワークを作る
- Cloudflare Worker 上で、WebComponents Registry を作る
- Native Server でSSRできるようにする
- Wasm 対応して、wasmedge で動く
是非、使ってみてください。意見が欲しいです。
(とはいえ個人で作るには限界があるので、ちゃんとしたガバナンスを作ったり、可能ならスポンサーしてくださる会社を探したいところですね)
Discussion