Getting Fancy with Rust and WebAssembly (Part 2) - DOM Manipulation and Type Conversion

Jun 14, 2023

I. Introduction

In the previous article "Getting Fancy with Rust and WebAssembly (Part 1) - Quick Start", we described how to create a project and quickly generate wasm for use in the frontend, taking the first step towards creating cool stuff.

When developing web applications, Wasm modules written in Rust can provide higher performance and better security. However, to integrate with existing JavaScript code, interaction between Rust and JS must be implemented. The main purpose of Rust-JS interaction is to combine the strengths of both languages to achieve better web applications.

In this article, we will continue development based on the project created in the previous article.

II. Environment

  • Rust 1.70.0
  • wasm-bindgen 0.2.87
  • web-sys 0.3.64

III. DOM

1. Configuring Dependencies

To manipulate the DOM, we need to introduce a new dependency web-sys. Therefore, you can configure the dependencies in Cargo.toml as follows:

[dependencies]
wasm-bindgen = "0.2.87"
web-sys = { version = "0.3.64", features = [] }

You might be curious about what this features is. To be honest, I was very curious at first and didn't see any special explanation. I found out through trial and error that you need to manually import feature dependencies... For example, if you need to use JS's console in Rust, you need to add console to features.

2. Getting the Document

To use Document in Rust, we need to add features as explained in the previous step. There's a dependency relationship here: first, we need to get the window in Rust, and then get the document.

Therefore, after adding features, it looks like this:

[dependencies]
wasm-bindgen = "0.2.87"
web-sys = { version = "0.3.64", features = ["Window", "Document"] }

Then create a function in lib.rs to call document:

#[wasm_bindgen]
pub fn update_message() {
    let window = web_sys::window().expect("Failed to load window");
    let document = window.document().expect("Failed to load document");
}

Now we have unlocked document in Rust, so we can do whatever we want in the frontend!

3. Manipulating Elements

Let's start manipulating. First, we need to get the Element......

Yes, you guessed it right, let's continue to add features. Here we need to add an Element:

[dependencies]
wasm-bindgen = "0.2.87"
web-sys = { version = "0.3.64", features = ["Window", "Document", "Element"] }

OK, let's continue. Here we set the function to take two parameters selector and message, then get the element through selector, and update the value to the value of the message parameter. The complete function is as follows:

#[wasm_bindgen]
pub fn update_message(selector: &str, message: &str) {
    let window = web_sys::window().expect("Failed to load window");
    let document = window.document().expect("Failed to load document");
    let element = document.query_selector(selector).expect("Failed to load element");

    if let Some(element) = element {
        element.set_inner_html(message);
    } else {
        panic!("Failed to set inner html")
    }
}

4. Compilation

Compile the completed Rust project into wasm:

wasm-pack build --target web

5. Calling in HTML

Based on the HTML from the project in the previous article, add a div here with id message, and add a call to the wasm update_message function. The code is as follows:

<body>
    <div id="message"></div>
</body>

<script type="module">
    import init, { update_message } from './pkg/hello_wasm.js'; // 引入update_message函数

    const run = async () => {
        await init();

        update_message('#message', '<h1>Hello, Rust!</h1>'); // 调用update_message函数
    }

    run();
</script>

6. Verification in Browser

Start an HTTP server, then view it in the browser. You can see an h1 tag with Hello, Rust! appearing on the page.

7. Discovering More Methods

As you write according to the article, you might notice a problem - why aren't these methods auto-completing?!

Yes, that's right, (at least I found that) currently web-sys doesn't have auto-completion, so we can only develop by combining developers' excellent frontend skills and the rich official documentation.

IV. Type Conversion Between Rust and JS

For wasm, performance is certainly improved, but type conversion has always been an issue. When a large amount of data needs to be type-converted in wasm/js, it's really a disaster for performance. I encountered this problem when developing wasm with Go before, where I needed to use official methods for manual type conversion, but wasm was dealing with a very large amount of data......

Fortunately, Rust's type support is really rich!

1. Basic Types

Basic types are quite simple, and Rust's generics also support many types well. Here's a mapping table of basic types:

  • i8: number
  • i16: number
  • i32: number
  • i64: BigInt
  • u8: number
  • u16: number
  • u32: number
  • u64: BigInt
  • f32: number
  • f64: number
  • bool: boolean
  • char: string
  • &str: string
  • String: string
  • &[T] (e.g.: &[u8]): [T] (e.g.: Uint8Array)
  • Vec<T>: Array

2. Basic Type Conversion Example

In the lib.rs file, create a function that takes a few types as parameters, then reads and prints them:

#[wasm_bindgen]
pub fn print_values(js_number: i32, js_boolean: bool, js_uint8_array: &[u8], js_number_array: Vec<i32>) {
    println!("js number: {}", js_number);
    println!("js boolean: {}", js_boolean);

    for item in js_uint8_array {
        println!("js Uint8Array item: {}", item);
    }

    for item in js_number_array {
        println!("js number array item: {}", item);
    }
}

You can see that this function takes four types of parameters from JS: number, boolean, Uint8Array, and Array.

Then compile:

wasm-pack build --target web

Next, import the function and call it in the frontend:

<script type="module">
    import init, { print_values } from './pkg/hello_wasm.js';

    const run = async () => {
        await init();

        const jsNumber = 10;
        const jsBoolean = true;

        const jsUint8Array = new Uint8Array(3);
        jsUint8Array[0] = 1;
        jsUint8Array[1] = 2;
        jsUint8Array[2] = 3;

        const jsNumberArray = [30, 40, 50];

        print_values(jsNumber, jsBoolean, jsUint8Array, jsNumberArray);
    }

    run();
</script>

Finally, start the HTTP server and open the browser, and in the console you can see... you can't see?!

Yes, that's right, Rust's println! will only send the printed content to Rust's standard output stream, not the frontend's console. If you want to print in the console, you need to call JS's console.

To use the new functionality, the first step is to add features. Add console to Cargo.toml as follows:

[dependencies]
wasm-bindgen = "0.2.87"
web-sys = { version = "0.3.64", features = ["Window", "Document", "Element", "console"] }

Calling console.log() in Rust is as follows:

web_sys::console::log_1(&"Hello, Rust!".into());

Here we encapsulate it into a function:

fn console_log(message: String) {
    web_sys::console::log_1(&message.into());
}

Then, change the println in the example function to console_log() and format!. The function code is as follows:

#[wasm_bindgen]
pub fn print_values(js_number: i32, js_boolean: bool, js_uint8_array: &[u8], js_number_array: Vec<i32>) {
    console_log(format!("js number: {}", js_number));
    console_log(format!("js boolean: {}", js_boolean));

    for item in js_uint8_array {
        console_log(format!("js Uint8Array item: {}", item));
    }

    for item in js_number_array {
        console_log(format!("js number array item: {}", item));
    }
}

Finally, after compiling, open the browser, and you can see the output in the console:

js number: 10
js boolean: true
js Uint8Array item: 1
js Uint8Array item: 2
js Uint8Array item: 3
js number array item: 30
js number array item: 40
js number array item: 50

3. Generic Type

Rust provides a generic type - JsValue, which can be used as any JS type.

Here's a simple example, setting up a function that takes JsValue as a parameter and prints it.

Create the function:

#[wasm_bindgen]
pub fn print_js_value(val: JsValue) {
    console_log(format!("{:?}", val));
}

Then compile it into a wasm file.

Call it in HTML:

<script type="module">
    import init, { print_js_value } from './pkg/hello_wasm.js';

    const run = async () => {
        await init();

        const jsNumber = 10;
        const jsBoolean = true;

        const jsUint8Array = new Uint8Array(3);
        jsUint8Array[0] = 1;
        jsUint8Array[1] = 2;
        jsUint8Array[2] = 3;

        const jsNumberArray = [30, 40, 50];

        print_js_value(jsNumber);
        print_js_value(jsBoolean);
        print_js_value(jsUint8Array);
        print_js_value(jsNumberArray);
    }

    run();
</script>

In the HTML, different types of parameters are passed in, but in the browser's console, you can see that all different types of parameters are printed:

JsValue(10)
JsValue(true)
JsValue(Uint8Array)
JsValue([30, 40, 50])

4. Result

Result is a very important existence in Rust. If you often write Rust, you don't want to change your development habits when writing WebAssembly.

For JS, Result can be directly caught in catch, but here we need to define the parameter types well.

4.1 Using Result to Return Errors

First, let's look at a scenario that only returns errors:

#[wasm_bindgen]
pub fn only_return_error_when_result(count: i32) -> Result<(), JsError> {
    if count > 10 {
        Ok(())
    } else {
        Err(JsError::new("count < 10"))
    }
}

Here the return type is Result, but it only returns an error. It's worth noting that the error type used here is JsError, of course, JsValue can also be used here.

Then call it in HTML:

<script type="module">
    import init, { only_return_error_when_result } from './pkg/hello_wasm.js';

    const run = async () => {
        await init();

        try {
            only_return_error_when_result(1);
            console.log('1 is ok');
        } catch(error) {
            console.log('An error is reported when the input parameter is 1: ', error);
        }

        try {
            only_return_error_when_result(100);
            console.log('100 is ok');
        } catch(error) {
            console.log('An error is reported when the input parameter is 100: ', error);
        }
    }

    run();
</script>

Here it's called twice, the first time should be an error, the second time should be correct, and both use catch to catch errors.

So, in the browser's console, you can see the output:

An error is reported when the input parameter is 1:  Error: count < 10
100 is ok

4.2 Using Result to Return Normal Values and Errors

So, what if you want to return both normal values and errors? Rust returning a Result is no problem, but how does JS parse it?

Let's go straight to the Rust code:

#[wasm_bindgen]
pub fn return_all_when_result(count: i32) -> Result<i32, JsError> {
    if count > 10 {
        Ok(count + 10)
    } else {
        Err(JsError::new("count < 10"))
    }
}

This function, after getting the parameter, if it meets the condition, adds 10 and returns, otherwise it reports an error.

Let's see how to call it in HTML:

<script type="module">
    import init, { return_all_when_result } from './pkg/hello_wasm.js';

    const run = async () => {
        await init();

        try {
            const res = return_all_when_result(1);
            console.log(`get ${res}`);
        } catch(error) {
            console.log('An error is reported when the input parameter is 1: ', error);
        }

        try {
            const res = return_all_when_result(100);
            console.log(`get ${res}`);
        } catch(error) {
            console.log('An error is reported when the input parameter is 100: ', error);
        }
    }

    run();
</script>

Yes, that's right, just get it normally..... /facepalm

The call here is still, the first one is wrong, the second one correctly returns a value, and both use catch to catch errors.

Finally, in the browser's console, you can see:

An error is reported when the input parameter is 1:  Error: count < 10
get 110

5. Directly Importing JS Types

If you want to be more direct, you can directly import JS types! This mainly uses the js-sys dependency, you can see many JS types and functions on the official documentation, just import them directly to use. Of course, in certain scenarios, directly imported types need to be manually converted.

5.1 Configuring Dependencies

Add the js-sys dependency in Cargo.toml:

[dependencies]
wasm-bindgen = "0.2.87"
web-sys = { version = "0.3.64", features = ["Window", "Document", "Element", "console"] }
js-sys = "0.3.61"

5.2 Uint8Array

First, take Uint8Array as an example, import the type at the top of lib.rs:

use js_sys::Uint8Array;

Then create a function with parameters and return both of type Uint8Array:

#[wasm_bindgen]
pub fn print_uint8_array(js_arr: Uint8Array) -> Uint8Array {
    // new Uint8Array
    let mut arr = Uint8Array::new_with_length(3);

    // Uint8Array -> vec
    for (index, item) in js_arr.to_vec().iter().enumerate() {
        console_log(format!("{} - the item in js_arr: {}", index, item));
    }

    // Avoid type conversion
    // Use the method of the type itself
    for index in 0..js_arr.length() {
        console_log(format!("{} - the item in js_arr: {}", index, js_arr.get_index(index)));
    }

    // vec -> Uint8Array
    let vec = vec![1, 2, 3];
    let arr2 = Uint8Array::from(vec.as_slice());
    arr = arr2.clone();

    // Use the method of the type itself
    arr.set_index(0, 100);

    arr
}

Ignore the meaningless logic in this function and the warning of the arr variable, it's just for demonstration purposes.

You can see in the code that the directly imported Uint8Array has its own methods. In certain scenarios, type conversion is needed, but it's best to avoid type conversion and directly use its own methods.

We can briefly summarize here that it's best to use directly imported JS types entirely within certain scenarios, or use Rust types to replace JS types entirely. When both exist, manually converting types is a terrible thing.

5.3 Date

The Date type wasn't mentioned in the above sections. Here we can directly import and use the JS Date type.

First, import the type:

use js_sys::Date;

Then, create a function that returns a timestamp:

#[wasm_bindgen]
pub fn return_time() -> f64 {
    let date = Date::new_0();
    date.get_time()
}

Then, call it in HTML:

<script type="module">
    import init, { return_time } from './pkg/hello_wasm.js';

    const run = async () => {
        await init();

        console.log('current time: ', return_time());
    }

    run();
</script>

Finally, in the browser's console, you can see:

current time:  1686979932833

V. Conclusion

In this article, we mainly discussed how to use Rust to implement DOM operations. Readers can find suitable methods to implement their own scenarios based on the methods. Secondly, we also discussed the type conversion between Rust and JS, from the mapping of basic types of each, to Rust's unique Result, to directly importing JS types. Of course, it should be noted that directly importing JS types and mapping JS types to Rust's basic types should not be mixed as much as possible. Mixing will lead to the need for manual type conversion, causing performance loss.

At this point, we've taken another step towards creating cool stuff with Rust and WebAssembly~😼