ElementaryUIでSwiftでWebフロントエンドを書いてみる
ElementaryUIという、SwiftUIのような宣言型でWebのフロントエンドをSwiftWasmで書けるフレームワークが公開されていたので試してみた。
- https://forums.swift.org/t/introducing-elementaryui-swift-in-the-browser/83952
- https://elementary.codes/
元々ElementaryというHTMLのレンダリング用のライブラリのレイヤーは前から公開されていて、 それだけだとバックエンドでHTML生成して返すぐらいしか出来なかったけど、それをベースにSwiftUIとかReactみたいな宣言的に 状態を記述して動的なUIを組み立てられるようになっている。.wasmファイルにコンパイルすることで、Webブラウザ上で直接動作させられる。
SwiftUIとの違い(そもそもSwiftUIと互換があるものを作っているわけではない)というか特徴は以下。
- トップレベルに定義されたHTMLタグ名の型でDSLを書くことでViewを組み立てる
@State,@BindingなどはSwiftUI由来のアノテーションがマクロで作成されている(ので展開後のコードも見える)- ただしEmbedded対応のためにObservation frameworkを使ってなくて、
@Observableと同等のことをやるために@Reactiveとして自作している
- ただしEmbedded対応のためにObservation frameworkを使ってなくて、
- 見た目を変えるためのmodifierは無いのでCSSが必要
- TailwindなどのCSSフレームワークがあると便利
- SwiftUI風のアニメーションのサポートがある
- View用のstructの
bodyプロパティにViewを定義していくので書き心地は似ている - コードを変更して保存するたびにバックグラウンドで自動でビルドが走ってlocalhostの画面も自動でリロードされて快適
- ElementaryUIが用意しているViteのプラグイン利用時
- インクリメンタルビルドになるのでまぁまぁ早い)
サンプルコード
公式サイトより拝借
@View
struct LoveCounter {
@State var count = 1
var body: some View {
p { String(repeating: "❤️", count: count) }
if count < 10 {
button { "More Love" }
.onClick { count += 1 }
} else {
p { "Enough love for you!" }
button { "Less Love" }
.onClick { count = 1 }
}
}
}
こんな感じで動的なUIが動く。このコードを見たら分かるとおり、 実質HTMLを書いているものの書き心地はだいぶSwiftUIに似たものになっている。
Todoアプリ
こういう時の定番、Todoアプリを作ってみた。永続化してないのでメモリ上でリストを保持しているだけ。
Loading tweet...
Tweet by @ainame
- リポジトリ https://github.com/ainame/elementary-ui-todo-app
- デモ https://ainame.github.io/elementary-ui-todo-app
決まった書き方みたいなのはないのでモバイルアプリでよくやるMVVMみたいな書き方で書くことに。
@Reactive をつけると@Observableみたいな挙動になり、ViewModelが簡単に書ける。
MVVMにせずにView内で@StateだけでModel-Viewみたいにすることも可能。
import Reactivity
@Reactive
class TodoViewModel {
private(set) var items: [TodoItem] = []
private var lastId = 0
func onClickAddNewButton() {
lastId += 1
items.append(
TodoItem(
id: lastId,
title: "Title",
description: "Description",
deadline: "2026-01-15"
)
)
}
func onClickDeleteButton(at index: Int) {
items.remove(at: index)
}
func onClickDoneButton(with item: TodoItem, at index: Int) {
items[index] = item
}
}
Viewには @View をつけるとbodyが @HTMLBuilder というResultBuilderになるのでDSLが書けるようになる。
基本はほぼHTMLだけど、if文が使えるし、key単位でトラックすることでアニメーションとかを使ったリストが書けるようにForEachもサポートされている。
import ElementaryUI
@View
struct ContentView {
let viewModel = TodoViewModel()
var body: some View {
div(.class("max-w-3xl mx-auto p-8")) {
button(
.class(
"bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-bold px-6 py-3 rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 mb-8 w-full"
)
) {
"➕ Add New Todo"
}
.onClick {
viewModel.onClickAddNewButton()
}
div(.class("space-y-4")) {
ForEach(viewModel.items.enumerated(), key: { String($0.element.id) }) { item in
TodoItemView(
item: item.element,
onClickDeleteButton: { viewModel.onClickDeleteButton(at: item.offset) },
onClickDoneButton: { viewModel.onClickDoneButton(with: $0, at: item.offset) },
)
}
}
}
.animateContainerLayout()
.animation(.smooth, value: viewModel.items.count)
}
}
Wasmファイルのサイズ
ElementaryUIのプロジェクトのテンプレートを利用すると、Viteを使ったビルドが最初からセットアップされているので、
pnpm run buildをするとデプロイ用のindex.html,js,cssとWasmファイルが生成されていて、これらをそのままサーバーに置いたら良い。
デモのプロジェクトでもそのままGitHub Pagesにアップロードしている。
気になるWasmファイルのサイズはuseEmbeddedSDK = true と useWasmOptというオプションを使うことで、177kbまで落ちた (gzip後)。
そうでなければ、TODOアプリみたいな簡単なものでも2MBぐらいになる。
$ pnpm run build
...
dist/index.html 0.36 kB │ gzip: 0.25 kB
dist/assets/WebApp-CcEiOZXG.wasm 449.76 kB │ gzip: 177.88 kB
dist/assets/index-DjD_SlHc.css 13.71 kB │ gzip: 3.30 kB
dist/assets/index-BlOi47Zf.js 32.93 kB │ gzip: 8.50 kB
useEmbeddedSDK = false
$ pnpm run build
...
dist/index.html 0.36 kB │ gzip: 0.25 kB
dist/assets/WebApp-B03SLlGi.wasm 5,127.45 kB │ gzip: 1,968.64 kB
dist/assets/index-DBVn_mL9.css 14.01 kB │ gzip: 3.36 kB
dist/assets/index-oR8VjpfT.js 32.93 kB │ gzip: 8.50 kB
useEmbeddedSDK = false & import Foundation
$ pnpm run build
...
dist/index.html 0.36 kB │ gzip: 0.25 kB
dist/assets/WebApp-qs0ldgOg.wasm 5,135.93 kB │ gzip: 1,971.95 kB
dist/assets/index-DBVn_mL9.css 14.01 kB │ gzip: 3.36 kB
dist/assets/index-CNLES2eP.js 32.93 kB │ gzip: 8.50 kB
FoundationをimportすればDate型などが使えるようになる上に、
useEmbeddedSDK = falseの時には、Foundationを使うかどうかでファイルサイズの変化はあまりないので
Foundationが必要かどうかでEmbeddedSDKを使うかどうか決めると良さそう。
Embedded Swiftにすると、現状Foundationが使えずURLSessionとかDateとかCodableと同等のことをするために別途何かしらの方法を用意する必要がある。今後、この辺りの選択が分かりやすくなるようガイドラインが整っていくと期待したい。
雑感
ElementaryUIはまだv0.1.1とかなり初期のリリースで、ドキュメントもそこまで充実しているわけでは無いけど、デモがかなりまともに動いていて驚いた。 本格的なSPAのWebアプリケーションを書けるかどうかまではまだ試してないけど、そこが解決したら結構良さそうに見える。 例えば、HTTPクライアントはどうするのかとかURL・Cookieの制御はどうするのかとか。
SwiftWasmにはJavaScriptKitというJavaScriptをSwiftから利用するためのAPIがあるので、それらを利用すれば最低限何とかなる気がするが、現状のElementaryUIではそこまでカバーしてくれていない。
現状TypeScriptとReactなどと比べて明確に良いと言える強みは、Swift初学者の人にとってはなさそうなので、 SwiftWasmを使うことでの複雑な計算が必要な処理を実装するときのパフォーマンス面の比較とか、 バックエンドもSwiftで統一されたコードベースでの親和性なども考慮して今後評価されると良さそうだと思った。
一方でSwift経験者では慣れた言語のSwiftで書けるのは嬉しいし、JS界隈のツール群と触れ合わなくても良いので便利かも。 ElementaryUIのテンプレートプロジェクトではViteを使っているのでNodeとViteは必要だけど、全部自前でやるなら不要。
RustやKotlinなど近い言語もそれぞれWasmで出力できるようになっているので、Wasmでアプリを作るぞとなったときにそれらと比較するとこんな感じかも。
- Rust - Swiftの方がフロントエンドのアプリケーションに必要なコードは書きやすい
- Kotlin
- Compose Multiplatformが既にあってWasmでWebにデプロイできるのが大きい
- 本質的に全部同じコードで出来る?っていう疑問はある
- デモのページの動作が結構重い https://zal.im/wasm/jetsnack/
- Gradleの癖がある & JetBrainのIDEへの依存が強い
- SwiftはLSPが快適になってきたのでVSCodeでも書けるし言語仕様としてimportの管理が圧倒的に楽
- Compose Multiplatformが既にあってWasmでWebにデプロイできるのが大きい
MoonBitというWebAssemblyをターゲットにした言語も主にmizchiが話題にしているけど、まだ言語としては若そう。
(追記):v1に向けてのロードマップが公式サイトにかいてあった。自分が気になっていた部分はv1になる前に取り組まれるらしい。
Here are a few high-level topics that are still ahead
- Support for creating web components (custom elements)
- Fill in missing gaps for value bindings and event handlers
- Client-side router implementation (for route-based SPAs)
- Mechanism for fetching JSON data (embedded compatible)
- Web Storage APIs (local/session storage, embedded compatible)
- Complete transform and filter effects and built-in transitions
- Swift-native layout and style module (idiomatic APIs for CSS)
- Support more CSS properties in FLIP animations
- Investigate how to further reduce wasm binary size
- Server-side rendering and client-side hydration
- “Meta-Framework” features
https://elementary.codes/guide/introduction.html
参考
- Embedded Swiftで共通の通信処理を提供するのが難しいという話が書いてあるスレッド https://forums.swift.org/t/what-is-swift-embeddeds-vision-for-networking-filesystem-syscalls-etc/
- Embedded SwiftをサポートするCodableの後継のAPIについての議論 https://forums.swift.org/t/the-future-of-serialization-deserialization-apis/
- ICUのデータをフィルターしてみてはという議論 https://forums.swift.org/t/proposal-use-filtered-icu-data-as-the-default-in-swift-foundation-icu/
- Moonbit https://zenn.dev/mizchi/articles/introduce-moonbit