こんにちは、電通国際情報サービス(ISID) 金融ソリューション事業部の大場です。 今回は、Rustでフロントエンドの実装ができるYewというライブラリを使ってMarkdownエディタを作った話をします。本記事は、Yewの内部実装に触れながらYewやRustのマクロの動作について理解を深めることを目的としています。これらについて詳しく知りたい方はぜひ本記事を参考にしていただければと思います。
また、本記事で紹介するコードはこちらのリポジトリで公開しています。 https://github.com/ISID/wasm-md-editor
作った背景
WebAssemblyを触ってみたいと思っていたところ、Rustでフロントエンドの実装ができるライブラリを発見したのを機に「これは何か作ってみるしかない!」と一念発起して作り始めました。デザインに時間をかけたくなかったので、最もシンプルなUIで作れそうなMarkdownを作ることにしました。(逆にシンプル過ぎたかもしれません)
採用した主要なCrate
さっそく、Markdownエディタに使用した主要なライブラリの簡単な紹介です。pulldown-cmarkの実装部分の説明は省いています。気になる方はgitリポジトリをご覧ください。
- Yew
- WebAssemblyによるRust製フロントエンドフレームワーク
- JSX記法を提供するHTMLマクロや、ReactライクなComponent(Class Component、Functional Component)の実装が可能
- pulldown-cmark
- Markdown記法のテキストをHTML形式に変換するParser
全体像とフロー
作ったアプリの全体像がこんな感じです。
- ①ユーザーが画面にMarkdown記法でテキストを入力
- ➁Yewコンポーネントは文字入力のイベントを受信し、pulldown-cmarkで作られたrendererへテキストを渡す
- ③rendererはMarkdown記法を解釈し、HTML形式のテキストに変換する
- ④Yewコンポーネントに返却し再レンダリング
ここからYewの紹介をしつつ、実際にYewの実装を見ていきます。
Yew
そもそもWebAssembly(Wasm)とは
ブラウザ上で動作するバイナリー形式のアセンブリ言語で、ネイティブアプリに近いパフォーマンスで動作する言語と言われています。現在Wasmにコンパイルできる言語にはC、C++、Rustがあります。 Wasmには「JavaScriptを補完し、並行して動作するための言語」という思想が根底にあり、JavaScriptと(今回の記事では)Rustが双方向にそれぞれの関数をexport, importして利用することができます。
Yew内部で使われる主要なCrate
- js-sys
- JavaScript標準のビルトインオブジェクトをRustに提供
- web-sys
- ブラウザが提供するWeb APIをRustに提供
- wasm-bindgen
- Rustで書いたコード(関数)をJavaScript側で利用するためのCrate。両者の関数を受け渡しするブリッジ的な役割を果たす。js-sysやweb-sysを使ったRustのコードもwasm-bindgenが最終的にjavaScriptにexportする
- wasm-bindgen-futures
- RustとJavaScript両者の非同期実装をブリッジするためのCrate。JavaScriptのPromiseをRustのFutureとして操作することが可能
Yewの実装
手続き型マクロ
簡易的なComponentを実装してみます。
use yew::prelude::*;
// これはHomeコンポーネントとして認識される
#[function_component(Home)] // 1.
pub fn home() -> Html {
html! { // 2.
<h1>{"Welcome to my editor!"}</h1>
}
}
#[function_component]
アトリビュートを付与することで関数全体がComponentであるとYewが認識します。アトリビュートの引数にある`home
はComponentの名称を指しています。- home関数では、
html!
を利用してインナーブロックで与えられたHTMLタグを処理しHTMLとして返却します。
マクロについての補足
1.2で述べた記法は、Rustの世界ではどちらもマクロと呼ばれています。1のように関数や構造体に付与するものは手続き型マクロと呼ばれるのに対し、macro_rules!で定義され、呼び出し元からは関数呼び出しのように利用されるものは宣言的マクロ(println!など)に分類されます。
ここで注意ですが、2のhtml!
は一見宣言的マクロに見えますが実は手続き型マクロです。これはhtml!マクロの内部の実装を見ると明らかになります。
#[proc_macro_error::proc_macro_error]
#[proc_macro] // 1.
pub fn html(input: TokenStream) -> TokenStream {
let root = parse_macro_input!(input as HtmlRootVNode);
TokenStream::from(root.into_token_stream())
}
- html!マクロの実装は
#[proc_macro]
によって定義されており、macro_rules!を使っていません。 #[proc_macro]
を使った手続き型マクロは関数風マクロと呼ばれています。手続き型マクロがRustのバージョン1.15.0で追加されたため、従来から存在した宣言的マクロと後発の関数風マクロが共存してしまっているようです。
それでは、#[function_component]
マクロはどのように実装されているのか見ていきましょう。
#[function_component]実装
先に実装から。
#[proc_macro_attribute] // 1.
pub fn function_component(
attr: proc_macro::TokenStream, // 2.
item: proc_macro::TokenStream, // 2.
) -> proc_macro::TokenStream {
let item = parse_macro_input!(item as FunctionComponent); // 3.
let attr = parse_macro_input!(attr as FunctionComponentName); // 3.
function_component_impl(attr, item) // 4.
.unwrap_or_else(|err| err.to_compile_error())
.into()
}
pub fn function_component_impl(
name: FunctionComponentName,
component: FunctionComponent,
) -> syn::Result<TokenStream> {
let FunctionComponentName { component_name } = name;
// ・・・省略・・・
let quoted = quote! { // 5.
#[doc(hidden)]
#[allow(non_camel_case_types)]
#[allow(unused_parens)]
#vis struct #function_name #impl_generics {
_marker: ::std::marker::PhantomData<(#phantom_generics)>,
}
impl #impl_generics ::yew::functional::FunctionProvider for #function_name #ty_generics #where_clause {
type TProps = #props_type;
fn run(#arg) -> #ret_type {
#block
}
}
#(#attrs)*
#[allow(type_alias_bounds)]
#vis type #component_name #impl_generics = ::yew::functional::FunctionComponent<#function_name #ty_generics>;
};
Ok(quoted)
}
コンポーネントを定義する際に利用した#[function_component]
アトリビュートは下記のように動作します。
proc_macro_attribute
はfunction_component()関数がCustom Attributeであることを示しており、利用側で#[function_component]
を関数に付与した際には、定義元であるこの関数にリンクされます。- 手続き型マクロを定義する関数は、TokenStreamを入力として受け取りTokenStreamを出力として返します。つまり、アトリビュートを付与した関数のコードそのものが入力値としてTokenStreamに変換され、そのTokenStreamを基にマクロで生成されるコードがTokenStreamとして返却されます。
- 引数の1つ目である
attr: proc_macro::TokenStream
は呼び出し側(#[function_component(Home)]
)のHomeを指しているのに対し、2つ目のitem: proc_macro::TokenStream
は#[function_component(Home)]
を付与した関数の中身(Componentの実装)に対応しています。 - itemがFunctionComponent型、attrはFunctionComponentName型にキャストされていることがわかります。
- 引数の1つ目である
- parse_macro_input!はTokenStreamのトークン列を構文木にパースし、Rustが解釈できるデータ構造に変換されます。その後、マクロ実装によってデータ構造に対して書き込みが行われていきます。書き込み後に生成されたコードをTokenStreamに再変換し、呼び出し元に返却します。
- 一般的な手続き型マクロの順番は上述の通りですが、
parse_macro_input!
マクロによって構文木にパースされたTokenStreamはこのあとfunction_component_impl()関数内の処理で再度TokenStreamに変換されます。 - 構文木にパースされると、マクロによってコードが生成されます。function_component_impl()関数を見ると
quote!
マクロが呼ばれ、構文木からTokenStreamへの変換が行われます。- コードを生成する処理については各手続き型マクロの固有処理になるので、マクロを定義するlib.rsからは分離して定義することが多いです。
- これは、proc_macroで定義したマクロ内部のコードは、マクロが評価される実行時のタイミングでしか呼び出すことができません。つまり、マクロ内部の詳細な実装をテストすることも踏まえると、詳細な実装はlib.rsではない別のクレートに実装し、lib.rsから呼び出すように書く必要があるのです。
実際に画面を実装してみる
Top画面とルーティング実装
Rustのマクロの説明で利用したHomeコンポーネントをラップして、トップ画面を作成します。
use crate::{components::home::Home, Routing};
use stylist::style;
use yew::prelude::*;
use yew_router::{history::History, hooks::use_history};
#[function_component(Top)] // 1.
pub fn top() -> Html {
let container = style!( // 2.
r#"
display: flex;
flex-direction: column;
align-items: center;
"#
)
.expect("Failed to styled.");
let button = style!(
r#"
color: #ffffff;
width: 200px;
padding: 10px;
background-color: #1976d2;
box-shadow: 0 3px 5px rgba(0, 0, 0, .3);
-webkit-box-shadow: 0 3px 5px rgba(0, 0, 0, .3);
:hover {
background: #115293;
margin-top: 3px;
}
"#
)
.expect("Failed to styled.");
let history = use_history().unwrap();
let onclick = Callback::once(move |_| history.push(Routing::Editor));
html! {
<>
<div class={container}>
<Home />
<button class={button} {onclick}>{"Start"}</ button>
</div>
</>
}
- Topコンポーネントは、Homeコンポーネントを呼び出しており、Startボタンを配置するTopページを表している。
- styleはstylistを利用しており、Reactのような宣言的なスタイルの定義が可能。
あとはmain.rsとしてルーティングの設定を行います。今回はHome画面をトップ画面に、Home画面に設置しているボタンを押下するとエディタ画面に遷移するようにします。(解説は省きます)
#[derive(Clone, Routable, PartialEq)]
pub enum Routing {
#[at("/")]
Home,
#[at("/editor")]
Editor,
#[not_found]
#[at("/404")]
NotFound,
}
pub enum Msg {
SetInput(String),
}
#[function_component(App)]
fn app() -> Html {
html! {
<BrowserRouter>
<Switch<Routing> render={Switch::render(switch)} />
</BrowserRouter>
}
}
fn switch(routes: &Routing) -> Html {
match routes {
Routing::Home => html! {
<Top />
},
Routing::Editor => html! {
<Text />
},
Routing::NotFound => html! {<NotFound />},
}
}
fn main() {
yew::start_app::<App>();
}
サーバを起動すると下記のようなTop画面が表示されます。
エディタ画面の作成
エディタ画面の実装です。改めてですが、画面のデザインは驚くほど凝っていません。 Reactを書いてるような感覚でstyleやイベントハンドラを実装できるのがわかるかと思います。
#[styled_component(Text)]
pub fn text() -> Html {
let style = style!(
r#"
background-color: #1e2126;
color: #fff;
font-family: inherit;
margin: 2rem;
"#
)
.expect("Failed to styled.");
let container = style!(
r#"
display: flex;
"#
)
.expect("Failed to styled.");
let item = style!(
r#"
"#
)
.expect("Failed to styled.");
let value = use_state(|| String::from(""));
let on_input = { // 1.
let value = value.clone();
Callback::from(move |e: InputEvent| { // 2.
let input: HtmlTextAreaElement = e.target_unchecked_into();
value.set(input.value());
})
};
let html = cmark(value.to_string()); // 3.
let div = web_sys::window()
.unwrap()
.document()
.unwrap()
.create_element("div")
.unwrap();
div.set_inner_html(&html);
let node = Node::from(div);
let vnode = VNode::VRef(node); // 5.
html! {
<>
<div class="markdown-body">
<div class={container}>
<div class={item}>
<textarea class={style} rows="140" cols="100" value={value.to_string()} oninput={on_input} />
</div>
<div class="item" >
{vnode}
</div>
</div>
</div>
</>
}
}
fn cmark(text: String) -> String {
let mut options = Options::empty();
options.insert(
Options::ENABLE_TABLES // 4.
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_SMART_PUNCTUATION,
);
let parser = Parser::new_ext(&text, options);
let mut html_output = String::new();
// parser changes rendered String for markdown
html::push_html(&mut html_output, parser);
html_output
}
- テキストエリアに文字が入力された際に発火されるイベントを登録しておきます。
- Callbackを使ってComponentが保持する状態にテキストの値を渡します。
- pulldown-cmarkの処理です。Markdown記法で書かれたテキストをHTMLに変換します。
- Parserを作成する際に必要なオプションの有無を設定しています。
- HTMLに変換されたテキストをVNodeを使って仮想DOMにセットします。
エディタ画面はこんな感じになりました。
最後に
本記事では、Yewの実装を見ながらマクロの挙動やYewの実装方法について説明しました。WasmやRustに興味を持っていただけたら嬉しいです。 また、Rustでフロントエンド実装ができるライブラリはYew以外にもいくつかあるので興味のある方はぜひ見てみてください!
補足
Trunkについて
ローカルでのWebサーバはTrunkを利用しました。TrunkはRustのコードをJavaScriptモジュールにコンパイルするバンドルツールとしてwasm-packと大変似ていますが、Trunkの場合はwasm-packと違ってJavaScriptモジュールのほかその他のアセット(HTML, CSS, Image)も同時に自動生成します。また、TrunkにはビルトインでWebサーバが提供されているため、コマンド一発で開発時に利用するWebサーバを立ち上げることができます。 Yew公式でも、Trunkが推奨されています。
Markdownのスタイルについて
公開されているものがあったのでそのまま利用しました。 https://github.com/sindresorhus/github-markdown-css
執筆:大場 進太郎 (@ShintaroOba)、レビュー:@sato.taichi (Shodoで執筆されました)