Porting a JavaScript App to WebAssembly with Rust (Part 3)
28. 02. 2020
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 components 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 check out 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 TODO
s:
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:
- Host some JSON files on your server that contain language specific values
- Fetch the data depending on your needs
- Mark the currently used language in your model
- Define a lookup function
- 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:
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 smoothly it worked to port an existing React project. We were able to devide the work into 14 small steps:
- Prepare
- Initialize a Seed project
- Move existing code and clean up
- Create modules
- Setup development workflow
- Porting constants
- Looking at Elm, Redux and Seed
- Porting actions
- Porting reducers
- Porting the WebAPI
- Porting JSX
- Use Sass to build CSS
- Bind events
- 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 works(see routing or JS interaction sections).
Our conclusion:
What are your thoughts? Did you have different experiences? Or do you have further questions? Feel free to contact us.
Get in touch
You can see that we know what we are doing. We can support with hands on software development or consulting and architecture development. Get in touch!