大約兩週前,我們 展開 我們的行動,集體建構 Gloo,一個利用 Rust 和 Wasm 建構快速又可靠的 Web 應用程式和函式庫的模組化工具包。我們知道我們希望透過推出可重複使用的獨立函式庫來積極培養 Rust 和 Wasm 函式庫生態系統,無論您使用純 Rust 編寫全新的 Web 應用程式、建構自己的架構、或將採用 Rust 產生的 Wasm 手術植入既有的 JavaScript 專案中,都能在這些函式庫的協助下順利進行。然而,我們當時仍不清楚也沒有決定好應該如何設計並展示這些可重複使用的部分。

採用洋蔥層的 API

很高興地告訴各位,在問題串中進行了一些協作討論之後,我們已經想出一個很有潛力的 Gloo API 設計方式,並在 CONTRIBUTING.md 中正式寫出來。我將這個方式稱為採用「洋蔥層」設計 API。

簡單來說,我們希望在原始的 -sys 繫結的基礎上建構中階抽象函式庫、在中階 API 的基礎上建構期貨和串流整合、以及在这一切之上再建構高階 API。但是,最重要的關鍵在於:每一層都必須公開曝露並且可重複使用。

雖然這個 API 設計方式絕對稱不上創新,但我們希望有意識地按照這種方式進行,才能

  • 最大化整個生態系的可重複使用性,以及
  • 在建構高階 API 時運用我們的中階 API,確保它們的通用性和適當性,以作為穩固的基礎。

在我們逐一檢視每一個層級時,我們將使用 Web API 的 setTimeoutsetInterval 作為運行範例。

核心:wasm-bindgenjs-sysweb-sys

最內層是建構在 wasm-bindgenjs-sysweb-sys 之上的原始繫結。這些繫結快速且具有較小的程式碼檔案大小,並且相容於 特定主機繫結建議

它們並非隨時都非常符合人體工學。有時,直接使用原始的 web-sys 繫結元件的感覺就像在調用原始 libc 呼叫,而不是善用 Rust 的 std 抽象。

以下使用原始 web-sys 繫結元件,來執行 500 毫秒逾時後的作業

use wasm_bindgen::{closure::Closure, JsCast};

// Create a Rust `FnOnce` closure that is exposed to JavaScript.
let closure = Closure::once(move || {
    do_some_operation();
});

// Get the JavaScript function that reflects our Rust closure.
let js_val = closure.as_ref();
let js_func = js_val.unchecked_ref::<js_sys::Function>();

// Finally, call the `window.setTimeout` API.
let timeout_id = web_sys::window()
    .expect("should have a `window`")
    .set_timeout_with_callback_and_timeout_and_arguments_0(js_func, 500)
    .expect("should set a timeout OK");

// Then, if we ever decide we want to cancel the timeout, we do this:
web_sys::window()
    .expect("should have a `window`")
    .clear_timeout_with_handle(timeout_id);

回呼函數層級

當看到原始 web-sys 的使用情況時,會發現出現一些類型轉換雜訊、令人遺憾的方法名稱,以及一些 unwrap 來忽略臨界情況場景,在那些情況下,我們會偏好擲回明顯的錯誤訊息,而不是含糊不清地繼續執行。我們可以透過我們的「中階」API 層級中的第一個層級,來清除這些內容,在計時器的案例中,也就是 gloo_timers crate 中的 callbacks 模組(它也會以 gloo::timers 的名稱從 gloo 保護傘 crate 中重新匯出)。

建立在 -sys 繫結元件之上的第一個「中階」API 會公開 Web 上所有相同的功能和設計,但會使用適當的 Rust 類型。例如,在此層級中,我們不會採取包含 js_sys::Function 中的未輸入類型 JavaScript 函式,而是採取任何 F: FnOnce()。此層級基本上為觀念最少、直接轉譯到 Rust 的 API。

use gloo::timers::callbacks::Timeout;
// Alternatively, we could use the `gloo_timers` crate without the rest of Gloo:
// use gloo_timers::callbacks::Timeout;

// Already, much nicer!
let timeout = Timeout::new(500, move || {
    do_some_operation();
});

// If we ever decide we want to cancel our delayed operation, all we do is drop
// the `timeout` now:
drop(timeout);

// Or if we never want to cancel, we can use `forget`:
timeout.forget();

將 Futures 和 Streams 加入層級

下一個要新增的層級,就是整合 Rust 生態系統中廣受歡迎的特質和函式庫,比如 Futureserde。對於我們正在執行的 gloo::timers 範例來說,這表示我們實作透過 setTimeout 支援的 Future,以及透過 setInterval 支援的 Stream 實作。

use futures::prelude::*;
use gloo::timers::futures::TimeoutFuture;

// By using futures, we can use all the future combinator methods to build up a
// description of some asynchronous task.
let my_future = TimeoutFuture::new(500)
    .and_then(|_| {
        // Do some operation after 500 milliseconds...
        do_some_operation();

        // and then wait another 500 milliseconds...
        TimeoutFuture::new(500)
    })
    .map(|_| {
        // after which we do another operation!
        do_another_operation();
    })
    .map_err(|err| {
        handle_error(err);
    });

// Spawn our future to run it!
wasm_bindgen_futures::spawn_local(my_future);

請注意,我們現在使用 futures 0.1,因為我們已經用盡全力讓 Wasm 生態系統相容於穩定的 Rust,但等到新的 std::future::Future 設計一穩定下來,我們就計畫切換過去。我們也十分期待 async/await 的到來!

更多層級?

這是我們在 setTimeoutsetInterval API 所使用的全部層級。不同的 Web API 會有不同的層級集合,這是正常的。並非每個 Web API 都會使用回呼函數,因此每個 Gloo crate 中永遠都有 callbacks 模組是不切實際的。重點在於,我們積極找尋層級,讓它們公開而可重複使用,並在較底階的層級之上建立較高階的層級。

如果合理,我們可能還會將更高級的層級新增到其他 Web API。例如,File APIFileReader 介面會公開某些方法,這些方法不應該在某個特定事件觸發之前呼叫,任何在那之前呼叫的嘗試都會引發例外。我們可以用 基於狀態機的 Future 來編寫,甚至不讓你呼叫那些方法,直到事件觸發且狀態機達到某個狀態。在編譯時利用型別以提升使用體驗和正確性!

另一個未來方向是加入更多整合層,整合 Rust 箱子生態系中更多部分。例如,新增功能反應式程式風格的層級,方法是透過 futures-signals 箱子dominator 架構也使用此箱子。

事件

Gloo 目前正在積極設計的工作之一,就是如何製作我們的事件目標和監聽器層級。事件用於大部分的 Web API,因此我們必須做好這個設計非常重要,因為它在許多的其他箱子中會扮演基礎角色。儘管這個設計還沒有 100% 到位,不過我們目前的進度令我感到非常滿意。

web_sys::Eventweb_sys::EventTarget::add_event_listener_with_callback 之上,我們建立了一個層級,可用來 加入和移除事件監聽器,並透過 RAII 風格的 drop 時自動清理來管理它們的生命週期。

我們可以使用這個 API 來建立慣用 Rust 型別,這些型別可以附加事件監聽器,當型別被 drop 時會自動從 DOM 中移除。

use futures::sync::oneshot;
use gloo::events::EventListener;

// A prompt for the user.
pub struct Prompt {
    receiver: oneshot::Receiver<String>,

    // Automatically removed from the DOM on drop!
    listener: EventListener,
}

impl Prompt {
    pub fn new() -> Prompt {
        // Create an `<input>` to prompt the user for something and attach it to the DOM.
        let input: web_sys::HtmlInputElement = unimplemented!();

        // Create a oneshot channel for sending/receiving the user's input.
        let (sender, receiver) = oneshot::channel();

        // Attach an event listener to the input element.
        let listener = EventListener::new(&input, "input", move |_event: &web_sys::Event| {
            // Get the input element's value.
            let value = input.value();

            // Send the input value over the oneshot channel.
            sender.send(value)
                .expect_throw(
                    "receiver should not be dropped without first removing DOM listener"
                );
        });

        Prompt {
            receiver,
            listener,
        }
    }
}

// A `Prompt` is also a future, that resolves after the user input!
impl Future for Prompt {
    type Item = String;
    type Error = ();

    fn poll(&mut self) -> Poll<Self::Item, Self::Error> {
        self.receiver
            .poll()
            .map_err(|_| {
                unreachable!(
                    "we don't drop the sender without either sending a value or dropping the whole Prompt"
                )
            })
    }
}

在該層級之上,我們使用 Rust 的特質系統來設計 更高級別的靜態事件 API,這會讓事件轉型更安全、更能進行靜態檢查,並確保您在監聽的事件型別中沒有錯字。

use gloo::events::{ClickEvent, on};

// Get an event target from somewhere.
let target: web_sys::EventTarget = unimplemented!();

// Listen to the "click" event, know that you didn't misspell the event as
// "clik", and also get a nicer event type!
let click_listener = on(&target, move |e: &ClickEvent| {
    // The `ClickEvent` type has nice getters for the `MouseEvent` that
    // `"click"` events are guaranteed to yield. No need to dynamically cast
    // an `Event` to a `MouseEvent`.
    let (x, y) = event.mouse_position();

    // ...
});

這些事件 API 目前仍在開發中,有些問題必須解決,但我對它們感到非常期待,我們希望在我們建立其他使用它們的 Gloo 箱子時,這些 API 能發揮很大的功效。

參與貢獻!

一起來打造 Gloo!想要參與嗎?