WebAssembly — Is it as scary as it sounds? 😱
WebAssembly (Wasm) has been around for more than 2 years. It is still a relatively new piece of technology. For some reason, something about it always feels scary to me. Maybe because it has “ Assembly” written on its name? Or maybe simply picturing having to code in a language that is very different compared to JavaScript already stroke fear into me? Have I grown very attached to the quirks and features of JavaScript that the thought of NOT writing code in JavaScript just seems so unappetising? Whatever it was, the curiosity in me can’t stand not knowing it. After watching the talk by Surma on this year I/O, I finally brought myself to try to learn WebAssembly.
What is WebAssembly anyway? Why does it matter?
[WebAssembly] provides a way to run code written in multiple languages on the web at near native speed, with client apps running on the web that previously couldn’t have done so. — MDN
Basically, WebAssembly allows us to compile a code written in some languages like C, Rust, etc. (a complete list of languages can be found here) to a .wasm file and then run it on the browser. It is incredible in term of opening all new kinds of possibilities for the web platform. If you have tried Unity to build games before, you might be familiar with this picture.
It is a sample 3D game made with unity, ported to the browser with the help of WebAssembly! The crazy thing is: it’s not sluggish to play! You can try it out yourself here. Another story is AutoCAD. AutoCAD was ported to the web as well. You can watch this video to know more about it. WebAssembly isn’t magic. It won’t automatically take your existing program written in C++ and then compile it and it will run in the browser. What WebAssembly allows is for us to reuse code from other languages, and run it on the browser at near native speed!
I can see its benefits, but do I need to learn it though?
If your web app does not do heavy computations, but only do light tasks such as rendering the UI, making request to APIs, etc. then you probably don’t need WebAssembly. WebAssembly allows us as web developer to achieve things we previously can’t (or at least not feasibly) do with just JavaScript. It acts as a complement to JavaScript instead of a replacement for it. With that being said though, while you might not use it, it is always beneficial to have learned it and tried it out.
Foreword
In this article, we will use wasm to decode QR code from an image input. We will be using Rust and wasm-bindgen to help us in doing this. The final result Rust Crate can be seen in the following repo.
Be warned though, I am by no means an expert in WebAssembly and/or Rust. My experience in software engineering mostly consists of web development and writing JavaScript. This basically means what I am doing in this article might not be a good practice, and some of it maybe not technically accurate; but it is a genuine learning journey of a guy that is new to this environment. Hopefully, you will find this useful in a way or another!
Setting up
First, we will need to install Rust. Just follow this guide to do it. Once done with that, we will add wasm as a compilation target to the Rust compiler. We do it by running the following command.
rustup target add wasm32-unknown-unknown
Now, we should install the wasm-bindgen-cli
because we will be using it later. We will install it using cargo. It should already be installed along with Rust. Think of it like npm, but for Rust. Run the following command to install wasm-bindgen-cli.
cargo install wasm-bindgen-cli
That’s all we need for now! Before we start, let me give you an overview of what we are going to do.
- Write a function in Rust that can decode QR code
- Compile the Rust code to wasm
- Try using the wasm file in a simple HTML + JS webpage 🎉
Writing code in Rust
If you are familiar with Node, usually we start a project by running npm init
. In Rust, we do cargo new hello_world
instead. This will create a directory named hello_world with some files pre-made for us. In Rust, this project is called a package. We can import third-party crates as well, just like in Node we can import third-party modules. Now, let’s take a look at the Cargo.toml
file. Yours probably looks a bit empty right now, but it’s okay. Just modify it to follow the following snippet.
You might see that this file contains the information of this package, and its dependencies. This file is much like a package.json file, it is the manifest of the package. The important thing here is that in [lib] section, we are defining the crate-type as ["cdylib"]. This is required when we are targeting Wasm. Also, we are not going to write a QR decoder ourselves, so we will be using third-party crate. We will use rqrr as the QR decoder. To create an image that will be fed to the decoder, we will use the image crate, and we will also use wasm-bindgen to help provide “Rust-to-JavaScript” bindings for us.
Now, rename src/main.rs
to src/lib.rs
and write the following code in it. lib.rs is the entry point of our package when we compile it to wasm later.
I am not very familiar with Rust, but I will try my best to explain. The extern crate
and use
statement is used to import the crates we will use, in this case, they are wasm_bindgen
, rqrr
and image
.
Then, we create a public function using the pub fn
keyword, named decode_qr
. This function accepts an array of unsigned 8-bit integer named bytes (bytes:&[u8]
) representing the image data. It will decode the image and return a String. The #[wasm_bindgen]
attribute tells wasm-bindgen
that we want this function to be exposed to our JavaScript when we use it. This information is used by wasm-bindgen
to create appropriate bindings for us.
#[wasm_bindgen]
pub fn decode_qr(bytes: &[u8]) -> String {
We then create an image from this array, using the load_from_memory method provided by image crate. Since this operation can fail, we use the match keyword, and handle cases when the method returns Ok and Err results. On error, we will just return the string “[Error] Failed when trying to load image”
to JavaScript side.
let img = match image::load_from_memory(&bytes) {
Ok(v) => v,
Err(_e) => return format!("{}", "[Error] Failed when trying to load image"),
};
Then, we convert this image to a grayscale image before feeding it to rqrr
.
let img = img.to_luma();
Note: You might be thinking: “Where do all these methods name come from?”. All of these methods along with other information can be found in the crate documentation pages. I have linked to the documentation pages above, but I will provide it again here in case you missed it. rqrr docs; image docs; wasm_bindgen docs;
Moving on. We then prepare the image and then feed it to rqrr
, along with the case handling as well. Finally, we return the String to JavaScript side.
// Prepare for detection
let mut img = rqrr::PreparedImage::prepare(img);
// Search for grids, without decoding
let grids = img.detect_grids();
if grids.len() != 1 {
return format!("{}", "[Error] No QR code detected in image") }
// Decode the grid
let (_meta, content) = match grids[0].decode() {
Ok(v) => v,
Err(_e) => return format!("{}", "[Error] Failed decoding the image"),
};
return format!("{}", content);
Compiling to Wasm
Now that we have our Rust code, we can compile it to wasm easily using the following command.
cargo build --target wasm32-unknown-unknown --release
This will generate a wasm file in target/wasm32-unknown-unknown/release
.
Note that the name of the file is qr_rust.wasm
. This is because the name of my package is qr-rust
, so the output file is named accordingly.
We are not done though! Since we are using wasm-bindgen
, we need to run wasm-bindgen
against this wasm file to generate another wasm file with a JavaScript file containing the bindings needed to help us use the wasm file easily! Run the following command to do this.
wasm-bindgen target/wasm32-unknown-unknown/release/qr_rust.wasm --out-dir ./dist --no-modules --no-typescript
You should now see 2 new files in the dist directory.
If you try to look inside the .js file, you will see a bunch of codes that is generated by wasm-bindgen
so we can easily use the wasm module. 2 notable things that it does for us is that it helps us instantiate the wasm module in the init()
function:
and it provides the necessary stuffs needed to pass data between JavaScript and Wasm:
If we are not using wasm-bindgen
, we would have to write these stuffs ourselves.
And now…
Let’s try using it!
First, let’s create a simple HTML file that includes the JavaScript bindings generated by wasm-bindgen
. The bindings have to be executed before we can do anything with the wasm module. Let’s create this index.html file in the ./dist
directory.
<html>
<!-- the javascript bindings -->
<script src="qr_rust.js"></script>
</html>
The bindings create a wasm_bindgen
variable in the global scope. Which we can use to load our wasm module.
<html>
<!-- the javascript bindings -->
<script src="qr_rust.js"></script>
<script>
wasm_bindgen("qr_rust_bg.wasm")
.then(() => {
console.log('it is loaded!');
})
.catch(console.error);
</script>
</html>
Now, let’s try serving this HTML file locally and see what happens. The easiest way to do it would be to use http-server
npm module and serve our ./dist
directory.
npm install http-server -g
http-server ./dist -g
Open the URL, if everything is correct, you should see It is loaded! in the browser console.
The function that we wrote in Rust, can be accessed from the global wasm_bindgen variable.
const { decode_qr } = wasm_bindgen;
At this point, we can just pass an array of unsigned 8-bit integer to the function and log the output. Here, I am passing new Uint8Array([1,2,3,4,5]);
to the function.
<html>
<!-- the javascript bindings -->
<script src="qr_rust.js"></script>
<script>
wasm_bindgen('qr_rust_bg.wasm')
.then(() => {
console.log('it is loaded!');
const { decode_qr } = wasm_bindgen;
const output = decode_qr(new Uint8Array([1,2,3,4,5]));
console.log("output of decode_qr:", output);
})
.catch(console.error);
</script>
</html>
Apparently, it fails trying to load the image. This is expected because we are just passing a random array that is not actually representing an image data. Let’s create a <video />
element from which we will get the image data from.
<video id="video" autoplay></video>
// some other stuffs here
<script>
// other scripts
// inside wasm_bindgen().then()
const video = document.getElementById('video');
navigator.mediaDevices.getUserMedia({ video: true })
.then(stream => {
video.srcObject = stream;
});
</script>
If you refresh the browser, your browser should be asking permission to use the camera now. Allow it, and you should see your camera feed in the browser.
Next, we want to be able to get the current frame of the video, get the image data as an array of unsigned 8-bit integers, and send it to the decode_qr() function. We will do this with the help of canvas
and FileReader
. Here is a captureImage
function that will do just that.
Now, we just need to periodically call this function. We can simply do this with setInterval. We will start off the interval after the video stream is created.
navigator.mediaDevices.getUserMedia({ video: true })
.then(stream => {
video.srcObject = stream;
setInterval(captureImage, 300);
});
So, our final HTML file should look like this.
Refresh your browser, time to try it out!
Sometimes, you might see an error that says “unreachable code”. I haven’t figured out why that happens yet. If you do know the cause, please let me know!
What you can try to do next
We have taken a functioning QR decoder written in Rust, compile it to WebAssembly and use it in the browser with the help of wasm-bindgen
.
However, if you look at the network requests in devtools, you would see that the size of this wasm file is HUGE!
Note that we haven’t do any compression and optimisation. You can try looking up wasm-opt to optimise the size. This guide will help you with that. Then, you can compress it down with any compression algorithm you want, a common one is gzip. See how small you can get the wasm file size down to. I am currently at 264 KB gzipped!
Next, you can try publishing your creation as an npm module so other people can use it easily. I have done this exercise myself. If you want to take a look at my implementation, you can check out my repository below. It contains mostly the same code that I included in this writing.
Takeaways
So, is WebAssembly scary? For me, the answer is no. The reason why it seemed so scary at first because it was a mysterious piece of technology that I was very unfamiliar with. As a guy who mostly coded in JavaScript, it was a bit odd to code in Rust. After reading and watching stuffs about WebAssembly, I could no longer ignore it and decided I had to get over my fear of WebAssembly; and apparently, a good way to get over the fear of it is to actually dive into it.
Note that you don’t even have to create your own Wasm! There is an increasing number of Wasm module created by other people being published to npm. We can just consume these modules in our projects. The purpose of the exercise we did is to get to know WebAssembly better. In practice though, we probably might not have to do that. But, if one day you need to, at least you have an overview of how it works already.
WebAssembly opens up a whole lot of possibilities for the web platform, especially for tasks that were too heavy to do with just JavaScript. Your use cases might not need WebAssembly. For example, our QR decoder (after some optimisations) is 264 KB big. There is this QR scanner written in JavaScript that is only ~12.4 KB gzipped. Depending on your use case, you could argue that even with the performance advantage, the WebAssembly solution is overkill for this purpose and you could be right. The point is, at the end of the day, tools are just tools; and WebAssembly is a great addition to our toolbox. Whether it makes sense to use the tool will be decided by us, the builder.
You can learn more about WebAssembly by watching one of the talk at 2019’s Google I/O here. You can also play around with it in the online IDE: webassembly.studio.
Top comments (0)