JSを書かずにWebアプリを作る
※ 他サービスで書いたエントリの再掲になります。 前に、自分はrustでAPNGのライブラリを作りました。(良ければみてください 記事) また、別のweb appを作った記事もここにあります。
今回、作ったライブラリの活用手段として、Webアプリ化と自分の中で考え、Webアプリを作ろうと思いました。
JSを書かない
JSを書きたくない 書かない理由としては、rustはwasmを使ってWebアプリが書けることと、JSよりも高速かつメモリセーフといったようなメリットがあるためです。また、個人的に静的型付け言語が好きだからです。
作ったもの
もうすでに公開されているので、ソースコード、Webアプリが気になる方は先に見てみてください。
https://drawasm.netlify.com | generated APNG |
---|---|
作ったアプリを簡単に説明すると、Web上で絵や文字を描きそれをAPNGに変換できるものです。
今回はその作ったもの説明を以下の順序で話していければと思います。
目次
下準備
まず、rustでwasmを書くためにツール群のインストール、そしてテンプレートを使用して準備をしていきます。
wasmのツールをインストールし終わったら、
$ cargo generate --git https://github.com/poccariswet/rust-wasm-template
これは自分が作った、templateで上記のコマンドをうって rustのwasmプロジェクトを作成していきます。
wasmで実装したところ
canvas部分について
絵や文字書くための場所であるcanvas部分については、自分が以前書いた記事の wasmのEventListenerの実装をコードとともに見てみるに飛んでみてください。 ここには、使っているライブラリ(web-sys)などやEventListenerについて詳しくまとめているので読んでいただけたらと思います。
state部分について
今回の実装上、色やペンの太さ、キャンバスの画像の保持などを複数のデータや状態を共有しなければなりません。そのため、rustでは共有スマートポインタである、Arc/Rc
を使用します。今回はスレッドでの共有がないと考えているため、Rc を使っていきます。
- Rc
は、同じデータに複数の所有者を持たせてくれる; Box とRefCell は単独の所有者。(*1) - Box
は、不変または可変借用をコンパイル時に精査してくれる; Rc は不変借用のみをコンパイル時に精査してくれる; RefCell は、不変または可変借用を実行時に精査してくれる。(*1)
状態共有
まず、状態共有をするには 内部可変性コンテナであるRefCellを使います。
- RefCell
は実行時に精査される可変借用を許可するので、RefCell が不変でも、 RefCell 内の値を可変化できる(*1)
また、今回のアプリでは複数のオブジェクトから共有されている状態を持つので以下のように宣言します。
https://github.com/poccariswet/drawasm/blob/master/src/lib.rs#L60-L61
let state: Rc<RefCell<state::State>> = Rc::new(RefCell::new(state::State::new(canvas_w, canvas_h)));
このように宣言することで、複数のオブジェクト間での状態の共有ができます。
toolbar部分について
アプリのこの部分です。
ここでは、クリックイベントを取得して、それに応じてキャンバスに描くモードを変えたり、プレビューエリアへのプレビュー画像の追加をしていきます。
クリックイベントを取得する
https://github.com/poccariswet/drawasm/blob/master/src/toolbar.rs#L159-L187
fn create_color_picker( document: &Document, state: &Rc<RefCell<State>>, ) -> Result<Element, JsValue> { // divタグの生成 let element = document.create_element("div")?; // toolbar用にスタイルの設定 element.set_attribute( "style", "height: 50px; width: 50px; display: flex; align-items: center; justify-content: center; font-size: 11px; border: 1px solid #9b9b9b;", )?; // inputタグの生成 let input = document .create_element("input")? .dyn_into::<HtmlInputElement>()?; // inputのattributeを設定 // inputのタイプをcolorにしてvalueをblackに設定 input.set_attribute("type", "color")?; input.set_attribute("value", "#000000")?; // stateをクローンして、Closure内で使用する let state = state.clone(); let picked_color = Closure::wrap(Box::new(move |e: Event| { let target = e.target().unwrap().dyn_into::<HtmlInputElement>().unwrap(); let color = target.value(); state.borrow_mut().set_color(color) }) as Box<dyn FnMut(_)>); input.add_event_listener_with_callback("change", picked_color.as_ref().unchecked_ref())?; picked_color.forget(); //⚠️注意点 element.append_child(&input)?; Ok(element) }
上記の実際に、実装されたコード参考に見ていきます。
ここでやっていることは大きく分けて2つあります。
1つ目は、タグの生成、タグスタイルの設定
Documentの create_element
を使用して、elementの生成をします。生成されたelementにset_attribute
を使用して、styleの適用をしていきます。今回はカラーパレットを使うのでtype
をcolor
にしています。
2つ目は、jsで言うEventListener
のイベント制御の設定
“inputのカラーが changeされた際に、value
をstateのcolorに保存する。“
これがここのイベント制御の内容です。
JSのEventListener
のClosureを実装するために、rustのクロージャをjsのクロージャに変換する、Closure::wrap メソッドを使用します。
その中で、InputEvent
のtarget.value
を取得したいので、event.targetをHtmlInputElement
にdyn_into
をして、Eventの型をキャストします。キャスト後にvalue()でカラーパレットで選択されたカラーを取得します。
Closure
を実装し終えた後に、input
タグにadd_event_listener_with_callbackでEventListener
の設定をします。
EventListenerをセットする際の注意点(forget)
以前の記事でも言っているのですが、作成したClosureをforgetすることで意図的にメモリリークしています。(メモリリークと言っていますが、「このクロージャをリークして、プログラム全体の期間中有効にする」と言う意味です。)
こう言った理由で、Closureをforgetしておかないと、スコープを抜けた際にdropしてしまうのでEventListener
として活用できなくなってしまいます。
以上のイベントを受け、canvas内では https://github.com/poccariswet/drawasm/blob/master/src/draw.rs#L24-L55
// mousedown { let context_copy = context.clone(); let state_copy = state.clone(); let mouse_down = Closure::wrap(Box::new(move |event: MouseEvent| { ... context_copy.set_stroke_style(&JsValue::from(state_copy.borrow().get_color())); context_copy.set_line_width(state_copy.borrow().get_pen_thin()); context_copy.move_to(new_x, new_y); ...
のように、stateに保存されたcolorをmousedown時(絵を描くとき)に取得して、set_stroke_style
メソッドを使用して描く色を変えています。
残りのtoolbar部分については、説明したinputの挙動と似ているので省略します(preview部分は次で説明)。
canvasのpreview部分について
さて次はcanvasに描いた絵をaddした際のpreview部分についてです。
見てわかる通り、cssでbox-shadowでくぼみを作っているところがpreviewエリアになります。 ここに画像を追加するIconをクリックするとcanvasに書かれている画像が追加され、プレビューされます。
ここの実装は以下を見るとわかるようにまた、Closure内(クリックイベント)での実装となりまうす。 https://github.com/poccariswet/drawasm/blob/master/src/toolbar.rs#L265-L303
let handle_click = Closure::wrap(Box::new(move || { let img = document_copy .create_element("img") .unwrap() .dyn_into::<HtmlImageElement>() .unwrap(); let url = canvas.to_data_url_with_type("image/png").unwrap(); state.borrow_mut().add_preview_image(url.clone()); // img set_src URL string img.set_src(&url); img.set_attribute("class", "preview-img").unwrap(); img.set_width(state.borrow().get_preview_width()); img.set_height(state.borrow().get_preview_height()); preview.append_child(&img).unwrap(); }) as Box<dyn FnMut()>);
このクロージャ内では、previewエリアに追加するためのimgタグの生成をし、そのimgタグのsrc
に、canvasのメソッドである、to_data_url_with_type("image/png")を使用してBase64
にしたimage情報を追加します。また、タイプがpng
になっているのは後々APNGをgenerateするためです。
その後は、前と同じようにset_attribute
やset_width
、set_height
で付加情報をつけ、previewのchildとして、追加します。
APNGをgenerateする部分について
最後にプレビューされた画像をAPNGを作成する部分についてです。 https://github.com/poccariswet/drawasm/blob/master/src/generate.rs#L96-L166
ここでは、プレビューする際にstateに追加したbase64にエンコードされた画像データをdata:image/png;base64,
部分だけ取り除き、逆にデコードしu8
のbufferにします。
rustのimage::load_from_memory_with_formatでimage::DynamicImage
(PNG)にエンコードします。その後に、自分で自作したAPNGのライブラリにその値を入れAPNGにエンコードしていきます。
エンコード後、Vec<u8>
のbufferが出来上がるので、それをblobに変換して、window.open_with_urlで開けるようにしていきます。
https://github.com/poccariswet/drawasm/blob/master/src/generate.rs#L149-L159
// ⚠️bufはAPNGにエンコードされたbuffer let b = js_sys::Uint8Array::new(&unsafe { js_sys::Uint8Array::view(&buf) }.into()); let array = js_sys::Array::new(); array.push(&b.buffer()); let blob = Blob::new_with_u8_array_sequence_and_options( &array, BlobPropertyBag::new().type_("image/png"), ) .unwrap(); let url = Url::create_object_url_with_blob(&blob).unwrap(); let window = window().unwrap(); window.open_with_url(&url).unwrap();
Blob::new_with_u8_array_sequence_and_optionsを使用し、blob作成していきます。
その前に、Blobを作成するためにbuf
(APNGエンコード後のbuffer)をjs側(browser側)でも相互運用できるようにUint8Array
型に変換し線形メモリへビューします。
https://rustwasm.github.io/wasm-bindgen/api/js_sys/struct.Uint8Array.html#method.view
Views into WebAssembly memory are only valid so long as the backing buffer isn't resized in JS. Once this function is called any future calls to Box::new (or malloc of any form) may cause the returned value here to be invalidated. Use with caution!
のように、js_sys::Uint8Array::view()
を使用する際には、unsafe{}
ブロックを使用しなければなりません。
https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob Syntax
var newBlob = new Blob(array, options);
array: An Array of ArrayBuffer, ArrayBufferView, Blob, USVString objects, or a mix of any of such objects, that will be put inside the Blob. USVString objects are encoded as UTF-8.
あとは、MDNに描いてあるように、blobを作成するための引数に描いてある通り、js_sys::Array::new()
でArrayを作成し、中にキャストしたUint8Array
を追加し、blob
を作成していきます。
blob
を作成後は、コード通り、urlを作成し、open_with_url
で次のタブに出来上がった画像をプレビューします。
こんな感じです。
あとは、この書いたコードをbuildし、WebAssemblyを吐き出してjs or html内でrun()
して終わりです!
今後
一旦ここで完成ですが、色々と機能を増やしたいことがあるのでwasmで行けるところまで挑戦したいと思います。 以下にやっていこうと思うことを並べていきます。
- gifエンコードのサポート
- うごメモのようにclearしても背景に前に描いたやつが薄く見える状態にする
- 絵を描くときの透過性を変えれるボタンの追加
- previewされた画像を一枚一枚編集/削除できる ...etc
終わりに
ここまで読んでいただきありがとうございます。 これを読んでいただいた方が、rustでwasmをやる興味を持っていただけたり、自分の記事がwasmをさわる際の参考にしてもらえれば幸いです。
また、JS書きたくないけど、Webアプリ作りたいと言う方が入ればぜひ試してみてください!
また、記事を読んでいて、疑問点やここ違くない?と言ってようなことがあればぜひ教えていただきたいのでコメント待ってます!
reference
- https://qiita.com/qnighy/items/4bbbb20e71cf4ae527b9
- https://doc.rust-jp.rs/book/second-edition/ch15-05-interior-mutability.html [*1]
- https://wasmbyexample.dev/examples/webassembly-linear-memory/webassembly-linear-memory.rust.en-us.html
- https://docs.rs/js-sys/0.3.35/js_sys/
- https://docs.rs/web-sys/0.3.35/web_sys/