Getting Fancy with Rust and WebAssembly (Part 3) - Rust and JS interaction

Jun 26, 2023

I. Introduction

In the previous article "Getting Fancy with Rust and WebAssembly (Part 2) - DOM Manipulation and Type Conversion", we described how to use Rust to manipulate the DOM and implement various methods for type conversion between Rust and JS.

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 advantages of both languages to achieve better web applications.

Based on the various methods of type conversion between Rust and JS in the previous article, this article continues to delve into the interaction between Rust and JS.

First, the type conversion between Rust and JS can achieve variable passing, so where are these variables to be used? It must be in functions!

Therefore, this article will discuss the mutual function calls between Rust and JS. Based on this, a large number of daily functional developments can be implemented.

Additionally, we will also discuss how to export Rust structs for JS to use.

Yes, that's right, calling Rust structs in JS! When I first saw this feature, my mind was a bit blown......😳

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

III. Mutual Function Calls

1. JS Calling Rust Functions

Actually, in the first article of this series, we used JS calling Rust functions as an example for demonstration. Here we'll still use this as an example, mainly to discuss the key points.

First, declare a Rust function:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

The key points to note here are as follows:

  • Import wasm_bindgen
  • Declare a function, use pub to declare
  • Use the #[wasm_bindgen] macro on the function to export the Rust function as a function of the WebAssembly module

Then, compile it into a wasm file:

wasm-pack build --target web

Next, call this function in JS:

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

    const run = async () => {
        await init();
        const result = add(1, 2);
        console.log(`the result from rust is: ${result}`);
    }

    run();
</script>

Finally, start the http server, and you can see the result from rust is: 3 in the browser's console, indicating a successful call!

2. Rust Calling JS Functions

2.1 Specifying JS Objects

When calling JS functions in Rust, you need to specify JS objects, which means you need to explicitly tell Rust where this JS function is taken from JS to use.

It mainly involves the following two ways:

  • js_namespace: This is an optional attribute used to specify a JavaScript namespace that contains functions to be exported in the wasm module. If js_namespace is not specified, all exported functions will be placed in the global namespace.
  • js_name: This is another optional attribute used to specify the function name in JavaScript. If js_name is not specified, the exported function name will be the same as the function name in Rust.

2.2 JS Native Functions

For some JS native functions, in Rust, you need to look for alternative solutions. For example, the console.log() function we talked about in the previous article, isn't it a bit troublesome!

So, do you want to directly call JS native functions in Rust?!

Here, let's take the console.log() function as an example, directly import and call it in Rust, avoiding the trouble of alternative solutions.

First, here's the Rust code:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(message: &str);
}

#[wasm_bindgen]
pub fn call_js_func() {
    log("hello, javascript!");
}

In the above code, the call_js_func function, as the name suggests, calls the js function and passes the parameter hello, javascript!.

So, let's analyze the code above the call_js_func function step by step:

  1. The first line of code #[wasm_bindgen] is a Rust attribute that tells the compiler to export the function as a WebAssembly module
  2. extern "C" is the C language calling convention, which tells the Rust compiler to export the function as a C language function
  3. #[wasm_bindgen(js_namespace = console)] tells the compiler to bind the function to the console object in JavaScript
  4. fn log(message: &str) is a Rust function that takes a string parameter and prints it to the console object in JavaScript

The key to interacting with JS here is js_namespace. In Rust, js_namespace is an attribute used to specify the JavaScript namespace. In WebAssembly, we can use it to bind functions to objects in JavaScript.

In the above code, #[wasm_bindgen(js_namespace = console)] tells the compiler to bind the function to the console object in JavaScript. This means that the log() function in Rust is called using the console.log() function in JS.

Therefore, similar native functions can all be called using this method.

Finally, let's call it in JS:

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

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

    run();
</script>

You can see hello, javascript! in the browser's console. Wonderful!

Actually, for console.log(), there's another way to call it, which is to use js_namespace and js_name together.

You might ask, is there any difference? Yes, there is some difference.

I don't know if you've noticed, in the current example, js_namespace is specified as console, but the actual executed function is log(). So the specification of this log function is actually reflected in the same function name log in Rust. In other words, the log() in this example is the log() in console.log().

Let's try changing the name, change the original log() to log2():

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log2(message: &str);
}

#[wasm_bindgen]
pub fn call_js_func() {
    log2("hello, javascript!")
}

Then after compiling, look at the console, you'll see an error:

TypeError: console.log2 is not a function

Therefore, when we use the combination of js_namespace and js_name, we can customize the function name here.

Let's look at the Rust code:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(message: &str);

    #[wasm_bindgen(js_namespace = console, js_name = log)]
    fn log_str(message: &str);
}

#[wasm_bindgen]
pub fn call_js_func() {
    log_str("hello, javascript!")
}

Here, a new function log_str is defined, but it specifies js_namespace = console and js_name = log, so here, a custom function name can be used.

After directly compiling, look at the console, you can see the normal output: hello, javascript!.

To summarize, if js_name is not specified, the Rust function name will be used as the JS function name.

2.3 Custom JS Functions

In certain scenarios, you need to use Rust to call JS functions, such as scenarios that are more advantageous for JS - use JS to manipulate DOM, use Rust to calculate.

First, create a file index.js, write a function:

export function addIt(m, n) {
    return m + n;
};

The current file structure relationship is as follows:

.
├── Cargo.lock
├── Cargo.toml
├── README.md
├── index.html
├── index.js
├── pkg
│   ├── README.md
│   ├── hello_wasm.d.ts
│   ├── hello_wasm.js
│   ├── hello_wasm_bg.wasm
│   ├── hello_wasm_bg.wasm.d.ts
│   └── package.json
├── src
│   └── lib.rs
└── target
    ├── CACHEDIR.TAG
    ├── debug
    ├── release
    └── wasm32-unknown-unknown

Among them, index.js and lib.rs, as well as hello_wasm_bg.wasm are not at the same level, index.js is at the upper level of the other two files. Remember this structural relationship!

Then, in lib.rs, specify the function:

#[wasm_bindgen(raw_module = "../index.js")]
extern "C" {
    fn addIt(m: i32, n: i32) -> i32;
}

Where raw_module = "../index.js" means to specify the corresponding index.js file, you should be clear that this specifies the index.js we just created. The role of raw_module is to specify the js file.

This code in the frontend can be equivalent to:

import { addIt } from '../index.js'

This way, you don't need to import it in the frontend, it's directly imported in Rust, which feels a bit magical.

Next, call this function in Rust:

#[wasm_bindgen]
pub fn call_js_func() -> i32 {
    addIt(1, 2)
}

Finally, call it in the frontend, after compiling, you can see the output result in the browser's console!

To summarize, there are several points to note here:

  1. The JS function must be exported, otherwise it cannot be called;
  2. raw_module can only be used to specify relative paths, and, as you can notice in the browser's console, the ../ relative path here is actually the relative path in terms of the wasm file, this must be noted!

IV. JS Calling Rust's struct

Now, for something mind-blowing, JS calling Rust's struct?!

JS doesn't even have structs, what will this exported thing look like, and how will it be called in JS?!

First, define a struct, and declare several methods:

#[wasm_bindgen]
pub struct User {
    name: String,
    age: u32
}

#[wasm_bindgen]
impl User {
    #[wasm_bindgen(constructor)]
    pub fn new(name: String, age: u32) -> User {
        User { name, age }
    }

    pub fn print_user(&self) {
        log(format!("name is : {}, age is : {}", self.name, self.age).as_str());
    }

    pub fn set_age(&mut self, age: u32) {
        self.age = age;
    }
}

Here, a struct named User is declared, containing two fields name and age, and methods new, print_user, and set_age are declared.

There's also an unseen #[wasm_bindgen(constructor)], constructor is used to indicate that the bound function should actually be converted to calling the new operator in JavaScript. You might not be very clear yet, but as you continue reading, you'll understand.

Next, call this struct and its methods in JS:

<script type="module">
    function addIt2(m, n) {
        return m + n;
    };

    import init, { User } from './pkg/hello_wasm.js';

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

        const user = new User('HunterJi', 20);
        user.set_age(21);
        user.print_user();
    }

    run();
</script>

As you can see, the usage here is very familiar!

Think about how to call it in Rust? That is, directly new one - User::new('HunterJi', 20).

Here in JS, it's the same, first new one!

Then naturally call the struct's methods.

After compiling, open the browser, you can see the output in the console: name is : HunterJi, age is : 21.

Actually, you might be very curious, at least I was very curious, what exactly is Rust's struct in JS?

Here, directly add a console.log(user), and you can see in the output. So what exactly is it in JS? Please print it out and see for yourself! :P

V. Conclusion

In this article, we mainly discussed the interaction between Rust and JS, reflected in the mutual calls between Rust and JS, which is based on the type conversion in the previous article.

The learning cost of mutual function calls between Rust and JS is still relatively high, and compared to writing wasm with Go, Rust's granularity is very fine, almost can be said to be at will.

The most mind-blowing thing is exporting Rust's struct for JS to use, which is a very great experience for the interaction between Rust and JS.