自環境では動作していますが、学習中のためスマートな実装方法でない可能性があることをご了承下さい。
確認しているLeptosのバージョンは「0.8.15」です。
ここではLeptosでユーザーが選択したファイルを読み込み画面に出力する方法について記載します。
ファイル名を画面に表示する
ファイル名を取得する処理は以下の通りです。
use leptos::mount::mount_to_body;
use leptos::{component, view, IntoView};
use leptos::prelude::*;
use leptos::web_sys::HtmlInputElement;
fn main() {
mount_to_body(App);
}
#[component]
fn App() -> impl IntoView {
let file_name = RwSignal::new(String::new());
let on_change = move |ev| {
let input: HtmlInputElement = event_target(&ev);
if let Some(files) = input.files() {
if let Some(file) = files.get(0) {
file_name.set(file.name())
}
}
};
view! {
<input
type="file"
on:change=on_change
/>
<p>"選択されたファイル:" { file_name }</p>
}
}
ユーザーが選択したファイルのファイル名を取得するのにはEventをHtmlInputElementにキャストしてfiles()から取得します。
今回はひとつのファイルを読み込むのでfiles.get(0)で一番目のファイルを取得しています。
取得したファイルの名前をfile_nameに設定することでファイル名が表示させるようになります。
これでファイル名だけは表示できるかと思いきや、コンパイルエラーが発生します。
以下のような内容のものです。
no method named files found for struct HtmlInputElement
in the current scope [E0599] method not found in HtmlInputElement
これはHtmlInputElement型は存在しているものの、files()メソッドが実装されていないという状態になるからのようです。
何故かというとweb-sysはWeb APIをfeature単位で切り出す設計になっているからだそうです。
つまりこの場合はFile系のfeatureを有効にしないと実装されないという仕組みみたいです。
ですのでtomlを以下のように修正します。
[package]
name = "leptos-file"
version = "0.1.0"
edition = "2024"
[dependencies]
leptos = { version = "0.8.15", features = ["csr"] }
web-sys = { version = "0.3.83", features = [
"HtmlInputElement", "FileList", "File", "FileReader"
] }
ここで注目するのはweb-sysのfeaturesにいくつかの機能を有効にしているところです。
この修正により選択したファイル名が画面に表示されます。
読み込んだテキストファイルを画面に表示する
テキストファイルを読み込む処理は以下の通り。
use leptos::mount::mount_to_body;
use leptos::{component, view, IntoView};
use leptos::prelude::*;
use leptos::wasm_bindgen::JsCast;
use leptos::wasm_bindgen::prelude::Closure;
use leptos::web_sys::HtmlInputElement;
use web_sys::{FileReader, ProgressEvent};
fn main() {
mount_to_body(App);
}
#[component]
fn App() -> impl IntoView {
let content = RwSignal::new(String::new());
let on_change = move |ev| {
let input: HtmlInputElement = event_target(&ev);
if let Some(files) = input.files() {
if let Some(file) = files.get(0) {
let reader = FileReader::new().unwrap();
let reader_for_cb = reader.clone();
let content = content.clone();
let onload = Closure::wrap(Box::new(move |_e: ProgressEvent| {
let text = reader_for_cb.result().unwrap().as_string().unwrap();
content.set(text);
}) as Box<dyn FnMut(_)>);
reader.set_onload(Some(onload.as_ref().unchecked_ref()));
reader.read_as_text(&file).unwrap();
onload.forget();
}
}
};
view! {
<input
type="file"
on:change=on_change
/>
<pre>{ content }</pre>
}
}
ここは処理を把握しきれておらず、曖昧になっている場所がありますが、動作は確認できています。
勉強が必要ですね……。
ファイルはFileReaderを使用して読み込みます。
この読み込みは非同期処理になりますのでreader.onloadで読み込み完了時のコールバックを登録します。
reader.result()で読み込んだ内容をcontentに設定します。
ですのでファイル読み込み処理は
- 読み込み処理の開始は
read_as_text(&file)が呼ばれた時に開始 - 読み込み完了したら
onloadが呼ばれる - 読み込んだデータは
reader.resultに入る - 入ったデータを
contentに設定する
の順番で実行されます。
FileReaderの話
ここで生成したFileReaderのクローンを作成し、クロージャーではこのクローンを使用しています。
こうしないとコンパイルエラーが発生するんですよね。
Rustの仕様を考えればそうなのですが、すぐに気づけなかったため忘れないよう理由を記載しておきます。
何故コンパイルエラーが発生するかというと、クロージャーでmoveした値を後続の処理reader.set_onload(...)で使用しているからですね。
この問題を解消するためにクローンを作成し、クロージャー内でこれを使用することで解決しています。
コールバック処理
JavaScriptのreader.onload = () => {...}をRust + wasm-bindgenで表現するためにClosure::wrapを使用します。
クロージャーの引数に_eを記述している通りこの変数は使用しないためプレフィックスに_を付けています。
ここは使用しない変数なのですが、型を指定しています。
というのも指定しないとコンパイルエラーになるんですよね。
これは後続のBox<dyn FnMut(_)>で_と型推論に任せているのですが、この型が曖昧なため決定できないからです。
そのためe: ProgressEventと型を指定することでこれを解決できます。
解決する方法はいくつかありますが、この方法が一番簡単でしたのでこれを採用しています。
Box型を使用しているのはRustのクロージャーはコンパイル時にサイズが確定しないからです。
更にこの型をwasm-bindgenが期待する型に変換するためas Box<dyn Mut(_)を付けています。
クロージャー内の処理として、reader.resultに入った結果(JsValue)を文字列としてを取り出します(as_string)。
この文字列をcontentに設定することで画面に表示されます。
コールバック処理の登録
コールバック処理の登録はreader.set_onload(Some(onload.as_ref().unchecked_ref()));で行っています。
as_ref()は所有権を渡さないようにClosureの参照を渡しています。
unchecked_ref()はJavaScriptのFunctionとして扱うためのキャスト処理です。
ここら辺ふわっとした感じでしか理解できていないので要勉強です……。
読み込み処理の開始
reader.read_as_text(&file).unwrap();で読み込み処理が開始します。
読み込み処理が終わったらコールバック処理(onload)が呼び出され、テキストがcontentに設定されます。
onload.forget()について
処理の最期にonload.forget()が記述されていますが、これはRust側でコールバックを解放しないようにするためです。
というのもJavaScript側ではコールバックがいつ呼ばれるかわかないため、呼ばれるタイミングで既に解放されているため何も起きない場面が発生するからです。
つまり
Rustのスコープが終了した時点で参照が解放され、その後ブラウザ(JavaScript)がコールバック(onload)を呼び出そうとするが、既に参照がないので何も起きない。
という状況に陥ります。
しかもこれはエラーすら発生せず、ただ動作しなくなるだけのようです。
そのため、Rust側で参照の管理をさせないことでJavaScript側に管理を任せるようにするようです。
読み込んだ画像を画面に表示する
画像を読み込むには2通りの方法があるようです。
- Data URL方式
- Object URL方式
Data URL方式はbase64文字列にしてそれを<img src="..." />に設定します。
Object URL方式はブラウザ内部のバイナリを指す一時URL(blob:...)を作りそれを読み込みます。
Data URL方式
Data URL方式の処理は以下の通りです。
use leptos::mount::mount_to_body;
use leptos::{component, view, IntoView};
use leptos::prelude::*;
use leptos::wasm_bindgen::JsCast;
use leptos::wasm_bindgen::prelude::Closure;
use leptos::web_sys::HtmlInputElement;
use web_sys::{FileReader, ProgressEvent};
fn main() {
mount_to_body(App);
}
#[component]
fn App() -> impl IntoView {
let src = RwSignal::new(String::new());
let on_change = move |ev| {
let input: HtmlInputElement = event_target(&ev);
if let Some(files) = input.files() {
if let Some(file) = files.get(0) {
let reader = FileReader::new().unwrap();
let reader_for_cb = reader.clone();
let src = src.clone();
let onload = Closure::wrap(Box::new(move |_e: ProgressEvent| {
let data_url = reader_for_cb.result().unwrap().as_string().unwrap();
src.set(data_url);
}) as Box<dyn FnMut(_)>);
reader.set_onload(Some(onload.as_ref().unchecked_ref()));
reader.read_as_data_url(&file).unwrap();
onload.forget();
}
}
};
view! {
<div style:display="flex" style:flex-direction="column" style:gap="20px">
<input type="file" accept="image/*" on:change=on_change />
<img src=move || src.get() />
</div>
}
}
基本的にテキストを読み込む際の処理と変わりません。
読み込み処理がreader.read_as_text(&file)からreader.read_as_data_url(&file)に変わるだけです。
ただしbase64化はサイズが大きくなりがちですので大きい画像だとメモリーが厳しくなることもあります。
Object URL方式
Object URL方式の処理は以下の通りです。
use leptos::mount::mount_to_body;
use leptos::{component, view, IntoView};
use leptos::prelude::*;
use leptos::web_sys::HtmlInputElement;
use web_sys::{Url};
fn main() {
mount_to_body(App);
}
#[component]
fn App() -> impl IntoView {
let src = RwSignal::new(Some(String::new()));
let on_change = move |ev| {
let input: HtmlInputElement = event_target(&ev);
if let Some(files) = input.files() {
if let Some(file) = files.get(0) {
if let Some(old) = src.get_untracked() {
Url::revoke_object_url(&old).ok();
}
let url = Url::create_object_url_with_blob(&file).unwrap();
src.set(Some(url));
}
}
};
on_cleanup(move || {
if let Some(u) = src.get_untracked() {
Url::revoke_object_url(&u).unwrap()
}
});
view! {
<div style:display="flex" style:flex-direction="column" style:gap="20px">
<input type="file" accept="image/*" on:change=on_change />
{move || src.get().map(|u| view! {
<img src=u />
})}
</div>
}
}
やることは簡単で、Url::create_object_url_with_blob(&file)でblobのURLを作成し、それをsrcに設定するだけです。
Url::revoke_object_url()でメモリーを解放しないと掴みっぱなしになるので注意が必要です。
on_cleanup()はコンポーネント破棄時の保険としてUrl::revoke_object_url()を入れているようです。
こちらの方が速い・軽い・大きい画像でも扱い易いようです。
base64を使用する必要がない場合、基本Object URL方式の方が良さそうです。