使用 Rust/WebAssembly 和 web-sys 实现 Reactive Canvas,或者说我如何学会不再担心并爱上宏

2025-06-04

使用 Rust/WebAssembly 和 web-sys 的 Reactive Canvas

或者我如何学会不再担心并爱上宏

或者我如何学会不再担心并爱上宏

距离上次我用某个深奥的堆栈搭建一个带滑块的可调整大小的圆点已经有一段时间了。是时候写第三章了!我猜这应该算是一个系列了。

前两个演示使用的语言是将整个应用编译为常规 JavaScript 进行解释执行。这次,我们将先将应用编译为 WebAssembly,然后让 JavaScript 加载它。

和往常一样,这些 dot demo 的实现对于这个应用来说有点儿过度了。这个尤其如此。卷起袖子,我们去抓取一些 DOM 吧。

管道

本节内容主要摘自《RustWasm 手册》。如果您计划进一步学习 Rust 和 WebAssembly,请直接前往该手册(或现在)。您需要 Rust 工具链和 Node/NPM 才能继续学习。

首先,创建一个新的库类型板条箱:

$ cargo new wasm-dot --lib
$ cd wasm-dot
Enter fullscreen mode Exit fullscreen mode

我们需要添加wasm-bindgen。这个 crate 会自动为我们生成所有 JS <-> Rust 的 FFI 粘合剂,这也是 Rust 成为编写 WebAssembly 的绝佳选择的主要原因。Cargo.toml在 crate 根目录中打开它,并使其如下所示:

[package]
name = "wasm-dot"
description = "Demo canvas wasm app"
license = "MIT"
version = "0.1.0"
authors = ["You <you@yourcoolsite.you>"]
edition = "2018"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
Enter fullscreen mode Exit fullscreen mode

cratecdylib类型将生成一个动态系统库,以便加载到其他语言中。现在打开它src/lib.rs并使其看起来像这样:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    // import the alert() js function
    pub fn alert(s: &str);
}

// export a Rust function that uses the imported JS function
#[wasm_bindgen]
pub fn say_hi() {
    alert("Hi from Rust/Wasm!");
}
Enter fullscreen mode Exit fullscreen mode

我们导入 JavaScriptalert()函数,并导出调用say_hi()该函数的 Rust 函数。这就是我们需要做的,wasm_bindgen只需处理细节即可。这只是为了确保两个方向都能按预期工作。

rustwasm 团队还提供了一个名为 的工具,wasm-pack用于自动化 WebAssembly 打包以及与 npm 集成。您只需安装一次cargo install wasm-pack,即可使用它来构建您的软件包:

$ wasm-pack build
[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...
    Finished release [optimized] target(s) in 0.00s
[INFO]: :-) Done in 0.05s
[INFO]: :-) Your wasm pkg is ready to publish at ./pkg.
Enter fullscreen mode Exit fullscreen mode

您将在里面pkg/找到部署所需的一切,并准备好导入任何 npm 项目。我们现在只需要一个可以使用它的项目!rustwasm 小组已经考虑到了一切,因此我们准备了一个模板——使用它来创建一个新项目:

$ npm init wasm-app www
Enter fullscreen mode Exit fullscreen mode

www文件夹现在包含一个网页,其中设置了所有机制来加载您的 wasm 库并从中调用它index.js

import * as wasm from "hello-wasm-pack";

wasm.greet();
Enter fullscreen mode Exit fullscreen mode

这里包含一个存根,以便它可以按原样运行,但我们不想从 导入hello-wasm-pack,而是想使用我们正在开发的应用程序。为了使其指向正确的方向,请打开www/package.json并添加一个dependencies键,直接指向 的pkg输出目录wasm-pack

  // ..
  "dependencies": {
    "wasm-dot": "file:../pkg"
  }
Enter fullscreen mode Exit fullscreen mode

现在我们可以指出www/index.js

import * as wasm from "wasm-dot";

wasm.say_hi();
Enter fullscreen mode Exit fullscreen mode

让我们看看它是否能做到:

$ npm install // because we added a dependency - us!
$ npm run start
Enter fullscreen mode Exit fullscreen mode

您应该在以下位置看到所请求的警报localhost:8080

hello wasm 警告框截图

好极了!现在我们可以迭代了。我建议此时打开第二个终端。在一个终端中运行npm run start并保持打开状态,在另一个终端中,wasm-pack build每当你对 Rust 进行更改时调用它。

布局

为了应对 JavaScript 世界,该wasm-bindgen项目提供了两个重要的 crate:web-sys提供所有 Web API 的绑定(!!),以及js-sys提供所有 ECMAScript 内容,例如ArrayDate(!!)。是的,他们已经完成了艰苦的工作。这很酷,你不需要手动定义extern 或任何其他东西。相反,只需从in 中Document.createElement提取我们需要的内容即可web-sysCargo.toml

[dependencies]
wasm-bindgen = "0.2"

[dependencies.web-sys]
version = "0.3"
features = [
    "Attr",
    "CanvasRenderingContext2d",
    "Document",
    "Element",
    "Event",
    "EventTarget",
    "HtmlCanvasElement",
    "HtmlElement",
    "HtmlInputElement",
    "Node",
    "Text",
    "Window"
]
Enter fullscreen mode Exit fullscreen mode

这是一个巨大的 crate,所以每个接口都经过功能门控。你只能使用你需要的。如果你尝试调用某个函数,但它提示你该函数不存在,请仔细检查 API 文档。它总是会告诉你给定方法需要哪些功能:

功能门控截图

为了确保一切符合 Groovy 规范,我们将自己构建一个 DOM 节点,既像 JS 风格,又像 Rust 风格。删除alert()测试代码src/lib.rs并添加:

#[wasm_bindgen]
pub fn run() {
    // get window/document/body
    let window = web_sys::window().expect("Could not get window");
    let document = window.document().expect("Could not get document");
    let body = document.body().expect("Could not get body");

    mount_app(&document, &body);
}

fn mount_app(document: &Document, body: &HtmlElement) {
    mount_title(&document, &body);
}

// Create a title
fn mount_title(document: &Document, body: &HtmlElement) {
    // create title element
    let title = document
        .create_element("h1")
        .expect("Could not create element");
    let title_text = document.create_text_node("DOT"); // always succeeds
    title
        .append_child(&title_text)
        .expect("Could not append child to title");

    // append to body
    body.append_child(&title)
        .expect("Could not append title to body");
}
Enter fullscreen mode Exit fullscreen mode

此代码使用web_sys调用手动为标题构建一个 DOM 节点并将其安装到页面正文。

现在say_hi()我们需要run()调用www/index.js

import * as wasm from "wasm-dot";

wasm.run();
Enter fullscreen mode Exit fullscreen mode

wasm-pack build通过运行并重新加载来查看它是否有效localhost:8080

dom节点截图

哇哦。你看到这个标题有多快并且融入了 WASM 吗?!

是的,你没有,但仍然很棒。继续之前,我们先来处理一下错误处理的情况。所有这些web-sys调用都会返回一个Result<T, JsValue>。在这个小演示中,我们不会处理其他类型的错误,所以只需为其添加别名即可:

type Result<T> = std::result::Result<T, JsValue>;
Enter fullscreen mode Exit fullscreen mode

现在,我们可以让函数返回 aResult<()>并使用该?运算符,而不必expect()到处乱用。重构run()以利用此功能:

fn get_document() -> Result<Document> {
    let window = web_sys::window().unwrap();
    Ok(window.document().unwrap())
}

#[wasm_bindgen]
pub fn run() -> Result<()> {
    let document = get_document()?;
    let body = document.body().unwrap();

    mount_app(&document, &body)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

拉出get_document()部分将有助于我们稍后定义事件监听器。不过,首先我们需要定义所需的 DOM 树。以下是我们在 HTML 中想要实现的目标:

  <div id="rxcanvas">
    <span id="size-output"></span>
    <input id="size" type="range" min="1" max="100" step="5">
    <label for="size">- Size</label>
    <p>
      <canvas id="dot-canvas"></canvas>
    </p>
  </div>
Enter fullscreen mode Exit fullscreen mode

如果你曾经通过 JavaScript 操作过 DOM,那么现在基本可以开始了。然而,在 Rust 中,这太冗长了。看看创建一个简单的元素的函数<h1>DOT</h1>有多大!我之前保证过会有宏——现在开始吧。

对于初学者来说,宏是一小段代码,在 Rust 代码执行之前会将其扩展为其他代码。在 Rust 中,它们看起来像函数调用,只是末尾带有一个感叹号。但它们根本不是函数调用——当编译器遇到你的模块时,它会将任何找到的宏扩展为你(或库)定义的完整 Rust 代码。这是一种自动代码生成的机制!

这种语法是 Rust 中唯一能看到括号模式的地方thing { () => {} }。它有自己独特的语法。参数以 a 为前缀$,放在括号中,在扩展过程中会被复制到 Rust 代码中的花括号中,就在代码的正确位置。

Rust 实际上有另一种类型的宏,称为过程宏,它更加强大和神秘,但现在对macro_rules!我们来说已经足够了。

下面是一个宏,用于将任意数量的属性附加到 DOM 元素,并以 2 元组的形式传递:

macro_rules! append_attrs {
    ($document:ident, $el:ident, $( $attr:expr ),* ) => {
        $(
            let attr = $document.create_attribute($attr.0)?;
            attr.set_value($attr.1);
            $el.set_attribute_node(&attr)?;
        )*
    }
}
Enter fullscreen mode Exit fullscreen mode

每个要扩展的参数都标有一个 token 类型—— anident允许我们传递一个 Rust 名称,而 anexpr可以接受任何 Rust 表达式(在本例中是一个二元组)。调用时,每个参数都会使用我们传入的内容,将这段 Rust 代码块粘贴到我们的函数中。

这个宏是可变的,这意味着它可以接受可变数量的参数。它的$( $name:expr ),*语法意味着,对于给定的零个或多个参数,它将执行此代码块,并将代码副本粘贴到此处传递的每个参数的花括号中。每次执行时,我们正在处理的参数都会获得 name $attr

您可以像这样调用它,并为每个属性添加尽可能多的尾随元组参数:

append_attrs!(document, label, ("for", "size"));
Enter fullscreen mode Exit fullscreen mode

不过,我们可以做得更好——宏可以调用其他宏!我们可以通过定义一些辅助函数,将所有内容精简到最低限度:

macro_rules! append_text_child {
    ($document:ident, $el:ident, $text:expr ) => {
        let text = $document.create_text_node($text);
        $el.append_child(&text)?;
    };
}

macro_rules! create_element_attrs {
    ($document:ident, $type:expr, $( $attr:expr ),* ) => {{
        let el = $document.create_element($type)?;
        append_attrs!($document, el, $( $attr ),*);
        el}
    }
}

macro_rules! append_element_attrs {
    ($document:ident, $parent:ident, $type:expr, $( $attr:expr ),* ) => {
        let el = create_element_attrs!($document, $type, $( $attr ),* );
        $parent.append_child(&el)?;
    }
}

macro_rules! append_text_element_attrs {
    ($document:ident, $parent:ident, $type:expr, $text:expr, $( $attr:expr ),*) => {
        let el = create_element_attrs!($document, $type, $( $attr ),* );
        append_text_child!($document, el, $text);
        $parent.append_child(&el)?;
    }
}
Enter fullscreen mode Exit fullscreen mode

有两个“顶级”宏,append_element_attrs分别是 和append_text_element_attrs。前者会将一个具有指定属性的无子元素附加到指定的父元素,后者会包含一个文本节点子元素。请注意,要将可变尾随参数向下传递,只需在花括号扩展中使用相同的语法,但省略expr类型:

let el = create_element_attrs!($document, $type, $( $attr ),* );
Enter fullscreen mode Exit fullscreen mode

mount_title()现在我们可以用一个宏调用来替换整个函数:

fn mount_app(document: &Document, body: &HtmlElement) -> Result<()> {
    append_text_element_attrs!(document, body, "h1", "DOT",);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

我还添加了新的返回类型,因此现在我们Ok(())在末尾返回一个 simple 来表示成功。这个宏扩展包含?运算符,现在可以正常工作了!

注意,后面的逗号"DOT"是必需的——这就是这个宏接受的“零个或多个”属性。虽然我们避免了这么多样板代码。初始函数是编译器在构建二进制文件时看到的,我们省去了输入所有内容的麻烦。感谢宏!Thacros。

以下是这只该死的猫头鹰的其余部分:

fn mount_canvas(document: &Document, parent: &Element) -> Result<()> {
    let p = create_element_attrs!(document, "p",);
    append_element_attrs!(document, p, "canvas", ("id", "dot-canvas"));
    parent.append_child(&p)?;
    Ok(())
}

fn mount_controls(document: &Document, parent: &HtmlElement) -> Result<()> {
    // containing div
    let div = create_element_attrs!(document, "div", ("id", "rxcanvas"));
    // span
    append_text_element_attrs!(
        document,
        div,
        "span",
        &format!("{}", STARTING_SIZE),
        ("id", "size-output")
    );
    // input
    append_element_attrs!(
        document,
        div,
        "input",
        ("id", "size"),
        ("type", "range"),
        ("min", "5"),
        ("max", "100"),
        ("step", "5")
    );
    // label
    append_text_element_attrs!(document, div, "label", "- Size", ("for", "size"));
    // canvas
    mount_canvas(&document, &div)?;
    parent.append_child(&div)?;
    Ok(())
}

fn mount_app(document: &Document, body: &HtmlElement) -> Result<()> {
    append_text_element_attrs!(document, body, "h1", "DOT",);
    mount_controls(&document, &body)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

STARTING_SIZE精明的人会注意到对- 将该常量添加到文件顶部的引用,这是页面呈现时滑块的起始位置:

const STARTING_SIZE: u32 = 5;
Enter fullscreen mode Exit fullscreen mode

web_sys如果您熟悉 JavaScript,所有调用看起来都非常熟悉。如果您需要 Web API 函数,只需在web-sysAPI 文档中查找即可。每个列表都方便地链接到相应的 MDN 页面!利用 crate 或编写自己的抽象来使这个过程更顺畅是完全可行的,也可以留给读者练习。

使用 重建wasm-pack build,如果您正在webpack-dev-server运行(通过npm run start),您可以重新加载localhost:8080

DOM 树截图

好东西。

行动

但这什么也没做。连个点都看不到,更别说可调整大小的点了。下一步是把这个点画到画布上:

// draw dot
fn update_canvas(document: &Document, size: u32) -> Result<()> {
    // grab canvas
    let canvas = document
        .get_element_by_id("dot-canvas")
        .unwrap()
        .dyn_into::<web_sys::HtmlCanvasElement>()?;
    // resize canvas to size * 2
    let canvas_dim = size * 2;
    canvas.set_width(canvas_dim);
    canvas.set_height(canvas_dim);
    let context = canvas
        .get_context("2d")?
        .unwrap()
        .dyn_into::<web_sys::CanvasRenderingContext2d>()?;

    // draw

    context.clear_rect(0.0, 0.0, canvas.width().into(), canvas.height().into());
    // create shape of radius 'size' around center point (size, size)
    context.begin_path();
    context.arc(
        size.into(),
        size.into(),
        size.into(),
        0.0,
        2.0 * std::f64::consts::PI,
    )?;
    context.fill();
    context.stroke();

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

如果你用 JavaScript 写过,这应该不会太陌生。其中一个不太熟悉的元素是那些dyn_into调用。为了使其正常工作,你需要在文件顶部再导入一次:

use wasm_bindgen::JsCast;
Enter fullscreen mode Exit fullscreen mode

当你用 抓取元素时,Document::get_element_by_id(&str)它会返回一个Element类型。但是,普通的 对象Element没有widthheight—— 这明确地是一个canvas元素。HtmlCanvasElement对象确实具有这些字段,因此我们可以尝试用 进行强制转换dyn_into()。如果我们确实抓取了正确的元素,则此强制转换将会成功。现在我们可以使用set_height()和 之类的东西get_context()。请注意,所有方法都使用 snake_case 而不是 camelCase,并且你不能直接用 修改字段canvas.height = 10;,而必须使用方法:canvas.set_height(10);。否则,这是等效 JavaScript 的转换,用于将画布大小调整为具有给定半径的圆的边界框,然后绘制该圆。

太棒了。我们还需要更新<span>我们专门用于显示当前尺寸的部分:

// update the size-output span
fn update_span(document: &Document, new_size: u32) -> Result<()> {
    let span = document.get_element_by_id("size-output").unwrap();
    span.set_text_content(Some(&format!("{}", new_size)));
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

这没什么好奇怪的,set_text_content是 的 setter Node.textContent。让我们把这两个更新打包起来:

// given a new size, sets all relevant DOM elements
fn update_all() -> Result<()> {
    // get new size
    let document = get_document()?;
    let new_size = document
        .get_element_by_id("size")
        .unwrap()
        .dyn_into::<web_sys::HtmlInputElement>()?
        .value()
        .parse::<u32>()
        .expect("Could not parse slider value");
    update_canvas(&document, new_size)?;
    update_span(&document, new_size)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

这将是滑块输入的 onChange 处理程序,在这个特殊的 FFI-interop-y 内部调用Closure

fn attach_listener(document: &Document) -> Result<()> {
    // listen for size change events

    update_all()?; // call once for initial render before any changes

    let callback = Closure::wrap(Box::new(move |_evt: web_sys::Event| {
        update_all().expect("Could not update");
    }) as Box<dyn Fn(_)>);

    document
        .get_element_by_id("size")
        .unwrap()
        .dyn_into::<web_sys::HtmlInputElement>()?
        .set_onchange(Some(callback.as_ref().unchecked_ref()));

    callback.forget();

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

这使用了web_sys::Closure。这允许你将 Rust 定义的闭包传递给 JS,用作事件监听器回调。这确实有些奇怪,我会引导你去阅读这本书,以便更好地理解为什么会这样。该链允许你从 中as_ref().unchecked_ref()提取&Function期望的set_onchangeweb_sys::Closure

现在我们只需要在安装应用程序后调用它:

#[wasm_bindgen]
pub fn run() -> Result<()> {
    let document = get_document()?;
    let body = document.body().unwrap();

    mount_app(&document, &body)?;
    attach_listener(&document)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

就这样!重新编译、重新加载,然后调整大小,尽情享受吧!太棒了

完成截图

完整代码可以在这里找到。

照片由 Leo Rivas 在 Unsplash 上拍摄

文章来源:https://dev.to/decidously/reactive-canvas-with-rust-web assembly-and-web-sys-2hg2
PREV
使用 bs-socket 在 ReasonML 中实现实时通信
NEXT
使用 Yew 构建 Rust 前端 - 第一部分 Wumpus Season