Retort-js is a rust-based front-end library that compiles to wasm
using wasm-pack
and wasm-bindgen
. It aims to provide utility for writing modern front-end
applications. You can think of it as a minimal Reactjs
.
Retort-js is written in rust, but that doesn't mean you should know any rust to use it. As mentioned earlier, rust
code is complied into wasm
and .wasm
files
can be used inside JavaScript modules(with a bit of glue code, courtesy of wasm-bindgen
). So all a developer is interfaced with is plain old JavaScript.
I'm going to get a little more technical here, explaining the general idea of how it works.
Component module is the only module which user directly interacts with. The Component
struct and its constructor are the most important bits in this module:
#[derive(Serialize, Deserialize, Debug)]
#[wasm_bindgen]
pub struct Component {
state: String,
presenter: String,
props: String,
vdom: Box<VirtualNode>,
#[serde(with = "serde_wasm_bindgen::preserve")]
effects: Array,
#[serde(with = "serde_wasm_bindgen::preserve")]
component_did_mount: Array,
#[serde(with = "serde_wasm_bindgen::preserve")]
component_will_unmount: Array,
}
#[wasm_bindgen(constructor)]
pub fn new(state: String, presenter: String) -> Component {
let empty_vdom = Box::new(VirtualNode {
// no need to have a valid vdom at this point
attributes: HashMap::new(),
children: Vec::new(),
node_type: NodeType::Tag(" ".to_owned()),
});
Component {
state,
presenter,
props: "{}".to_owned(),
vdom: empty_vdom,
effects: Array::new(),
component_will_unmount: Array::new(),
component_did_mount: Array::new(),
}
}
Using objects of this struct, a user can create Component
objects in JavaScript. As you can see, the only properties that are needed at the moment of initialization are state
and presenter
. state
is normally a JavaScript object and represents the state of a component. It is updated on predefined events using the following method:
#[wasm_bindgen]
pub fn set_state(&mut self, callback: Function) // Function type, from js_sys. represents a JavaScript callback.
which in JavaScript, would look like:
fetch(`https://jsonplaceholder.typicode.com/todos/${state.age}`)
.then((res) => res.json())
.then((res) => component.set_state((prevState) => ({ ...prevState, info: res })));
As you can see, the set_state
function on Component
instances takes a callback whose parameter is the current state of component. Pretty JavaScript-ish!
presenter
is a string, which is basically the markup template for the component. A valid presenter may look like this:
import HelloWorld from "/test/HelloWorld/HelloWorld.js";
<main>
<h1>My name is {state.name} and i'm {state.age} years old.</h1>
<HelloWorld />
</main>
An important thing to notice here is the use of curly brackets to indicate the use of a state or prop value. Other kinds of variables, like those defined with the const
keyword or event callbacks like onclick={callback}
are not yet supported.
Other properties are later added on demand using setter
functions; for instance, the following function allows you to register a callback, which will be called
when component mounts:
#[wasm_bindgen]
pub fn register_component_did_mount(&mut self, callback: Function)
example usage:
component.register_component_did_mount(
(intialProps, props, initialState, state) => {
const element = document.getElementById("click");
if (!element) return;
element.addEventListener("click", clickCallback);
function clickCallback() {
component.set_state((prev) => ({ ...prev, age: prev.age + 1 }));
}
}
);
Other than the Component
struct, this module has 2 other publicly available members. mount
and render
. mount
is used only on the root component and is basically
the starting point of our applications written with retort. render
though, must be called for every component that is going to be used in the application, because
it creates and populates the VDOM representation of the component, the one that we left out during the initialization of our component.
This module consists of 3 parts. utility functions, a publicly available wrapper function and unit tests for all previous functions. The wrapper function, tokenizer
, takes a String
as a parameter and returns a closure. Each successful call to the returened closuer will return the next tokenized value and its type, which is a variant of TokenizerState
enum:
#[derive(Debug, Clone, PartialEq)]
pub enum TokenizerState {
Uninitialized,
OpenAngleBracket, // <
CloseAngleBracket, // >
SelfClosingAngleBracket, // />
ClosingAngleBracket, // </
TagNameOpen,
TagNameClose,
Component,
Props,
Text,
Finalized,
}
This module provides utility functions to parse the presenter
of a component. Each presenter consists of at most 2 parts. The import statements and the markup template.
This module consists of a driver function for the functionality provided by the tokenizer
module. The parse_vdom_from_string
function transforms meaningless tokens
into VirtualNode
objects and returns a single virtual node.
This module provides functionality to build up the DOM according to the context of components and their VDOM representation:
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum NodeType {
Component(Component), //component object
Tag(String), // tag name
Text(String), // text content
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct VirtualNode {
pub node_type: NodeType,
pub attributes: HashMap<String, String>,
pub children: Vec<VirtualNode>,
}
The construct_dom
function will decide which utility function to call based on NodeType
for current VirtualNode
object.
This is one of the most important modules in retort. We saw earlier that retort supports the usage of some types of expressions in presenter
. The thing is, these
expressions are JavaScript expressions and need to(are expected to) be evaluated with JavaScript runtime behaviors. So, after retort detects an expression, that
expression is evaluated using the new Function()
syntax. But that doesn't mean we are sending expressions back and forth to JavaScript. This is done using one of
the most exiciting features of wasm-bindgen
:
#[wasm_bindgen(js_namespace=window)]
extern "C" {
fn Function(arg1: String, arg2: String, function_string: String) -> Function;
}
This syntax is basically empowering us to use the new Function
syntax of JavaScript inside rust environment and get the same result. Read more on what the
Function
constructor does in JavaScript and why we are not using the eval
function.
This is a module to improve DX. It visualizes the encountered errors during development for the developer:
*->these modules are not yet stable.
This project hasn't reached a stable phase yet. If you are interested in participating, contact me directly.
- Tokenizer
- Parser
- JavaScript Evaluator
- DOM initialization
- Conditional rendering
- Rendering lists
- Prop handling
- Error handling
- effect handling -> on going
- state management -> on going
- DOM updates -> up comming
- Unit tests -> on going