iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Rust

30天解鎖 Rust 開發者工具箱系列 第 13

「Day 13」寫 Rust 寫到前端去~

  • 分享至 

  • xImage
  •  

前言

今天來介紹 yew

實作範例

https://ithelp.ithome.com.tw/upload/images/20250927/20177832pVmX8dI4iJ.png

https://ithelp.ithome.com.tw/upload/images/20250927/20177832V94WGfHF44.png

https://ithelp.ithome.com.tw/upload/images/20250927/20177832cm7culRGEp.png

本範例專案所使用的技術們

  • Yew - Rust 網頁框架
  • WebAssembly (WASM) - 網頁組件執行環境
  • Tailwind CSS - 實用優先的 CSS 框架
  • Trunk - WASM 網頁應用程式建置工具

🚀 安裝與設定

1. 建立專案

cargo new yew-app
cd yew-app

2. 安裝 Rust 依賴

cargo build

3. 安裝 Node.js 依賴

npm install

4. 建置 CSS 樣式

npm run build:css

🎯 使用教學

啟動開發伺服器

trunk serve

應用程式將在 http://localhost:8080 啟動。

基本操作

  1. 首頁導航

    • 點擊導航列中的連結在不同頁面間切換
    • 目前頁面的導航項目會有特殊的活躍狀態樣式
  2. 影片播放器使用

    • 點擊「載入影片」按鈕
    • 選擇本地的影片檔案(支援所有常見影片格式)
    • 影片將立即在播放器中載入並可播放
  3. 圖片載入器使用

    • 點擊「載入圖片」按鈕
    • 選擇本地的圖片檔案(支援所有常見圖片格式)
    • 圖片將在頁面中顯示

開發命令

# 建置應用程式
trunk build

# 啟動開發伺服器(自動重新載入)
trunk serve

# 建置生產版本
trunk build --release

# 監視 CSS 變更
npm run watch:css

# 建置 CSS(一次性)
npm run build:css

📁 專案結構

yew-app/
├── src/
│   ├── main.rs              # 主應用程式入口和主要組件
│   ├── routes.rs            # 路由定義和切換邏輯
│   └── components/          # UI 組件目錄
│       ├── mod.rs          # 組件模組聲明
│       ├── navbar.rs       # 導航列組件
│       ├── home.rs         # 首頁組件
│       ├── video_player.rs # 影片播放器組件
│       └── image_loader.rs # 圖片載入器組件
├── index.html               # HTML 模板
├── style.css                # 編譯後的 CSS 樣式
├── tailwind.css             # Tailwind CSS 來源
├── tailwind.config.js       # Tailwind 配置
├── postcss.config.js        # PostCSS 配置
├── Trunk.toml              # Trunk 建置配置
├── Cargo.toml              # Rust 依賴配置
└── package.json            # Node.js 依賴配置

main.rs

use yew::prelude::*;
use yew_router::prelude::*;

mod components;
mod routes;

/// Main application component that integrates routing and navigation
#[function_component(App)]
fn app() -> Html {
    html! {
        <BrowserRouter>
            <components::navbar::Navbar />
            <main class="w-full container mx-auto px-6 py-8">
                <Switch<routes::Route> render={routes::switch} />
            </main>
        </BrowserRouter>
    }
}

/// Application entry point
fn main() {
    // Initialize logging for WASM environment
    wasm_logger::init(wasm_logger::Config::default());

    // Mount the main App component to the DOM
    yew::Renderer::<App>::new().render();
}

routes.rs

use yew::prelude::*;
use yew_router::prelude::*;

/// Application routes
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
    #[at("/")]
    Home,
    #[at("/video")]
    Video,
    #[at("/image")]
    Image,
}

/// Route switcher function that maps routes to components
pub fn switch(routes: Route) -> Html {
    match routes {
        Route::Home => html! { <super::components::home::HomePage /> },
        Route::Video => html! { <super::components::video_player::VideoPlayer /> },
        Route::Image => html! { <super::components::image_loader::ImageLoader /> },
    }
}

mod.rs

pub mod home;
pub mod navbar;
pub mod video_player;
pub mod image_loader;

home.rs

use yew::prelude::*;
use yew_router::prelude::*;
use crate::routes::Route;

/// Home page component
#[function_component(HomePage)]
pub fn home_page() -> Html {
    html! {
        <section class="w-full container mx-auto px-6 py-10">
            <div class="bg-white rounded-2xl shadow-sm ring-1 ring-black/5 p-10 text-center">
                <h1 class="text-3xl font-semibold tracking-tight">{ "歡迎使用 Yew 示範" }</h1>
                <p class="mt-4 text-gray-600 leading-relaxed">
                    { "工具展示:影片播放器和圖片載入器。" }
                </p>
                <div class="mt-8 flex flex-col sm:flex-row gap-4 justify-center">
                    <Link<Route>
                        to={Route::Video}
                        classes={classes!(
                            "inline-flex",
                            "items-center",
                            "justify-center",
                            "gap-2",
                            "text-white",
                            "px-5",
                            "py-2.5",
                            "rounded-lg",
                            "cursor-pointer",
                            "text-base",
                            "transition-colors",
                            "bg-blue-600",
                            "hover:bg-blue-700",
                            "no-underline",
                            "shadow-sm"
                        )}
                    >
                        { "前往影片播放器" }
                    </Link<Route>>
                    <Link<Route>
                        to={Route::Image}
                        classes={classes!(
                            "inline-flex",
                            "items-center",
                            "justify-center",
                            "gap-2",
                            "text-gray-700",
                            "px-5",
                            "py-2.5",
                            "rounded-lg",
                            "cursor-pointer",
                            "text-base",
                            "transition-colors",
                            "border",
                            "border-black/10",
                            "hover:bg-black/5",
                            "no-underline"
                        )}
                    >
                        { "前往圖片載入器" }
                    </Link<Route>>
                </div>
            </div>
        </section>
    }
}

navbar.rs

use yew::prelude::*;
use yew_router::prelude::*;
use crate::routes::Route;

/// Navigation bar component
#[function_component(Navbar)]
pub fn navbar() -> Html {
    let current_route = use_route::<Route>();

    html! {
        <nav class="w-full sticky top-0 z-10 backdrop-blur bg-white/70 border-b border-black/5">
            <div class="container mx-auto px-4 py-3 text-center">
                <Link<Route>
                    to={Route::Home}
                    classes={classes!(
                        "text-gray-700",
                        "no-underline",
                        "px-4",
                        "py-2",
                        "rounded-full",
                        "transition-colors",
                        "mx-1.5",
                        "hover:bg-black/5",
                        if current_route == Some(Route::Home) { "bg-gray-900 text-white hover:bg-gray-900" } else { "" }
                    )}
                >
                    { "首頁" }
                </Link<Route>>
                <Link<Route>
                    to={Route::Video}
                    classes={classes!(
                        "text-gray-700",
                        "no-underline",
                        "px-4",
                        "py-2",
                        "rounded-full",
                        "transition-colors",
                        "mx-1.5",
                        "hover:bg-black/5",
                        if current_route == Some(Route::Video) { "bg-gray-900 text-white hover:bg-gray-900" } else { "" }
                    )}
                >
                    { "影片播放器" }
                </Link<Route>>
                <Link<Route>
                    to={Route::Image}
                    classes={classes!(
                        "text-gray-700",
                        "no-underline",
                        "px-4",
                        "py-2",
                        "rounded-full",
                        "transition-colors",
                        "mx-1.5",
                        "hover:bg-black/5",
                        if current_route == Some(Route::Image) { "bg-gray-900 text-white hover:bg-gray-900" } else { "" }
                    )}
                >
                    { "圖片載入器" }
                </Link<Route>>
            </div>
        </nav>
    }
}

image_loader.rs

use yew::prelude::*;
use web_sys::{HtmlInputElement, Url};

/// Image loader component with file upload functionality
#[function_component(ImageLoader)]
pub fn image_loader() -> Html {
    let image_url = use_state(|| None::<String>);
    let file_input_ref = use_node_ref();

    let on_file_change = {
        let image_url = image_url.clone();
        Callback::from(move |e: Event| {
            let input: HtmlInputElement = e.target_unchecked_into();
            if let Some(files) = input.files() {
                if let Some(file) = files.get(0) {
                    if let Some(old_url) = (*image_url).as_ref() {
                        Url::revoke_object_url(old_url)
                            .unwrap_or_else(|_| log::warn!("Failed to revoke image URL"));
                    }
                    match Url::create_object_url_with_blob(&file) {
                        Ok(url) => image_url.set(Some(url)),
                        Err(err) => log::error!("Error creating image object URL: {:?}", err),
                    }
                }
            }
        })
    };

    let trigger_file_input = {
        let file_input_ref = file_input_ref.clone();
        Callback::from(move |_| {
            if let Some(input) = file_input_ref.cast::<HtmlInputElement>() {
                input.click();
            }
        })
    };

    {
        let image_url_handle = image_url.clone();
        use_effect_with(image_url_handle, move |url_state_handle| {
            let url_to_revoke = (**url_state_handle).clone();
            move || {
                if let Some(url) = url_to_revoke {
                    Url::revoke_object_url(&url).unwrap_or_else(|_| {
                        log::warn!("Failed to revoke image URL on unmount/change")
                    });
                }
            }
        });
    }

    html! {
        <div class="bg-white p-8 rounded-2xl shadow-sm ring-1 ring-black/5 w-full max-w-[720px] text-center mx-auto">
            <h1 class="text-xl font-medium tracking-tight text-gray-900">{ "簡單圖片載入器" }</h1>
            <input
                type="file"
                accept="image/*"
                ref={file_input_ref}
                class="hidden"
                onchange={on_file_change}
            />
            <button
                onclick={trigger_file_input}
                class="inline-flex items-center justify-center gap-2 text-white px-5 py-2.5 rounded-lg cursor-pointer text-base transition-colors bg-green-600 hover:bg-green-700 shadow-sm"
            >
                { "載入圖片" }
            </button>
            {
                if let Some(url) = (*image_url).as_ref() {
                    html! {
                        <img
                            src={url.clone()}
                            alt="已載入的圖片"
                            class="w-full mt-4 rounded-xl object-contain max-h-[70vh] ring-1 ring-black/10"
                        />
                    }
                } else {
                    html! { <p class="mt-4 text-gray-500">{ "請選擇要顯示的圖片檔案。" }</p> }
                }
            }
        </div>
    }
}

video_player.rs

use yew::prelude::*;
use web_sys::{HtmlInputElement, Url};

/// Video player component with file upload functionality
#[function_component(VideoPlayer)]
pub fn video_player() -> Html {
    let video_url = use_state(|| None::<String>);
    let file_input_ref = use_node_ref();

    let on_file_change = {
        let video_url = video_url.clone();
        Callback::from(move |e: Event| {
            let input: HtmlInputElement = e.target_unchecked_into();
            if let Some(files) = input.files() {
                if let Some(file) = files.get(0) {
                    if let Some(old_url) = (*video_url).as_ref() {
                        Url::revoke_object_url(old_url)
                            .unwrap_or_else(|_| log::warn!("Failed to revoke video URL"));
                    }
                    match Url::create_object_url_with_blob(&file) {
                        Ok(url) => video_url.set(Some(url)),
                        Err(err) => log::error!("Error creating video object URL: {:?}", err),
                    }
                }
            }
        })
    };

    let trigger_file_input = {
        let file_input_ref = file_input_ref.clone();
        Callback::from(move |_| {
            if let Some(input) = file_input_ref.cast::<HtmlInputElement>() {
                input.click();
            }
        })
    };

    {
        let video_url_handle = video_url.clone();
        use_effect_with(video_url_handle, move |url_state_handle| {
            let url_to_revoke = (**url_state_handle).clone();
            move || {
                if let Some(url) = url_to_revoke {
                    Url::revoke_object_url(&url).unwrap_or_else(|_| {
                        log::warn!("Failed to revoke video URL on unmount/change")
                    });
                }
            }
        });
    }

    html! {
        <div class="bg-white p-8 rounded-2xl shadow-sm ring-1 ring-black/5 w-full max-w-[720px] text-center mx-auto">
            <h1 class="text-xl font-medium tracking-tight text-gray-900">{ "簡單影片播放器" }</h1>
            <input
                type="file"
                accept="video/*"
                ref={file_input_ref}
                class="hidden"
                onchange={on_file_change}
            />
            <button
                onclick={trigger_file_input}
                class="inline-flex items-center justify-center gap-2 text-white px-5 py-2.5 rounded-lg cursor-pointer text-base transition-colors bg-blue-600 hover:bg-blue-700 shadow-sm"
            >
                { "載入影片" }
            </button>
            {
                if let Some(url) = (*video_url).as_ref() {
                    html! {
                        <video
                            controls=true
                            src={url.clone()}
                            class="w-full mt-4 rounded-xl bg-black ring-1 ring-black/10"
                        >
                            { "您的瀏覽器不支援影片標籤。" }
                        </video>
                    }
                } else {
                    html! { <p class="mt-4 text-gray-500">{ "請選擇要播放的影片檔案。" }</p> }
                }
            }
        </div>
    }
}

上一篇
「Day 12」candle 實現近乎純 Rust 神經網路模型框架
下一篇
「Day 14」Tokio 架構與生態
系列文
30天解鎖 Rust 開發者工具箱14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言