Porting a JavaScript App to WebAssembly with Rust (Part 2)

07. 01. 2020

Rust

TL;DR

We will demonstrate how to do a complete port of a web application from React+Redux written in JavaScript to WebAssembly (WASM) with Rust.

This is the second part of a blog post series. You can read the first part on our website.

Recap

Last time we layed the foundation for the port by applying four small steps to the original JavaScript project:

  1. Prepare
  2. Initialize a Seed project
  3. Move existing code and clean up
  4. Create modules

Now we can start with the actual translation of legacy JavaScript code to working Rust code.

Step 5: Setup development workflow

During the transformation we basically use the following development cycle to verify the results of each step:

  1. Port code or make changes
  2. Let the compiler check the new code
  3. Compile to WASM
  4. Run web application

Instead of doing all this manually we can automate some parts.

Starting the following command in a separate terminal window gives us feedback from the compiler on every file change (ignoring all files in pkg/):

cargo watch -i "pkg/*"

Because JavaScript has different code style conventions like using camelCase for variable names the Rust compiler produces hundreds of warnings in our codebase like the following:

warning: module `mapAndEntryList` should have a snake case name
 --> src/widgets/mod.rs:3:9
  |
3 | pub mod mapAndEntryList;
  |         ^^^^^^^^^^^^^^^ help: convert the identifier to snake case: `map_and_entry_list`

This is pretty annoying because we want to port the code with its original variable names first and do the Rust style refactoring later all at once. To be able to focus on errors we can set the compiler flag -A warnings that surpresses all warnings until then.

RUSTFLAGS="$RUSTFLAGS -A warnings" cargo watch -i "pkg/*"

At some point we also want to run our tests automatically that is why we extend the watch command with -xt:

RUSTFLAGS="$RUSTFLAGS -A warnings" cargo watch -i "pkg/*" -xt

For automatically building the WASM file we could use

cargo watch -i "pkg/*" -s "wasm-pack build --target web"

Note: Setting the rust flag -A warnings does not work here. If you know how to successfully surpress warnings with wasm-pack please let us know.

Step 6: Porting constants

We start with porting the constants because it's super easy :) As an example we translate the file src/constants/Liceses:

-module.exports = {
-  CC0: "CC0-1.0",
-  ODBL: "ODbL-1.0"
-};
+pub const CC0: &str = "CC0-1.0";
+pub const ODBL: &str = "ODbL-1.0";

As you can see most of the time you'll find yourself by prepending a pub const and adding type definitions like &str. If constants are nested objects we can tranform the name of the outer constant into a public module:

-module.exports = {
-  DEFAULT_BBOX: {
-    _northEast: {
-      lat: 48.82099347817258,
-      lng: 9.299583435058596
-    },
-    _southWest: {
-      lat: 48.73547433443503,
-      lng: 9.116249084472658
-    }
-  },
+pub mod DEFAULT_BBOX {
+  pub mod _northEast {
+      pub lat: f64 = 48.82099347817258;
+      pub lng: f64 = 9.299583435058596;
+  }
+  pub mod _southWest {
+      pub const lat: f64 = 48.73547433443503;
+      pub const lng: f64 = 9.116249084472658;
+  }
+}

Because JavaScript does not have enum types you can find workarounds in the legacy JavaScript code like this one:

module.exports = {
  EDIT: 'EDIT',
  NEW: 'NEW',
  NEW_RATING: 'NEW_RATING'
  // ...
}

Translating this to Rust is trivial:

-module.exports = {
-  EDIT: 'EDIT',
-  NEW: 'NEW',
-  NEW_RATING: 'NEW_RATING',
+#[derive(Debug)]
+pub enum PanelView {
+    EDIT,
+    NEW,
+    NEW_RATING,
     // ...
}

Step 7: Looking at Elm, Redux and Seed

Before we're going to translate our business layer we'll recapture the concepts and terms of our application architecture. Both Redux and Seed are inspired by Elm. That's why we first have a look at Elm's basic patterns.

Basically there is an HTML page that is rendered in the browser. An event like a click on a button is processed by the Elm application. The result is a new HTML page that is again renderd in the browser.

Elm source: https://guide.elm-lang.org/architecture/

The following concepts can be found in every Elm application:

  • a Model that represents the state of the application,
  • a View that knows how to render the state into HTML and
  • an Update function that modifies the state based on messages.

These three parts are the core of The Elm Architecture. The architecture of our target framework Seed comes quite close to this.

Now let's see how the Redux architecture looks like:

Redux Architecture source: https://krasimirtsonev.com/blog/article/my-take-on-redux-architecture

Here we have React that is responsible to render our state into HTML. Reducers are modifying the application state based on actions. The overall application state is referred to as the Store.

Within the next steps we need to port

  • all actions to message enums,
  • all Reducers to update functions,
  • the Store into multiple Model structs and
  • the React layer into multiple view functions.

Step 8: Porting actions

All the Redux actions can be found in the src/Actions/ folder. First we move the content of index.rs to mod.rs so we can delete the index.rs file. Then we can replace the module export with a Msg enum:

-module.exports = {
-  ...clientActions,
-  ...serverActions
-}
+#[derive(Debug, Clone)]
+pub enum Msg {
+    Client(client::Msg),
+    Server(server::Msg),
+}

This is our new mod.rs:

pub mod client;
pub mod server;

#[derive(Debug, Clone)]
pub enum Msg {
    Client(client::Msg),
    Server(server::Msg),
}

Next we're going to port the src/Actions/client.rs file. Since Rust supports to associate data with each enum variant, we can get rid of the type and the payload properties. But we should keep the JaveScript type constants as comments. This comes in handy later when we need to replace actions in the reducers with the corresponding messages.

So instead of writing

setSearchText: (txt) => ({
  type: T.SET_SEARCH_TEXT,
  payload: txt
}),

we can shrink the action to

setSearchText(String) // TODO: T.SET_SEARCH_TEXT

Some actions may have payloads whose type is currently undefined. For now we simply create a placeholder based on type aliases. For example the bounding box type BBox is unknown at the moment, so we assume it to be a String

type BBox = String;

and replace it with the corresponding struct when available and needed.

If your app made use of redux-thunk you will also have async actions that return a callback function. These actions might look like the following:

showNewEntry: () =>
  (dispatch) => {
    dispatch(Actions.setSearchText(''));
    dispatch(serverActions.Actions.search());
    dispatch({
      type: T.SHOW_NEW_ENTRY
    });
  },

Here we will move the content of the callback function into the reducers later. So we keep the body as a TODO note.

- // TODO: showNewEntry: () =>
+ showNewEntry,
// TODO:   (dispatch) => {
// TODO:     dispatch(Actions.setSearchText(''));
// TODO:     dispatch(serverActions.Actions.search());
// TODO:     dispatch({
// TODO:       type: T.SHOW_NEW_ENTRY
// TODO:     });
// TODO:   },

The actual port looks like this:

-// TODO: import T                          from "../constants/ActionTypes";
 // TODO: import GeoLocation                from "../GeoLocation";
 // TODO: import mapConst                   from "../constants/Map";
 // TODO: import serverActions              from "./server";
 // TODO:
+// TODO: replace with real types
+type EntryId = String;
+type Category = String;
+type Feature = String;
+type RatingContext = String;
+type RatingId = String;
+type Info = String;
+type BBox = String;
+type Url = String;
+type MapCenter = String;
+type CenterOrEntryId = String;
+type Coordinates = String;
+type Zoom = f32;
+
-// TODO: const Actions {
+ #[derive(Clone)]
+ pub enum Actions {
-// TODO:   setSearchText: (txt) => ({
-// TODO:     type: T.SET_SEARCH_TEXT,
-// TODO:     payload: txt
-// TODO:   }),
+   setSearchText(String),     // TODO: T.SET_SEARCH_TEXT,
-// TODO:
-// TODO:   setCitySearchText: (txt) => ({
-// TODO:     type: T.SET_CITY_SEARCH_TEXT,
-// TODO:     payload: txt
-// TODO:   }),
+   setCitySearchText(String), // TODO: T.SET_CITY_SEARCH_TEXT,
-// TODO:
-// TODO:   finishCitySearch: () => ({
-// TODO:     type: T.FINISH_CITY_SEARCH,
-// TODO:   }),
+   finishCitySearch,          // TODO: T.FINISH_CITY_SEARCH,
 // ... and so on and so forth
-// TODO:   showNewEntry: () =>
+   showNewEntry,
 // TODO:     (dispatch) => {
 // TODO:       dispatch(Actions.setSearchText(''));
 // TODO:       dispatch(serverActions.Actions.search());
 // TODO:       dispatch({
 // TODO:         type: T.SHOW_NEW_ENTRY
 // TODO:       });
 // TODO:     },
-// TODO:   hideLeftPanelOnMobile: () =>
+   hideLeftPanelOnMobile,
 // TODO:     (dispatch) => {
 // TODO:       if (document.documentElement.clientWidth < 600) {
 // TODO:         dispatch(Actions.hideLeftPanel())
 // TODO:       }
 // TODO:     },
 // ... and so on and so forth

Step 9: Porting reducers

As we have seen in step 7 the update function of Seed is basically what is a reducer in Redux. A reducer takes the current state and an action. As a result it returns the new state. In Seed a state is called Model.

Each reducer defines an initial state. In the Rust world we can define a struct Mdl and implement the Default trait.

+ [derive(Default)]
- // TODO: const initialState = {
+ Mdl = {
- // TODO:   version: null,
+ pub version: Option<Version>,
- // TODO:   entries: {},
+ pub entries: HashMap<EntryId, Entry>,
- // TODO:   ratings: {},
+ pub ratings: HashMap<RatingId, Rating>,
- // TODO:   loadingSearch: false
+ pub loadingSearch: bool
- // TODO: };
+ }

For our actions/messages defined before we can easily modify the state / model within the reducer / update funtion.

- // TODO: module.exports = (state=initialState, action={}) => {
+ pub fn update(action: &Msg, state: &mut Mdl, orders: &mut impl Orders<Msg>) {
-// TODO: switch (action.type) {
+ match action {
+   Msg::Client(msg) => {
+     //
+   }
+   Msg::Server(msg) => {
+     //
+   }

You might have noticed that not all actions found in the original reducers are handled right now. This is the point where the async actions come into play. But that's a separate topic for another blog post.

Summary and next steps

By matching the terms and application architectures of Elm, React/Redux, and Seed we have established a conceptual mapping that allows us to port the actual code in a straightforward manner. We translated global constants, simple actions, and reducers from React (JavaScript) to Seed (Rust).

In the next series we'll demonstrate how to translate React (JSX) components to Seed and how to "dispatch" asynchronous actions for fetching data from a remote server.

Tags: rust, react, javascript, frontend, wasm