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

28. 02. 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 third part of a blog post series. You can read the first part and the second part on our website.

Recap

Last time we had a look at the application architectures of Elm, React/Redux and Seed to establish a conceptual mapping and we translated simple actions and reducers from React (JavaScript) to Seed (Rust).

Step 10: Porting the WebAPI

Since then the reducers (the update functions) handled only synchronous actions. Now we're going to port async actions by "dispatching" the corresponding commands within the reducer with the perform_cmd method of orders.

E.g. here we move an async action defined in Actions::server::Action to the reducer reducers::server::update:

Actions/server.rs:

  getEvent(Id),
- // TODO:     (dispatch) => {
- // TODO:       WebAPI.getEvent(id, (err, res) => {
- // TODO:         dispatch({
- // TODO:           type: T.SEARCH_RESULT_EVENTS,
- // TODO:           payload: err || [ res ],
- // TODO:           error: err != null
- // TODO:         });
- // TODO:       });
- // TODO:     },

reducers/server.rs:

+ Msg::getEvent(id) => {
+   orders.perform_cmd(WebAPI.getEvent(id));
+ }
+ Msg::SEARCH_RESULT_EVENTS(res) => {
+   match res  {
+     Ok(events) => { /* add events to state */ }
+     Err(err) => { /* handle err */ }
+   }
+ }

We are calling the API funtion getEvent that needs to be ported, too:

WebAPI.rs:

- // TODO:   getEvent: (id, cb) => {
+ pub fn getEvent(id: &str) -> impl Future<Item = Msg, Error = Msg> {
+   let url = format!("/events/{}", id); // TODO: .use(prefix)
-  TODO:     request
+   Request::new(url).fetch_json_data(|d| Msg::Server(server::Msg::SEARCH_RESULT_EVENTS(d)))
- // TODO:       .get('/events/' + id)
- // TODO:       .use(prefix)
- // TODO:       .set('Accept', 'application/json')
- // TODO:       .end(jsonCallback(cb));
- // TODO:   },
+ }
}

Note: You need to add futures = "0.1" to your Cargo.toml.

Did you notice the Msg::SEARCH_RESULT_EVENTS message? This message was previously dispatched in the Action getEvent:

getEvent: (id) =>
  (dispatch) => {
    WebAPI.getEvent(id, (err, res) => {
      dispatch({
        type: T.SEARCH_RESULT_EVENTS,
        payload: err || [ res ],
        error: err != null
      });
    });
  },
}

In the Rust version this message becomes a variant of server::Msg:

enum Msg {
   // ...
   SEARCH_RESULT_EVENTS(Result<Vec<Event>, seed::fetch::FailReason<Vec<Event>>>),
   // ...
}

Step 11: Porting JSX

Porting React components (JSX) to Seed is straightforward. Basically you just have to replace the HTML tag names with the corresponding macro:

- <div className="app">
+ div![ class!["app"]]

If you're using custom components you import them and call their view function:

- import Sidebar from "./Sidebar"
+ use crate::components::Sidebar;

- <Sidebar search={ search } map={ map } />
+ Sidebar::view(&mdl)

The render function of your component becomes the view:

- class Main extends Component {
-   render(){
-     <div></div>
-   }
- }

+ pub fn view(mdl: &Mdl) -> impl View<Msg> {
+   div![]
+ }

In case you're using styled-components (as we are in our example project), you replace the styled compontens as follows by using the style! macro:

- const LeftPanelAndHideSidebarButton = styled.div`
-   display: flex;
-   flex-direction: row;
-   height: 100%;
- `
  // ...
-   <LeftPanelAndHideSidebarButton>

+   div![
+      style!{
+          St::Display => "flex";
+          St::FlexDirection => "row";
+          St::Height => percent(100);
+      }
+   ]

Most of the time you only need to change the syntax but keep the general component structure. Here is the full example (first step):

src/components/App.rs:

-// TODO: import "./styling/Stylesheets"
-// TODO: import "./styling/Icons"
-// TODO: import React, { Component } from "react"
-// TODO: import T                    from "prop-types"
+use crate::{Mdl,Msg, components::{Sidebar,LandingPage}};
+use seed::prelude::*;
+
 // TODO: import { translate }        from "react-i18next"
 // TODO: import NotificationsSystem  from "reapop";
 // TODO: import theme                from "reapop-theme-wybo";
-// TODO: import { FontAwesomeIcon }  from '@fortawesome/react-fontawesome'
 // TODO: import Swipeable            from 'react-swipeable'
-// TODO: import styled, { keyframes, createGlobalStyle } from "styled-components";
 // TODO: import V                    from "../constants/PanelView"
-// TODO: import Actions              from "../Actions"
 // TODO: import Modal                from "./pure/Modal"
 // TODO: import Map                  from "./Map"
-// TODO: import Sidebar              from "./Sidebar"
 // TODO: import LandingPage          from "./LandingPage"
 // TODO: import { EDIT }             from "../constants/Form"
 // TODO: import STYLE                from "./styling/Variables"
 // TODO: import { NUM_ENTRIES_TO_SHOW } from "../constants/Search"
 // TODO: import mapConst             from "../constants/Map"
-// TODO:
-// TODO: class Main extends Component {
-// TODO:
-// TODO:   render(){
+pub fn view(mdl: &Mdl) -> impl View<Msg> {
 // TODO:     const { dispatch, search, view, server, map, form, url, user, t } = this.props;
 // TODO:     const { entries, ratings } = server;
 // TODO:     this.changeUrlAccordingToState(url);
 // TODO:     const visibleEntries = this.filterVisibleEntries(entries, search);
 // TODO:     const loggedIn = user.email ? true : false;
-// TODO:
-// TODO:     return (
-// TODO:       <div className="app">
+               div![ class!["app"],
 // TODO:         <NotificationsSystem theme={theme}/>
 // TODO:         {
 // TODO:           view.menu ?
-// TODO:             <LandingPage
+                      LandingPage::view(&mdl),
 // TODO:               onMenuItemClick={ id => {
 // TODO:                 switch (id) {
 // TODO:                   case 'map':
@@ -90,11 +83,16 @@
 // TODO:         {
 // TODO:           view.modal != null ? <Modal view={view} dispatch={dispatch} /> : ""
 // TODO:         }
-// TODO:         <LeftPanelAndHideSidebarButton>
+                 div![
+                    style!{
+                        St::Display => "flex";
+                        St::FlexDirection => "row";
+                        St::Height => percent(100);
+                    },
 // TODO:           <SwipeableLeftPanel className={"left " + (view.showLeftPanel && !view.menu ? 'opened' : 'closed')}
 // TODO:             onSwipedLeft={ () => this.swipedLeftOnPanel() }>
-// TODO:             <Sidebar
+                      Sidebar::view(&mdl),
 // TODO:               view={ view }
 // TODO:               search={ search }
 // TODO:               map={ map }
@@ -125,7 +123,8 @@
 // TODO:               <ToggleLeftSidebarIcon icon={"caret-" + (view.showLeftPanel ? 'left' : 'right')} />
 // TODO:             </button>
 // TODO:           </HideSidebarButtonWrapper>
-// TODO:         </LeftPanelAndHideSidebarButton>
+                 ]
 // TODO:         <RightPanel>
 // TODO:           <div className="menu-toggle">
 // TODO:             <button onClick={()=>{ return dispatch(Actions.toggleMenu()); }} >
@@ -169,10 +168,10 @@
 // TODO:             showLocateButton={ true }
 // TODO:           />
 // TODO:         </Swipeable>
-// TODO:       </div>
-// TODO:     );
-// TODO:   }
-// TODO:
+               ]
+}
 // TODO:   filterVisibleEntries(entries, search){
 // TODO:     return search.entryResults.filter(e => entries[e.id])
 // TODO:       .map(e => entries[e.id])
@@ -358,12 +357,6 @@
 // TODO:   }
 // TODO: `
-// TODO: const LeftPanelAndHideSidebarButton = styled.div`
-// TODO:   display: flex;
-// TODO:   flex-direction: row;
-// TODO:   height: 100%;
-// TODO: `
 // TODO: const HideSidebarButtonWrapper = styled.div `
 // TODO:   position: relative;
 // TODO:   z-index: 2;

If you like to study the whole code you can checkout the rust branch in the original repository:

github.com/kartevonmorgen/kartevonmorgen/tree/rust

Step 12: Use Sass to build CSS

Originally we used Webpack to translate and bundle our SASS/CSS styles. Luckily in the Rust world there is the sass-rs crate and Cargo's build scripts feature that allows us to do similar things.

First add sass-rs as build dependency to Cargo.toml:

[build-dependencies]
sass-rs = "0.2"

And then define the build script:

build.rs:

use std::{error::Error, fs::File, io::Write};

const SASS: &str = "style.sass";
const CSS: &str = "style.css";

fn compile_scss() -> Result<(), Box<dyn Error>> {
    println!("cargo:rerun-if-changed={}", SASS);
    let options = sass_rs::Options {
        output_style: sass_rs::OutputStyle::Compressed,
        precision: 4,
        indented_syntax: true,
        include_paths: vec![],
    };
    let css = sass_rs::compile_file(SASS, options)?;
    let mut f = File::create(CSS)?;
    f.write_all(&css.as_bytes())?;
    Ok(())
}

fn main() {
    if let Err(err) = compile_scss() {
        panic!("{}", err);
    }
}

Include the resulting CSS file in your HTML:

<link rel="stylesheet" type="text/css" href="style.css">

The next time you run cargo build or webpack build --target web it will transpile the SASS file for you automatically :)

Et voilà!

Step 13: Bind events

In step 11 we ported the naked JSX to Seed but what happens with all the event handlers? These are still marked as TODOs:

input![
  // TODO: onChange = {onPlaceSearch}
  // TODO: onKeyUp  = {onKeyUp}
]

So let's turn them into valid Rust code:

 input![
-  // TODO: onChange    = {onPlaceSearch}
+  input_ev(Ev::Input,|txt|Msg::Client(Actions::client::Msg::setCitySearchText(txt))),
-  // TODO: onKeyUp     = {onKeyUp}
+  keyboard_ev(Ev::KeyUp, onKeyUp),
 ]

This leaves us with two event handlers.

The input_ev function maps Ev::Input change events to our custom Msg. The current content of the input element is available in the txt argument that is passed as a String.

With the keyboard_ev function we can map Ev::KeyUp events to a custom event handler. The onKeyUp function was defined as closure and takes a raw web_sys::KeyboardEvent event as its first argument:

let onKeyUp = move |ev: web_sys::KeyboardEvent|{
   ev.prevent_default();
   match &*ev.key() {
    "Escape" => {
        Msg::Client(Actions::client::Msg::setCitySearchText("".to_string()))
    }
    "Enter" => {
        if let Some(city) = currentCity {
            Msg::Client(Actions::client::Msg::onLandingPageCitySelection(city))
        } else {
            Msg::Client(Actions::client::Msg::Nop)
        }
    }
    "ArrowDown" => {
        Msg::Client(Actions::client::Msg::ChangeSelectedCity(1))
    }
    "ArrowUp" => {
        Msg::Client(Actions::client::Msg::ChangeSelectedCity(-1))
    }
    _=> {
        Msg::Client(Actions::client::Msg::Nop)
    }
   }
};

If you want to inject a handler that is defined by a parent component you can pass it as an argument of your view function:

fn view<F>(model: &Model, on_key_up: F) -> Node<Msg>
where
    F: FnOnce(web_sys::KeyboardEvent) -> Ms + Clone + 'static
{
    input![
        keyboard_ev(Ev::KeyUp, on_key_up)
    ]
}

For simple click events it's even simpler:

fn view(model: &Model, on_click_msg: Msg) -> Node<Msg> {
    button![
        simple_ev(Ev::Click, on_click_msg)
    ]
}

Step 14: How to do i18n

Please don't expect full featured i18n support here, but we made a small solution that's working for our case.

The idea is simple:

  1. Host some JSON files on your server that contain language specific values
  2. Fetch the data depending on your needs
  3. Mark the currently used language in your model
  4. Define a lookup function
  5. Use a map function in your views

So here is how it looks like.

1. JSON files

The JSON files are nested key-value stores (objects):

locales/translation-de.json:

{
    "ratings":{
        "requiredField":"Pflichtangabe",
        "rating":"Bewertung",
        "valueName":{
            "minusOne":"von gestern",
            "zero":"von heute",
            "one":"von morgen",
            "two":"visionär"
        }
    }
}

locales/translation-en.json:

{
    "ratings":{
        "requiredField":"required field",
        "rating":"rating",
        "valueName":{
            "minusOne":"so yesterday",
            "zero":"standard",
            "one":"of tomorrow",
            "two":"visionary"
        }
    }
}

2. Fetch

Fetching a file is straightforward.

In WebAPI.rs:

pub fn fetch_locale(lang: i18n::Lang) -> impl Future<Item = Msg, Error = Msg> {
    let url = format!("/locales/translation-{}.json", lang.alpha_2());
    Request::new(url).fetch_json_data(move |d| Msg::Server(Actions::server::Msg::LocaleResult(lang, d)))
}

3. Mark the currently used language

In lib.rs:

pub struct Mdl {
  // ...
  pub locales: HashMap<i18n::Lang, serde_json::Map<String, serde_json::Value>>,
  pub current_lang: i18n::Lang,
}

...and in i18n.rs:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Lang {
    En,
    De,
    Es
}

impl Lang {
    /// ISO 639-1 code
    pub fn alpha_2(&self) -> &str {
        match self {
            Lang::En => "en",
            Lang::De => "de",
            Lang::Es => "es",
        }
    }
}

4. Lookup function

In i18n.rs:

pub trait Translator  {
    fn t(&self, key: &str) -> String;
}

impl Translator for Mdl {
    fn t(&self, key: &str) -> String {
        match self.view.locales.get(&self.view.current_lang) {
            Some(locale) => {
                let keys: Vec<_> = key.split(".").collect();
                match deep_lookup(&keys, locale) {
                    Some(res) => res,
                    None => key.to_string(),
                }
            }
            None => key.to_string(),
        }
    }
}

fn deep_lookup(keys: &[&str], map: &Map<String, Value>) -> Option<String> {
    match &keys {
        [] => None,
        [last] => map.get(&last.to_string()).and_then(|x| match x {
            Value::String(s) => Some(s.clone()),
            _ => None,
        }),
        _ => map.get(&keys[0].to_string()).and_then(|x| match x {
            Value::Object(m) => deep_lookup(&keys[1..], m),
            _ => None,
        }),
    }
}

5. Use a map function in your views

pub fn view(mdl: &Mdl) -> impl View<Msg> {

    let t = |key| { mdl.t(&format!("ratings.{}", key)) };

    div![
        p![
            t("valueName.minusOne")
        ]
    ]
}

Summary

In this post we managed async actions such as fetching data from a server, we ported JSX code to Seed views, we told Cargo how to transpile our SASS styles and finally we migrated the event handlers of our new components.

Conclusion

Our motivation was to do a reality check of how far we could get using Rust as a frontend language.

This is how the result looks like:

Landing Page

Of course visually we expect no difference to the original React app, so we are happy to see that it looks and behaves equally ;-)

Overall we were surprised how smooth it worked to port an existing React project. We were able to devide the work into 14 small steps:

  1. Prepare
  2. Initialize a Seed project
  3. Move existing code and clean up
  4. Create modules
  5. Setup development workflow
  6. Porting constants
  7. Looking at Elm, Redux and Seed
  8. Porting actions
  9. Porting reducers
  10. Porting the WebAPI
  11. Porting JSX
  12. Use Sass to build CSS
  13. Bind events
  14. Do i18n

Of course there are things such as routing or the interaction with JavaScript which we didn't cover in the posts, but you can have a look at the corresponding sections on seed-rs.org that explain in-depth how it is working (see routing or JS interaction sections).

Our conclusion:

Rust, WASM, Seed and all the building blocks are mature enogh to create modern reliable web applications.

What are your thoughts? Did you have different expericenes? Or do you have further questions? Feel free to contact us.

Tags: rust, react, javascript, frontend, wasm