この記事は Sansan Advent Calendar 2019 - Adventar の 17 日目の記事です 🎄
今回は Rust のフロントエンド向けフレームワーク Yew を使って、Markdown のプレビューアプリ を実装しながら、 Rust による WebAssembly 開発に入門してみたいと思います。
JavaScript でのフロントエンド開発の経験がある人を対象にして書いているため、Node.js 周りの説明は少なめになっています。
Rust の文法についても詳しくは書かないので、全体的な開発の雰囲気を掴んでもらえればと思います。
ちなみに、Rust については if 文の書き方で検索するレベルの初心者なので、気になる点があったら編集リクエストかコメント欄に書いていただけるとありがたいです。
TL;DR
実際のコードは https://github.com/pvcresin/yew-markdown-preview にあります。
完成したアプリは https://yew-markdown-preview.netlify.com/ で体験できます。
(Chrome でしか動作確認してないです...🙇)
前提知識
Rust
- Mozilla が支援しているシステムプログラミング言語
- 静的型付けのコンパイラ言語
- C/C++と同等の処理速度を持ち、それらの代替を目指している
Web の開発だけでなく組み込みなどでも使われているようです。
WebAssembly(wasm)
- モダンブラウザで動作する低レベルなアセンブリ風言語
- 容量が小さく、実行までが高速
- 他の高級言語からコンパイルして作成する
JavaScript と比較して、高速に実行できることから、Web のパフォーマンスをネイティブに近づけるものとして注目されている印象です。
Yew
- React と Elm に影響を受けた、コンポーネントベースのRustフレームワーク
- JSX のようにコード内に HTML 風の記述が可能
- 仮想 DOM を実装している
React や Vue などを触ったことがある人には、理解しやすい記法かと思います。
環境構築
Rust
rustupというツールを使って Rust をインストールします。
Windows はインストーラから、Linux/macOS は下記のコマンドから。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
インストールされていれば、rustc --version
でバージョンが表示されるはずです。
Windows で Rust を使う場合、Visual C++ Build Tools が必要になるのでそちらもインストールしておきましょう。
Cargo
Cargo はビルドシステム兼、Rust のパッケージマネージャです。
npm みたいな認識でいます。
ちなみに、Rust ではライブラリやパッケージに当たるものは crate と呼びます。
上の Rust をインストールした段階でcargo
コマンドも使えると思います。
ここでは Rust で WebAssembly 開発行う際に便利なビルド支援ツールであるwasm-packを入れます。
cargo install wasm-pack
yew-wasm-pack-template
今回はyew-wasm-pack-template
を使います。
このテンプレートは Yew と wasm-pack と webpack が元から組み合わさっており、すぐに開発を始めることができます。
GitHub で「yew-wasm-pack-template」のリポジトリを開き、「Use this template」ボタンを押してリポジトリを作るだけです。
あとは、作成したリポジトリをgit clone
してローカルに落としてきます。
これで準備は完了です。
ビルド
このテンプレートをビルドすると、TODO アプリを MVC アーキテクチャで実装したサンプルが立ち上がります。
まずはじめに、Node.js 側のパッケージを落としてきます。
yarn install
続いて、webpack-dev-server でビルドします。
yarn run start:dev
ビルド時に crate のインストールが行われます。
ビルドが成功すれば、https://yew-todomvc.netlify.com/ と同じ Todo アプリが http://localhost:8000 で確認できると思います。
フォルダ構成
この Todo アプリがどのようにして動いているのかを見ていきます。
以下がこのプロジェクトの主要なファイル群になります。
├── pkg
│ ├── index_bg.wasm
│ └── index.js
├── src
│ ├── app.rs
│ ├── lib.rs
│ └── utils.rs
├── static
│ └── index.html
├── bootstrap.js
├── webpack.config.js
├── package.json
└── Cargo.toml
概形
src 下にある Rust ファイルは wasm-pack により pkg 下に wasm ファイルとして出力されます。
JS のエントリポイントである bootstrap.js では pkg 下に出力された wasm ファイルを pkg/index.js 経由で読み込んでいます。
Node.js 側
webpack.config.js
const path = require("path");
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const distPath = path.resolve(__dirname, "dist");
module.exports = (env, argv) => {
return {
devServer: {
contentBase: distPath,
compress: argv.mode === "production",
port: 8000
},
entry: "./bootstrap.js",
output: {
path: distPath,
filename: "todomvc.js",
webassemblyModuleFilename: "todomvc.wasm"
},
plugins: [
new CopyWebpackPlugin([{ from: "./static", to: distPath }]),
new WasmPackPlugin({
crateDirectory: ".",
extraArgs: "--no-typescript"
})
],
watch: argv.mode !== "production"
};
};
CopyWebpackPluginで static 下にある HTML ファイルを出力先へコピーしています。
また、WasmPackPluginで crate のディレクトリを指定しています。
npm scripts
{
"scripts": {
"dev": "webpack --mode development",
"build": "webpack --mode production",
"start:dev": "webpack-dev-server --mode development"
},
}
yarn start:dev
で開発用サーバ(webpack-dev-server)の起動が、yarn build
でプロダクションビルドができます。
bootstrap.js
import("./pkg").then(module => {
module.run_app();
});
wasm モジュールを Dynamic import しているだけです。
読み込みが完了したら、run_app()関数を実行します。
static/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Yew • TodoMVC</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/todomvc-common@1.0.5/base.css"/ >
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/todomvc-app-css@2.1.2/index.css"
/>
</head>
<body>
<script src="/todomvc.js"></script>
</body>
</html>
Todo アプリ用の CSS を CDN で読み込んでいるのと、JS ファイルを読み込んでいるだけです。
Rust 側
Cargo.toml
[package]
name = "yew-wasm-pack-template"
version = "0.1.0"
authors = ["Justin Starry <justin.starry@icloud.com"]
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["console_error_panic_hook"]
[dependencies]
log = "0.4"
strum = "0.13"
strum_macros = "0.13"
serde = "1"
serde_derive = "1"
wasm-bindgen = "=0.2.42"
web_logger = "0.2"
yew = "0.7"
console_error_panic_hook = { version = "0.1.6", optional = true }
wee_alloc = { version = "0.4.4", optional = true }
[dev-dependencies]
wasm-bindgen-test = "0.2"
Cargo.toml にはそのプロジェクトで使う crate などを書いていきます。
package.json みたいな認識。
src/utils.rs
pub fn set_panic_hook() {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}
ユーティリティファイルです。
Rust で起きたエラーの詳細を JS 側まで伝えてくれる crate、console_error_panic_hookをセットする関数が定義されています。
src/app.rs
長いので載せませんが、このファイルが Yew を使って書かれたアプリケーション本体です。
Yew 自体の理解は後の章で行います。
src/lib.rs
#![recursion_limit = "512"]
mod app;
mod utils;
use wasm_bindgen::prelude::*;
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
// This is the entry point for the web app
#[wasm_bindgen]
pub fn run_app() -> Result<(), JsValue> {
utils::set_panic_hook();
web_logger::init();
yew::start_app::<app::App>();
Ok(())
}
ここが Rust 側のエントリポイントです。
先のファイルをmod
として読み込み、run_app
関数内で使用しています。
pub fn
に#[wasm_bindgen]
属性をつけることで JS 側から呼び出すことができます。
この関数は上の bootstrap.js で呼んでいました。
状態管理とイベントハンドリング
ここからは、サンプルを書き直していきます。
なお、生成される JS ファイルの名前がtodomvc.js
になっていますが、webpack の設定ファイルなども直す必要があり少し面倒なのでそのままでいきます。
まずは、大まかなレイアウトと、イベントハンドリングを行います。
左ペインの teatarea に入力した文字列をアプリケーションで保持し、右ペインに表示してみます。
static/index.html
マークダウン用の CSS を CDN で読み込みます。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Yew Markdown Preview</title>
<link
href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/3.0.1/github-markdown.min.css"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<script src="/todomvc.js"></script>
</body>
</html>
static/style.css
static 下に CSS を追加します。
body {
margin: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
background-color: #efefef;
height: 50px;
display: flex;
align-items: center;
padding: 0 1rem;
}
header > p {
margin: 0;
}
.container {
display: flex;
}
.container > * {
min-height: calc(100vh - 50px);
width: 50%;
padding: 1rem;
box-sizing: border-box;
}
.container > textarea {
font-family: Consolas, "Courier New", Courier, Monaco, monospace,
"MS ゴシック", "MS Gothic", Osaka−等幅;
resize: none;
background-color: #1e1e1e;
color: #e8e8d4;
border: none;
font-size: 18px;
}
src/app.rs
use yew::{html, Component, ComponentLink, Html, Renderable, ShouldRender};
pub struct App {
text: String,
}
pub enum Msg {
Change(String),
}
impl Component for App {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
App {
text: "".to_string(),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
match msg {
Msg::Change(val) => {
self.text = val;
true
}
}
}
}
impl Renderable<App> for App {
fn view(&self) -> Html<Self> {
let link = "https://github.com/pvcresin/yew-markdown-preview";
html! {
<>
<header>
<p>
{"Yew Markdown Preview: "}
<a href={link}>{link}</a>
</p>
</header>
<div class={"container"}>
<textarea oninput=|e| Msg::Change(e.value) />
<div>{&self.text}</div>
</div>
</>
}
}
}
構造体App
がアプリケーション全体の状態のモデルになります。
enum Msg
はイベントを列挙したものです。
Logic
impl Component for App
の部分にロジックを書いていきます。
ここではライフサイクルメソッドが定義できます。
fn create
でtext
に初期値を入れます。
fn update
ではイベントが起こったとき、どのイベントかをmatch
で判別し、処理を行います。
今回定義したChange
イベントは String を引数にとり、text
の値を更新します。
最後にコンポーネントを更新するかどうかのShouldRender
をtrue
で返します。
View
impl Renderable<App> for App
中のfn view
の部分に View を書いていきます。
マクロhtml!
の中ではタグスタイルで View を記述でき、JSX 風な書き心地が楽しめます。
イベントハンドリングは以下のように書きます。
<textarea oninput=|e| Msg::Change(e.value) />
これで左の textarea に入力した文字列が右の div に表示されるようになりました。
Markdown をパースする
Markdown のパースには外部の crate を使います。
crate はhttps://crates.io/で探すことができます。このあたりも npm と似ています。
今回はpulldown-cmarkを使ってみます。
Cargo.toml
crate を追加するには Cargo.toml の[dependencies]
の末尾に crate を追記します。
[dependencies]
//...
pulldown-cmark = "0.6.1"
使い方はドキュメントなどを頼りに探り探りいきます。
src/lib.rs
mod
キーワードの前に
extern crate pulldown_cmark;
の行を追加します。
#![recursion_limit = "512"]
extern crate pulldown_cmark;
mod app;
mod utils;
use wasm_bindgen::prelude::*;
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
// This is the entry point for the web app
#[wasm_bindgen]
pub fn run_app() -> Result<(), JsValue> {
// ...
}
src/app.rs
use
use pulldown_cmark::{html::push_html, Options, Parser};
を追加します。
use pulldown_cmark::{html::push_html, Options, Parser};
use yew::{html, Component, ComponentLink, Html, Renderable, ShouldRender};
impl Component
impl Component for App {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
App {
text: "".to_string(),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
match msg {
Msg::Change(val) => {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(&val, options);
let mut parsed_text = String::new();
push_html(&mut parsed_text, parser);
self.text = parsed_text;
true
}
}
}
}
Parser に入力された文字列val
を渡し、parsed_text に変換された値が入ります。
Option でテーブルなどが記述できる設定を入れておきます。
試しに左ペインに# hello, world!
と入力してみると、右ペインに<h1>hello, world!</h1>
などと表示されるのが確認できます。
これでパースができました。
要素をレンダリングする
最後に先ほどの文字列を実際の DOM 要素としてレンダリングしてみます。
つまり、
element.innerHTML = parsed_text;
のようなことができれば実現できるということになります。
React ではdangerouslySetInnerHTML
を使ってそのようなことができましたが、Yew ではそういった類いのものは見つかりませんでした。
そこでstdweb crate と組み合わせて実現します。
Cargo.toml
[dependencies]
の末尾に crate を追記します。
[dependencies]
//...
pulldown-cmark = "0.6.1"
stdweb = "0.4.20"
ドキュメントを頼りに実装します。
src/lib.rs
extern crate stdweb;
の行を追加します。
#![recursion_limit = "512"]
extern crate pulldown_cmark;
extern crate stdweb;
mod app;
mod utils;
use wasm_bindgen::prelude::*;
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
// This is the entry point for the web app
#[wasm_bindgen]
pub fn run_app() -> Result<(), JsValue> {
// ...
}
src/app.rs
use
use stdweb::web::Node;
use yew::virtual_dom::VNode;
の 2 行を追加します。
use pulldown_cmark::{html::push_html, Options, Parser};
use stdweb::web::Node;
use yew::virtual_dom::VNode;
use yew::{html, Component, ComponentLink, Html, Renderable, ShouldRender};
impl Renderable<App>
impl Renderable<App> for App {
fn view(&self) -> Html<Self> {
let link = "https://github.com/pvcresin/yew-markdown-preview";
let html_text = format!("<div class='markdown-body'>{}</div>", &self.text);
let node = Node::from_html(&html_text).unwrap();
let preview = VNode::VRef(node);
html! {
<>
<header>
<p>
{"Yew Markdown Preview: "}
<a href={link}>{link}</a>
</p>
</header>
<div class={"container"}>
<textarea oninput=|e| Msg::Change(e.value) />
{preview}
</div>
</>
}
}
}
パースされた文字列を含む div タグをNode::from_html
に渡すことで Node を生成します。
その Node を Yew 内部の仮想 Node と紐づけ、{preview}
の位置にパースされた DOM の要素が展開されます。
(ここらへん、正直よくわかってない 😇)
これで、左ペインで Markdown 記法で記入すると右ペインに表示されるようになりました 🎉
今回は使いませんでしたが、stdweb
にはマクロjs!
があり、Rust 内で JS の記法が使える機能もあります。
まとめ
Markdown のプレビューアプリを例に、Yew による Rust/WebAssembly 開発を紹介しました。
Rust力が高ければもう少し綺麗なコード書けたんじゃないかなと思います。
また、今回考えられなかったですが、Markdown のプレビューってスクロール位置とかいろいろ考えなきゃいけないなと。。。
やってみた感想としては、Yew はフロントエンドエンジニアが Rust に入門するのに適していると思いました。
ただ、ドキュメントはまだまだ少ない気がします。
WebAssembly をうまく使って Web の体験を向上させていきたい!
最後までお読みいただき,ありがとうございました。