SeedでRust-onlyなポートフォリオサイトを作ってみた
この記事は Rust Advent Calendar 2021 の17日目の記事です。
はじめに
初めまして、えとあると申します。Rust歴は趣味でときどき触って1年くらいの初心者ですが、学習難易度が高い一方で既に書いていてとても楽しい言語だなと感じており、Rustを「困ったときに頼れる手に馴染んだ道具」にできるよう日々修行に励んでいます。
今回はRustのフロントエンドフレームワークである Seed を使ってポートフォリオサイトを作ってみたお話をしたいと思います。世の中のポートフォリオ作る系の記事では「簡単! すぐ作れる! 楽に運用!」的な謳い文句が目立ちますが、本記事の内容はそれらとは対照的に「できるかぎりRustだけで作る」という自分が楽しむためだけの縛りプレイをしているため、とても費用対効果が悪い方法であることをご承知おきください。
最初にお見せしてしまうと、作ったポートフォリオサイトは以下のリンクから閲覧できます。ポートフォリオといっても、中身はほぼ空っぽでまだ全然コンテンツが作れていませんが…
ソースコードのリポジトリは↓です。
🙆 こんな方には本記事の内容をオススメできます!
- Rustで書きたい(超重要)
- webフロントエンドをWebAssemblyで作りたい
- Elmアーキテクチャが気になっている
- デザイン考えるの好き、スタイルも自分で書くよ
- 結局無いモンは自分で作りゃええんや!
🙅 こんな方には本記事の内容をオススメできません
- リッチなフロントならJSで良くね? → JSで書いてどうぞ
- ブログサイトを作りたい → Hugo や Zola などの純粋な静的サイトジェネレータで十分
- テーマやスタイルを自分で用意したくない → 上記の静的サイトジェネレータの方がテーマも充実している
- とにかく楽してポートフォリオサイトを構築したい → 上記の静的サイトジェネレータの方がデプロイも楽(多分)
- クレートのバージョンアップなどに対しても堅牢なサイトを構築したい → もっとproduction-readyなクレートを使いましょう
- 英語での情報収集が死ぬほどダルい → Rust全般まだ割と日本語リソース少ないよね…わかる😑
- …やっぱJSで良くね? → (お前がそう思うんならそうなんだろう、お前ん中ではな…)
Seed : Webフロントエンドフレームワーク
Seed はRust製のwebフロントエンドフレームワークです。現状Rustをwebブラウザで動かすにはWebAssembly (WASM)へとコンパイルするのが主流です。SeedでもRustソースコードはwasm32-unknown-unknown
をターゲットとしてビルドされ、ブラウザ上ではHTML内のJSを経由してWASMモジュールを呼び出します。
Seedでは Elmアーキテクチャ (The Elm Architecture, TEA) と呼ばれるModel-View-Update (MVU)パターンに則ってフロントエンドの状態管理や描画処理を記述します[1]。このMVUの役割はそれぞれ
-
Model
: アプリケーションの状態(データ)を保持する構造体 -
view
: 状態を元に何をページに描画するかを記述する関数 -
update
: ユーザー操作などのイベントに応じて状態を更新する関数
となっており、view
→(ランタイム)→update
→Model
→view
のような一方向の処理サイクルによってアプリケーションが動作します[2]。
SeedにおけるElmアーキテクチャの各要素の書き方や設計方法は Seed公式のチュートリアル がめちゃくちゃ丁寧でわかりやすいので、そちらをご参照ください。
今回作ったポートフォリオサイトでは、ぶっちゃけ今のところフロントエンドの細かい状態管理などはあまり気にしていません。というのも、最終的な成果物であるポートフォリオは静的サイトとしてCDNから配信しているためです。つまりここでは Seedをただの静的サイトジェネレータとして活用している ということになります。
サイトでのMVU構成
ここまで今回作ったポートフォリオサイトのコードを1行もお見せしていないので、ElmアーキテクチャのMVUの要素に関連する部分のコードを簡単に見ていきましょう。
Model
use seed::{prelude::*, *};
const ABOUT: &str = "about";
// ...(中略)...
struct Model {
base_url: Url,
page: Page,
}
enum Page {
Home,
About,
NotFound,
}
impl Page {
fn init(mut url: Url) -> Self {
match url.next_path_part() {
None => Self::Home,
Some(ABOUT) => Self::About,
Some(_) => Self::NotFound,
}
}
}
Model
ではbase_url
とpage
の情報をアプリケーションの「状態」として持たせています。今回はポートフォリオサイトなので、管理すべき状態としては「どのページを表示しているか」くらいしかありません。
表示するページは列挙型Page
で定義しています。今はまだHome
とAbout
の2ページしかないので、特筆すべき事項はないでしょう。URLのパスに応じてパターンマッチングを行い、各ページがリクエストされたときの初期化処理としてModel.page
の状態を更新するinit
メソッドを実装しています。
view関数
extern crate etoal_com as et;
use crate::et::{home, about};
use seed::{prelude::*, *};
// ...(中略)...
fn view(model: &Model) -> impl IntoNodes<Msg> {
vec![
header(&model.base_url),
match &model.page {
Page::Home => home::view(),
Page::About => about::view(),
Page::NotFound => div!["404"],
},
]
}
view
関数ではModel
が持っているアプリケーションの状態を元に何をページに描画するかを決めます。関数の返り値になっているimpl IntoNodes<Msg>
はSeedが提供する「HTML要素へと変換可能」な振る舞いを表すトレイトで、型パラメータに入っているMsg
は後述するupdate
関数で登場するメッセージ(ブラウザ上のイベントを伝達する媒体)のことです。
このview関数はNode<Msg>
型の要素が2つが入ったベクタを返しますが、その要素を返すheader()
, home::view()
, about::view()
らの関数は別のモジュールなどに切り出しています。
Seedの特徴の1つとして、HTML要素をマクロで記述することが挙げられます。たとえば上記のview
関数内のHTML要素を返すheader
関数は次のような見た目をしており、HTMLタグっぽい名前のマクロがネストされていることが分かります(初見で意味が判別しづらいC!
は<class>
タグです)。
fn header(base_url: &Url) -> Node<Msg> {
nav![
id!["appNav"],
div![
C!["nav-container"],
nav_container_styles(),
a![
C!["nav-logo"],
nav_logo_styles(),
nav_logo_hover_styles(),
attrs! { At::Href => Urls::new(base_url).home() },
"Etoarium",
],
div![
C!["nav-menu"],
a![
nav_menu_styles(),
nav_menu_hover_styles(),
attrs! { At::Href => Urls::new(base_url).home() },
"Home",
],
a![
nav_menu_styles(),
nav_menu_hover_styles(),
attrs! { At::Href => Urls::new(base_url).about() },
"About",
],
]
],
]
}
Rustではしばしば強力な静的型付けによって関心事がモデリングされますが、Seedでは そもそもがランタイムエラーの温床である既存のHTML+CSS+JSスタックをRustの静的型に落とし込むつらみ からDOMなどを型に落とし込む方法は採用せず、コンパイラの型チェックの恩恵を受けつつ可読性・視認性を確保する落とし所としてマクロという記法を選択しているようです。
update関数
use seed::{prelude::*, *};
// ...(中略)...
enum Msg {
UrlChanged(subs::UrlChanged),
}
fn update(msg: Msg, model: &mut Model, _: &mut impl Orders<Msg>) {
match msg {
Msg::UrlChanged(subs::UrlChanged(url)) => {
model.page = Page::init(url);
},
}
}
update
関数では、ブラウザイベント(カーソル操作など)の情報を受け取ってModel
の状態を更新します。状態をどのように更新するかはメッセージ Msg
という列挙型で定義されたブラウザイベントの情報で決まります。
ここでもポートフォリオサイトでは全く複雑なことはしません。URLの変更イベントをMsg::UrlChanged
という型で受け取り、Model
の持つページ情報を更新しています。ページの初期化自体は前述のPage::init
メソッドにurl
を渡してそちらで処理させています。
Rust-onlyなポイント
今回のポートフォリオサイトはRust-onlyと言いつつ、正直完全にRust 100%でできているわけではありません。とはいえ色んな所をRustで済ませたいという自分の要求を叶えるために、手を加えたポイントを紹介します。
但し書きとして、以下のポイントはSeedのリポジトリでも推奨方法として記載されている内容であり、決して私のオリジナルの発想というわけではありません。ですが、この辺りはまだ日本語リソースが少ないという状況に甘えて、僭越ながら文書化の先陣を切らせていただきます。
ポイント1. TrunkによるJS弱依存のビルドパイプライン
Trunk はRust製のWASM webアプリケーションバンドラです。JSで言うところの webpack と似たような機能を持っており、RustでのWASM webアプリケーション開発に有用なCLIコマンド群(ビルドやdevサーバーの起動など)を提供してくれます。
通常RustコードのビルドからWASMをブラウザで動作させるまでの一連の処理ツールとしては wasm-pack が主流かと思います。wasm-packとTrunkの違いをざっくり説明するならば「JSとWASM、どちらを主役にwebアプリをビルドするか?」という点です。wasm-packはRustソースコードからコンパイルしたWASMをnpmパッケージという形でまとめ上げてJS-WASMを相互運用することをメインで想定しているのに対し、Trunkの方ではWASMおよびエントリーポイントとなるHTMLを中心にビルド & アセットバンドリングが行われ、JSは補助的な役割(WASMローダーと必要に応じたJSコードの活用)を想定しているようです[3]。
Trunkを用いたビルドパイプラインの利点は、RustでのWASM開発において Node.jsやnpmの環境構築・セットアップが不要になること です。Trunk自体はcargo
が使える状態であれば下記コマンドで一発でインストールできるため、Rust以外の言語の環境構築に煩わされないというのは嬉しい人が多いのではないでしょうか?
cargo install --locked trunk
その他、trunk
コマンドなど詳細な使い方は 公式ページ をご参照ください。
ポイント2. seed_stylesによる型付きCSS
SeedにはRustコード上で記述したSeedのHTML要素に対してスタイルを指定できるseed_styles
という関連クレートがあります。基本的な使い方は前述のHTML要素マクロにStyle
型のオブジェクトを要素として渡すことで、その要素にスタイルが反映されます。
例として、Homeのページで表示されるHELLO💚
と書かれた3DキューブのHTML要素マクロを見てみましょう。なんとこのキューブの3D配置およびアニメーションはCSSのみで記述されています。 Rustというモダンな言語を使っているのに、書いているのはゴリゴリの生CSSかというレベルのスタイリング、これが令和のCSSというわけか…😑(違う)
pub fn hello_cube<Ms>() -> Node<Ms> {
div![C!["cube-container"],
s().position_absolute()
.top("50%")
.left("50%")
.perspective("500px")
,
div![C!["cube"],
s().transform_style("preserve-3d")
.animation("10000ms linear infinite")
.keyframe(0, s().transform("rotateX(0deg) rotateY(0deg)"))
.keyframe(100, s().transform("rotateX(720deg) rotateY(360deg)"))
,
ol![C!["cube-list"],
s().transform_style("preserve-3d")
.transform("translateY(-85px) translateX(-85px)")
,
cube_surface(1),
cube_surface(2),
cube_surface(3),
cube_surface(4),
cube_surface(5),
cube_surface(6),
],
],
]
}
fn cube_surface<Ms>(id: i32) -> Node<Ms> {
let base_style = s()
.position_absolute()
.width(px(150))
.height(px(150))
.background_color(CssColor::Rgba(150., 100., 255., 0.6))
.display(CssDisplay::Flex)
.justify_content(CssJustifyContent::Center)
.align_items(CssAlignItems::Center)
.font_size(rem(6))
.color(CssColor::Rgba(255., 255., 255., 0.6))
.text_shadow("0px 0px 10px rgba(255, 255, 255, 0.4)")
.border_radius(CssBorderRadius::Length(px(10)))
.box_shadow("0px 0px 15px rgba(200, 150, 255, 0.4)")
;
let (s_char, s_style) = match id {
1 => ("H", base_style.clone().transform("translateZ(85px)")),
2 => ("E", base_style.clone().transform("translateY(85px) rotateX(270deg)")),
3 => ("L", base_style.clone().transform("translateX(85px) rotateX(180deg) rotateY(90deg)")),
4 => ("💚", base_style.clone().font_size(rem(5)).transform("translateX(-85px) rotateX(180deg) rotateY(-90deg)")),
5 => ("L", base_style.clone().transform("translateY(-85px) rotateY(180deg) rotateX(90deg)")),
6 => ("O", base_style.clone().transform("translateZ(-85px) rotateY(180deg)")),
_ => ("_", base_style),
};
li![
attrs! { At::from("surface-id") => id },
s_style,
s_char,
]
}
各要素マクロの中にあるs()
から始まるメソッドチェーンが型付きスタイルの記述部分です。s()
関数はStyle
型のオブジェクトを返し、それに具体的なスタイルを付与するメソッドをつなげて書くことで1つの要素のスタイルを指定します。スタイル指定メソッドの引数は"rgba(255, 255, 255, 0.4)"
のようなCSSを直書きしたような&str
型と、CssColor::Rgba(150., 100., 255., 0.6)
のようなseed_styles
で定義されているスタイルの型、どちらでも指定が可能です。
一見質めんどくさいことをしているようにしか見えませんが、この方法にも恩恵はあって、それは 無効なスタイル指定をちゃんとコンパイル時に気付けること です(スタイルを&str
型ではなく専用の型で指定している場合)。またStyle
型はオブジェクト毎にアプリケーション全体で固有のスコープを自身に割り当てるため、影響範囲を限定した安全なスタイリングが可能になります(この辺は他のCSSフレームワークでも割とある機能なのであまり差別化にはなりませんが)。
seed_styles
も 公式ページ に基本的な使い方が紹介されているので、興味がある方はぜひご参照ください。
その他、読者が気になるであろうQ&A
-
Q. 全部WASMで書いたことでパフォーマンス面での恩恵はありますか?
- A. 測定したわけではありませんが、特に恩恵は感じられていません。むしろWASMの読み込み後にDOMが展開されるので、初回アクセス時の描画速度は遅いまであります。
-
Q. Seedの依存クレート数はどれくらいですか?
- A.
seed
および関連クレートのseed_styles
,seed_hooks
(今回は未使用)のみをCargo.tomlのdependenciesに記述した状態でビルドして、およそ283くらいです。releaseオプションを付けたビルドは1-2分で完了します。
- A.
-
Q. デプロイはどうやってますか?
- A. GitHub Actionsでpush時にリリースビルドを行い、ホスティング先であるNetlifyに自動デプロイしています。
まとめ
今回は趣味全開のやり方で、ほぼRustのみで書かれた弊ポートフォリオサイトを紹介させていただきました。現在はサイトという自分の実験室をようやく拵えられたような状態で、中身はこれからWebAssemblyで動くスモールアプリやブログ記事などを充実させていきたいと考えています。
WebフロントエンドをRust (WASM)で作りたいときに、Seed はとても有力な候補だと思います。チュートリアルやドキュメンテーションが充実しており、Rust初学者でも簡単にGetting Startedできます。学んでいて/使っていて詰まってしまっても、Seedは開発者(メンテナから初心者まで含む)同士の情報共有を Discord 上で行っていて、分からないことは素直に聞けば割と親切に教えてくれたり相談に乗ってくれたりします。リポジトリの examples ディレクトリ配下にSeedアプリケーションの様々なコード例も用意されているので、それらを組み合わせることで一通りのアプリケーションは作れるんじゃないでしょうか? ただし、Seedのユーザー数もまだそこまで多くなく、SeedのコアであるWebAssembly自体もまだまだ黎明期にある技術なので、なかなか企業案件での採用は難しいかもしれません。
フロントエンドに関わらず、Rustのwebフレームワーク全般の比較は以下のリポジトリがそこそこ最新の状態にメンテされていて参考になります。
以上です。ここまで読んでいただきありがとうございました🙇♂️✨
-
Seedでは…
と書いたものの、他のRust webフロントエンドフレームワークでもTEAを採用しているものがやたら多い気がしています。 ↩︎ -
TEAについての詳しい説明はこちらの記事が個人的に分かりやすくて参考になりました → 図解 The Elm Architecture の流れ by @kazurego7 - Qiita ↩︎
-
Trunkの開発者によるRedditでの質疑応答 と実際にビルドしたときの成果物の印象から、このように説明しました。 ↩︎
Discussion