Rust Wasm 實現井字棋遊戲

井字棋

TLDR; 本文介紹了 yew 的兩種開發使用方式,然後以 React 入門教程tic tac toe爲例,給出了使用 yew 的函數式組件實現了 rust wasm 版本。

友情提示:本文最終效果可以通過點擊下方的閱讀原文訪問

前言

《用 Rust 鍊金術創造 WASM 生命遊戲》我們初步瞭解了什麼是 Wasm,以及 Rust 怎麼寫 Wasm。

有過前端開發經驗的朋友也許會像筆者一樣好奇了——Rust 有沒有類似於 React[1] 或者 Vue[2] 這樣用於開發客戶端 webapp 的數據驅動框架呢?

答案是有的,那就是 yewstack/yew[3]。

認識一下 Yew

官方簡介上寫着:

Yew 是一個現代的 Rust 框架,用於使用 WebAssembly 創建多線程前端 Web 應用程序。

筆者的體驗是:Yew 就像 React 那樣,使用類似 JSX[4] 的語法開發頁面,同時支持 class 和函數式兩種組件編寫方式。

準備環境

基礎的 Rust 環境安裝筆者不再贅述,有不懂的讀者建議參考筆者的《Rust 學習筆記》從頭看起。

安裝打包工具

需要wasm-pack,執行cargo install wasm-pack安裝即可。

創建項目

使用--lib flag 創建一個名爲yew-tic-tac-toe的項目:

cargo new yew-tic-tac-toe --lib

添加依賴

然後在項目根目錄的cargo.toml添加依賴:

[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2.67"
yew-functional = { git = "https://github.com/yewstack/yew", rev = "f27e268"}
yew = { git = "https://github.com/yewstack/yew", rev = "f27e268"}
yew-router = { git = "https://github.com/yewstack/yew", rev = "f27e268"}

讀者們會注意到,筆者在這裏沒有使用 crates.io[5] 上發佈的 yew 包,而是直接使用 git 庫的代碼。

爲什麼直接使用 git 庫

解釋下:

一方面,目前的 yew 還未穩定,不可用於生產環境,所以用什麼版本沒那麼重要,越新越好。

另一方面,後面筆者會提到 yew 的函數式組件開發方式,並且會以函數式組件的方式進行開發,已發佈的版本里無法使用這一功能。

當然,也正因爲 yew 的不穩定,經常有激進的破壞性 api 更改 [6],所以讀者請注意保持和筆者寫這篇文章時使用的 commit 一樣(即rev = "f27e268"),以免出現行爲不一致的問題。

準備靜態資源

在項目根目錄創建一個static文件夾,並分別創建一個index.htmlstyle.css

cd yew-tic-tac-toe
mkdir static
touch static/index.html
touch static/style.css

接着在index.html中填充如下代碼:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta http-equiv="x-ua-compatible" content="ie=edge">
    <meta >
    <meta >
    <title>Yew Tic Tac Toe</title>
    <meta >
    <link rel="stylesheet" href="style.css"/>
    <script type="module">
        import init from "./wasm.js"
        init()
    </script>
</head>
<body></body>
</html>

也不能忘了style.css的內容:

body {
    font: 14px "Century Gothic", Futura, sans-serif;
    margin: 20px;
}
ol, ul {
    padding-left: 30px;
}
.board-row:after {
    clear: both;
    content: "";
    display: table;
}
.status {
    margin-bottom: 10px;
}
.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}
.square:focus {
    outline: none;
}
.kbd-navigation .square:focus {
    background: #ddd;
}
.head {
    top: 0;
    position: sticky;
}
.head-icon {
    width: 2em;
    height: 2em;
}
.head-icon-link {
    margin: 0 .2em;
}
.game {
    display: flex;
    flex-direction: row;
    padding-top: 20px;
}
.game-info {
    margin-left: 20px;
}

這些靜態資源一旦準備好,我們之後就不會再去碰它了。

後面,我們會通過編譯命令將代碼打包成 wasm 並構建到靜態目錄使用。

安裝服務端

構建完 wasm,我們還會需要使用一個簡易 http 服務端搭建服務,瀏覽效果。

讀者可以自行選擇,也可以使用 TheWaWaR/simple-http-server[7]:

// 安裝
cargo install simple-http-server
rehash
// 在項目根目錄使用
simple-http-server --index=index.html static

Class 風格開發簡介

如同 React 的歷史開發方式一般,Yew 首要支持了 Class 風格的組件開發。

完整的開發介紹讀者可以官方文檔 [8]。

簡而言之,開發人員需要創建自己的結構體,併爲它實現yew::prelude::Component這個 trait。

假設我在lib.rs創建一個叫HelloWorld的結構體,並實現了yew::prelude::Component,那麼我只要在lib.rs上編寫如下代碼:

use wasm_bindgen::prelude::*;
use yew::prelude::*;
// ... HelloWorld的實現代碼
#[wasm_bindgen(start)]
pub fn run_app() {
    App::<HelloWorld>::new().mount_to_body();
}

然後在根目錄執行wasm-pack build --target web --out-name wasm --out-dir ./static構建 wasm 到資源目錄,再使用 http 服務器瀏覽即可看到效果。

Component 詳解

接下來講解一下實現yew::prelude::Component需要做的工作。

關聯類型

Component 這個 trait 使用了兩個關聯類型參數type Messagetype Properties,交給用戶自行實現:

type Message只需要是'static的生命週期即可 •type Properties需要實現yew::html::Properties這個 trait,以及ClonePartialEq——幸運的是這些都可以直接使用#[derive(Clone, PartialEq, Properties)]派生指令讓編譯器自動派生

type Message通常用於網頁中的分發回調事件,例如 onclick 觸發事件、onsave 觸發事件等,因此通常使用enum枚舉實現。

type Properties是組件的屬性,類似於 React 組件中的props,用於父子組件間的參數傳遞,通常使用struct實現。

實現方法

除此之外,用戶還需要實現幾個結構體方法,滿足 Component 的實現需求。它們分別是:

fn create(props: Self::Properties, link: ComponentLink<Self>) -> Selffn update(&mut self, msg: Self::Message) -> boolfn change(&mut self, props: Self::Properties) -> boolfn view(&self) -> Html

其中create是組件的構造方法,用於初始化組件自身。

它包含兩個參數:一個是Properties用於父子傳參;一個是yew::html::ComponentLink<Self>,用於創建回調事件。

這也意味着我們需要在創建的結構體中包含這兩個參數。

update則用於組件更新時判斷事件發生時是否需要刷新組件的視圖效果,它使用一個Message作爲參數。

如前文所說,我們使用enum實現Message,然後在這個方法裏通過match的方式枚舉匹配觸發的事件,進行回調操作。

update則用於決定組件屬性父傳參變化時是否需要刷新組件視圖。

最後的view就是類似於JSX的 html 構造方法了。它使用一個html!宏創建視圖界面,然後被框架渲染到 html 中。

除此之外,還有些其他方法,默認不需要我們自己實現,比如fn rendered(&mut self, _first_render: bool)fn destroy(&mut self),分別是渲染之後 html 更新之前的方法和解構方法。在有需要的時候也可以覆蓋掉自己實現。

編寫 demo

在 Class 風格介紹的最後,筆者以一個簡單的 hello world 的實現結束。

use wasm_bindgen::prelude::*;
use yew::prelude::*;
#[derive(Clone, PartialEq, Properties, Default)]
struct Properties {
    name: String,
}
enum Message {
    ChangeName(String),
}
struct HelloWorld {
    link: ComponentLink<Self>,
    props: Properties,
}
impl HelloWorld {
    fn change_name(&mut self, name: String) {
        self.props.name = name;
    }
}
impl Component for HelloWorld {
    type Message = Message;
    type Properties = Properties;
    fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self {
        Self {
            link,
            props: Properties {
                name: "world".to_string(),
            },
        }
    }
    fn update(&mut self, msg: Self::Message) -> bool {
        match msg {
            Message::ChangeName(name) => {
                self.change_name(name);
            }
        };
        true
    }
    fn change(&mut self, props: Self::Properties) -> bool {
        if self.props != props {
            self.props = props;
            true
        } else {
            false
        }
    }
    fn view(&self) -> Html {
        html! {
        <div>
            <p>{"hello "}{self.props.name.clone()}</p>
            <Button onclick={self.link.callback(|name: String| Message::ChangeName(name))} />
        </div>
        }
    }
}
#[derive(Clone, PartialEq, Properties, Default)]
struct ButtonProperties {
    onclick: Callback<String>,
}
enum ButtonMessage {
    ChangName,
}
struct Button {
    props: ButtonProperties,
    link: ComponentLink<Self>,
}
impl Button {
    fn change_name(&mut self) {
        self.props.onclick.emit("yuchanns".to_string());
    }
}
impl Component for Button {
    type Message = ButtonMessage;
    type Properties = ButtonProperties;
    fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
        Self { props, link }
    }
    fn update(&mut self, msg: Self::Message) -> bool {
        match msg {
            ButtonMessage::ChangName => {
                self.change_name();
            }
        };
        true
    }
    fn change(&mut self, props: Self::Properties) -> bool {
        if self.props != props {
            self.props = props;
            true
        } else {
            false
        }
    }
    fn view(&self) -> Html {
        html! {
        <button onclick={self.link.callback(|_| ButtonMessage::ChangName)}>{"click me"}</button>
        }
    }
}
#[wasm_bindgen(start)]
pub fn run_app() {
    App::<HelloWorld>::new().mount_to_body();
}

在這段代碼片段裏,筆者創建了父組件<HelloWorld>和子組件<Button>,並實現了一個由父組件傳遞給子組件的改變名字的 onclick 回調事件。

class hello world

如果你跟筆者一樣是逐字代碼敲下來,相信敲到一半已經血壓升高~

缺點

是的,通篇代碼寫來的感覺就是,繁瑣,需要實現一堆方法,寫一堆枚舉事件定義。其中大多數屬於無效代碼。而限於 Rust 的 trait 代碼複用率不高,整個開發過程的體驗十分糟糕!

函數式開發風格簡介

針對這個問題,社區提出了很多意見。

於是 Yew 官方又仿照 React 的函數式組件,使用一系列宏極大提高了開發體驗。

用戶只需要在原本的 yew 框架基礎上,追加引入一個yew_functional包就可以使用。

yew_functional提供了一些hook,以及一個派生宏function_component。使用戶可以簡單通過編寫一個返回 JSX 視圖的函數以及使用鉤子來避免上述繁瑣的實現和操作。

下面看一個例子:

use yew::prelude::*;
use yew_functional::*;
#[function_component(HelloWorld)]
fn hello_world() {
    let greet = "hello world";
    html! {
        <div>{greet}</div>
    }
}
#[wasm_bindgen(start)]
pub fn run_app() {
    App::<HelloWorld>::new().mount_to_body();
}

看完上述代碼,讀者肯定會感到疑惑:“並沒有看到 HelloWorld 結構體,是不是代碼寫錯了?”

答案是否定的。這就是 Rust 宏強大之處的體現

派生宏function_component會在編譯器自動展開,將用戶編寫的hello_world方法派生成結構體HelloWorld,自動實現上面 class 小節中Component trait 需要實現的那些方法。所以雖然源碼上沒有,編譯的時候卻可以正確通過,構建結果也可以正常使用。

當然,涉及上述小節中的 demo 還需要結合這個包提供的hook機制才能實現。

Hook 詳解

目前yew_functional提供了五個內建hooks,它們分別是:

use_stateuse_reduceruse_refuse_effectuse_context

以及一個實現自定義 hook 的 trait。

如果讀者有使用 react 或者 vue3 的經驗,應當很容易就能理解到這些hooks的用途。

創建變量

use_state是用於創建變量的hook。它接收一個閉包,然後返回一個gettersetter。用戶可以通過getter讀取值,通過setter設置值。

爲什麼要用這麼做呢?個人的看法,僅供參考:

• 一方面,模仿 React,給相關背景的開發人員提供熟悉的體驗 • 另一方面,在 Rust 中,所有權機制的約束導致開發人員在編寫組件過程中常常要負擔大量的心智與可變借用打交道。使用這種方式可以減輕負擔(相信經歷過上面的 class demo 的讀者深有體會)

下面展示一下簡單的使用例子 (摘自官方文檔):

use std::rc::Rc;
use yew::prelude::*;
use yew_functional::*;
#[function_component(UseState)]
pub fn state() -> Html {
    let (
        counter,
        set_counter,
    ) = use_state(|| 0);
    let onclick = {
        let counter = Rc::clone(&counter);
        Callback::from(move |_| set_counter(*counter + 1))
    };
    html! {
        <div>
            <button onclick=onclick>{ "Increment value" }</button>
            <p>
                <b>{ "Current value: " }</b>
                { counter }
            </p>
        </div>
    }
}

這個例子實現了一個經典的計數器。訪客在點擊了 button 之後就會進行次數計數。

fn counter

關於這裏面有幾點需要特別說明:

use_state返回兩個值都是使用Rc指針進行了包裹的變量 • 使用Rc指針的原因是方便複製:在編寫組件的過程中,會有大量的複製需求 • 使用Callback::from可以創建一個含有閉包函數的枚舉變量綁定到回調事件上;通過該變量提供的clone方法,用戶可以將回調事件進行復制 (在父子傳參的時候很重要!)

創建變量 - 進階

use_reduceruse_state類似,只是增加了 class 中的枚舉事件功能。這樣可以實現對一個變量進行不同的事件設置的作用。

下面是使用例子 (摘自官方文檔):

use std::rc::Rc;
use yew::prelude::*;
use yew_functional::*;
#[function_component(UseReducer)]
pub fn reducer() -> Html {
    /// reducer's Action
    enum Action {
        Double,
        Square,
    }
    /// reducer's State
    struct CounterState {
        counter: i32,
    }
    let (
        counter, // the state
        // function to update the state
        // as the same suggests, it dispatches the values to the reducer function
        dispatch,
    ) = use_reducer(
        // the reducer function
        |prev: Rc<CounterState>, action: Action| CounterState {
            counter: match action {
                Action::Double => prev.counter * 2,
                Action::Square => prev.counter * prev.counter,
            },
        },
        // initial state
        CounterState { counter: 1 },
    );
    let double_onclick = {
        let dispatch = Rc::clone(&dispatch);
        Callback::from(move |_| dispatch(Action::Double))
    };
    let square_onclick = Callback::from(move |_| dispatch(Action::Square));
    html! {
        <>
            <div>{ counter.counter }</div>
            <button onclick=double_onclick>{ "Double" }</button>
            <button onclick=square_onclick>{ "Square" }</button>
        </>
    }
}

可以看到該hook返回的是一個getter和一個dispatch分發方法,可以進行事件分發。

引用節點

有時候我們需要使用組件存儲一些狀態,而這不能依賴於組件本身,因爲組件會被刷新:

例如,導航菜單中,我們需要鼠標在導航組件及其子組件懸浮時,自動保持導航組件的展開狀態;在離開導航組件時則收縮。

像上面這種例子,如果僅依靠 css 的 hover 判斷,那麼鼠標在子組件上懸浮時是無法阻止導航收縮的。這就是use_ref的作用。

下面是使用例子 (摘自官方文檔):

use yew::prelude::*;
use yew_functional::*;
#[function_component(UseRef)]
pub fn ref_hook() -> Html {
    let (message, set_message) = use_state(|| "".to_string());
    let message_count = use_ref(|| 0);
    let onclick = Callback::from(move |_e| {
        let window = yew::utils::window();
        if *message_count.borrow_mut() > 3 {
            window.alert_with_message("Message limit reached");
        } else {
            *message_count.borrow_mut() += 1;
            window.alert_with_message("Message sent");
        }
    });
    let onchange = Callback::from(move |e| {
        if let ChangeData::Value(value) = e {
            set_message(value)
        }
    });
    html! {
        <div>
            <input onchange=onchange value=message />
            <button onclick=onclick>{ "Send" }</button>
        </div>
    }
}

這是一個會統計信息發送次數的組件,在達到一定信息次數後就會停止發送並提示已滿。

fn ref

use_effect

use_effect類似於 class 風格中的構造和解構方法。它由兩部分組成:

• 首先是一個函數體:裏面的內容會在組件構造時執行,且只執行一次 • 然後返回值是一個閉包:裏面的內容會在組件解構時執行,且執行一次

沒錯,和 React 很像。但是還沒有 React 那麼強大。

React 的use_effect還有第二個參數,是一個數組,用於確定組件依賴哪些參數變更時進行渲染更新。

下面是使用例子 (摘自官方文檔):

use std::rc::Rc;
use yew::prelude::*;
use yew_functional::*;
#[function_component(UseEffect)]
pub fn effect() -> Html {
    let (counter, set_counter) = use_state(|| 0);
    {
        let counter = counter.clone();
        use_effect(move || {
            // Make a call to DOM API after component is rendered
            yew::utils::document().set_title(&format!("You clicked {} times", counter));
            // Perform the cleanup
            || yew::utils::document().set_title("You clicked 0 times")
        });
    }
    let onclick = {
        let counter = Rc::clone(&counter);
        Callback::from(move |_| set_counter(*counter + 1))
    };
    html! {
        <button onclick=onclick>{ format!("Increment to {}", counter) }</button>
    }
}

該代碼會在網頁的標題記錄你點擊的次數,並在組件銷燬後,重置標題。

關於其他

限於精力,筆者還未弄懂自定義hookuse_context(官網例子報錯,無效),以後再補。

編寫 demo

筆者在此也給出上面小節中實現繁瑣的 demo 的函數式簡潔實現方式:

use wasm_bindgen::prelude::*;
use yew::prelude::*;
use yew_functional::*;
#[function_component(HelloWorld)]
fn hello_world() -> Html {
    let (name, set_name) = use_state(|| "world".to_string());
    let onclick = Callback::from(move |name: String| set_name(name));
    html! {
        <div>
            <p>{"hello "}{name}</p>
            <Button onclick=onclick />
        </div>
    }
}
#[derive(Clone, PartialEq, Properties)]
struct ButtonProps {
    onclick: Callback<String>,
}
#[function_component(Button)]
fn button(props: &ButtonProps) -> Html {
    let onclick = {
        let onclick = props.onclick.clone();
        Callback::from(move |_| onclick.emit("yuchanns".to_string()))
    };
    html! {
        <button onclick=onclick>{"click me"}</button>
    }
}
#[wasm_bindgen(start)]
pub fn run_app() {
    App::<HelloWorld>::new().mount_to_body();
}

顯而易見,相同的功能,簡潔了很多!

這裏值得一提的就是,在函數式組件中,父子傳參是通過引用的方式傳入的。

而傳參中如果包含了Callback類型的參數,在閉包中使用時,需要通過clone的方式獲取一個引用副本,否則無法使用。這個細節困擾了筆者好幾天才發現。

React 經典教程:Tic Tac Toe

請原諒筆者,原本打算在這一小節詳細講述井字棋遊戲怎麼使用 yew 實現。

然而在寫了上面這一大段內容之後,筆者感到實在沒有精氣神來繼續剩下的計劃,因此直接給出兩個演示 demo,分別使用 class 和函數式方式開發的:

•class 井字棋演示:http://yew-tic-tac-toe.yuchanns.xyz/• 函數式井字棋演示:https://yew-fn-tic-tac-toe.yuchanns.xyz/

關於源碼

本文中描述的相關代碼可以在 yuchanns/rustbyexample[9] 找到。

這是一個筆者創建的學習 Rust 過程中記錄各種 demo 的 git 倉庫。歡迎各位觀衆 star 關注,以及 fork 和 pr 添加新的 demo,大家一起學習進步!

如果你覺得這篇文章不錯,有更多心得想和筆者交流,歡迎添加我的微信,備註【來自 代碼鍊金工坊】!

引用鏈接

[1] React: https://zh-hans.reactjs.org/
[2] Vue: https://vuejs.org/
[3] yewstack/yew: https://github.com/yewstack/yew
[4] JSX: https://reactjs.org/docs/introducing-jsx.html
[5] crates.io: https://crates.io/
[6] 激進的破壞性 api 更改: https://github.com/yewstack/yew/issues/1549
[7] TheWaWaR/simple-http-server: https://github.com/TheWaWaR/simple-http-server
[8] 官方文檔: https://yew.rs/docs/en/
[9] yuchanns/rustbyexample: https://github.com/yuchanns/rustbyexample

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/j8Vhc1ca2X-6PEaUI845EA