Introducing Kobold
Kobold is a crate for creating declarative internet UI.
The TodoMVC implemented in Kobold weighs about 30kb when gzipped over the wire, out of which the gzipped Wasm blob is underneath 20kb. There are no tricks there. I have not changed the default allocator, nor have I minified the JavaScript (though I most likely ought to).
The objective I’ve set for myself was creating one thing that works and appears like a yet one more JSX-esque library akin to React or Yew, however with out the total performance overhead of Virtual DOM.
All of it in Rust, in fact.
The documentation mixed with the examples ought to offer you some perception into constructing issues. What I want to do right here as an alternative is lay down some context, clarify how I take into consideration this drawback house and the way Kobold truly works.
Zero-Value Static HTML
On the floor Kobold works precisely like a Digital-DOM-based library. There aren’t any additional containers to wrap your information in and it does certainly carry out the dreaded diffing, nevertheless the one factor that ever will get diffed is your information.
I first began experimenting in December of 2019 with a procedural macro that might finally turn into the view!
macro in Kobold. I did not take into consideration parts, or information states, occasion dealing with, and even rendering lists. All I needed is one thing that appeared like JSX, however as an alternative of manufacturing Digital DOM it might produce pre-compiled JavaScript code to handle all of the stuff within the DOM that by no means adjustments.
Ignoring parts for now, absolutely the easiest piece of code we may do with Kobold would look one thing like this:
fn hey() -> impl View {
view! {
<h1>"Whats up world!"</h1>
}
}
When you’ve got ever labored with Yew or React this could instantly look acquainted. The primary distinction between Kobold and Yew right here is the opaque impl View
return kind. The View
trait definition appears to be like as follows:
/// Trait that describes sorts that may be rendered within the DOM.
pub trait View {
/// The product ought to include a DOM reference to this View and
/// any information it must replace itself.
kind Product: Mountable;
/// Construct a product that may be mounted within the DOM from this kind.
fn construct(self) -> Self::Product;
/// Replace the product and apply adjustments to the DOM if needed.
fn replace(self, p: &mut Self::Product);
/// ... skipping some strategies right here which are auto offered
}
Any kind that implements View
must know the way to construct
its Product
and the way to replace
it on subsequent renders. Stated Product
will include a reference to its root DOM component plus any information it would have to diff for updates. The necessary half is: you by no means have to write down these two strategies by hand.
If we develop the view!
macro our hey
perform turns into:
fn hey() -> impl View {
#[wasm_bindgen(inline_js = "<snip>")]
extern "C" {
fn __e0_dd8ebbc530e4055f() -> web_sys::Node;
}
Static(__e0_dd8ebbc530e4055f)
}
I’ve taken the freedom of formatting the output whereas additionally not increasing the #[wasm_bindgen]
attribute macro right here. The snipped JavaScript is:
export perform __e0_dd8ebbc530e4055f() {
let e0=doc.createElement("h1");
e0.append("Whats up world!");
return e0;
}
The Static
is only a newtype wrapper. Right here is its declaration and View
implementation:
pub struct Static<F>(pub F);
impl<F> View for Static<F>
the place
F: Fn() -> Node,
{
kind Product = Component;
fn construct(self) -> Component {
Component::new(self.0())
}
fn replace(self, _: &mut Component) {}
}
Two issues of be aware:
Static<F>
taking perform as a generic parameter may havestd::mem::size_of::<Static<_>>() == 0
. Which means that on runtime callinghey()
does fairly actually nothing, and solelyhey().construct()
calls the extern JavaScript perform that constructs the<h1>
header and provides us the reference to its root component.- Since there aren’t any expressions right here, the
replace
methodology can also be empty, which means that invokinghey().replace(&mut product)
additionally does completely nothing at runtime.
Herein lies the crux of the #1 declare that Kobold makes: static HTML is zero-cost. It’s created within the precompiled JavaScript with absolute minimal of Wasm-JavaScript boundary crossing needed (one, precisely) and by no means up to date. There isn’t a diffing, not even a department to examine if diffing is critical.
Injecting Expressions
Let’s modify our hey
perform to render a string slice within the view:
fn hey(identify: &str) -> impl View + '_ {
view! {
<h1>"Whats up "{ identify }"!"</h1>
}
}
The expression in curly braces like { identify }
should implement View
itself, which &str
does. Notably a View
solely must dwell lengthy sufficient for use in both construct
or replace
, no 'static
lifetime needed. This avoids a complete bunch of momentary clones on every render.
The code this expands to is a bit longer, so let’s take a look at it little by little. The necessary half is:
fn hey() -> impl View {
/// snip!
struct Transient<A> {
a: A,
}
Transient { a: identify }
}
Given the earlier instance, you may already see the place that is going. The macro defines a brand new Transient
struct with a generic subject and it places the expression in it, on this case simply the variable identify
into that subject.
For this to work, the macro additionally has to implement the View
trait for this Transient
kind. It does it like this:
impl<A> View for Transient<A>
the place
A: View,
{
kind Product = TransientProduct<A::Product>;
fn construct(self) -> Self::Product {
let a = self.a.construct();
let e0 = Component::new(__e0_22790d91e19a0c42(a.js()));
TransientProduct { a, e0 }
}
fn replace(self, p: &mut Self::Product) {
self.a.replace(&mut p.a);
}
}
Right here is the place the magic occurs. The construct
methodology first builds product of the expression a
. The View
implementation for &str
will simply make a Text
DOM node and maintain an allotted String
of the identify
to examine for adjustments later. Adventurous customers may additionally write { identify.fast_diff() }
to change to pointer handle diffing, making this view utterly allocation-free for its whole life cycle.
We then take the &JsValue
reference to mentioned textual content node and name a precompiled __e0_22790d91e19a0c42
JavaScript perform with it. Skipping the #[wasm_bindgen]
half right here is the code:
export perform __e0_22790d91e19a0c42(a) {
let e0=doc.createElement("h1");
e0.append("Whats up ",a,"!");
return e0;
}
That is virtually similar to the beforehand generated perform, besides we append our Textual content
node as variable a
between two static textual content nodes: "Whats up "
and "!"
. Rust solely is aware of about that one Textual content
node containing the identify
we have to render and the foundation (<h1>
on this case) it wants with a purpose to stick this view into the DOM.
The replace
methodology is even less complicated: name replace on all fields of the Transient
with their corresponding merchandise. In our case this simply defers the replace to the View
implementation of &str
. No different node within the tree is ever touched: it’s zero-cost.
The TransientProduct
struct is reasonably boring so I’m not going to clarify it right here intimately. Suffice to say it simply holds all of the merchandise for all of the expressions, the foundation Component
, and probably few different hoisted parts that have to have their attributes operated on instantly.
Zero-Value Parts
There should be one– and preferably only one –obvious way to do it.
In Kobold there is just one sanctioned method to create parts: by turning plain features into useful parts. This includes:
- Altering the identify to comply with PascalCase scheme, identical to Rust structs.
- Annotating it with the
#[component]
attribute macro.
Taking our hey
instance from above and turning it right into a element is so simple as:
#[component]
fn Whats up(identify: &str) -> impl View + '_ {
view! {
<h1>"Whats up "{identify}"!"</h1>
}
}
The one distinction between that element and a plain Rust perform is how you’d invoke them within the view!
macro:
view! {
// the `Whats up` useful element:
<Whats up identify="World" />
// the `hey` perform:
{ hey("World") }
}
The precise interplay between the view!
and the #[component]
macros is unstable so I would not advise writing element structs by hand. That mentioned, at the moment <Whats up identify="World" />
merely desugars into:
Whats up::render(Whats up { identify: "World" })
The perform turns into an related render
perform on a struct with fields mapping to the unique parameters. Whereas this appears to be like extra difficult than hey("World")
, it has no efficiency overhead in comparison with an everyday perform name. The one function parts serve is the acquainted syntax with named parameters.
Off the Hook!
Astute readers by now are absolutely questioning how stateful parts work. Right here is the kicker:
Kobold doesn’t have an idea of a stateful element. The view rendering half is comparatively unopinionated about state administration. It’s fully possible to implement React-esque hooks like in Yew or Dioxus as a Third-party crate. Reactive signals like in Sycamore or Leptops must also work together properly with the View
trait.
The state administration offered by Kobold is in a really actual sense non-obligatory, you may even decide out of it utterly by disabling default options. Assuming you have not right here is an instance:
stateful(0, |rely| {
bind! _
view! {
<p>"Counter is at "{ rely }</p>
<button onclick={inc}>"Increment"</button>
<button onclick={dec}>"Decrement"</button>
}
})
This creates a stateful view. On this case the state is only a easy i32
integer. The rely
argument handed into the closure is of kind &Hook<i32>
(not associated to React hooks, I identical to the identify). You possibly can learn the state from the hook just by dereferencing it. It itself additionally implements the View
trait so we will simply use it instantly with out having to write down { **rely }
.
The bind!
macro creates event-handling closures that may mutate the state by way of a easy &mut i32
reference. If the macro appears to be like a bit too magical for you, you may all the time select to write down binds the good distance by way of Hook::bind
:
let inc = rely.bind(transfer |rely, _| *rely += 1);
let dec = rely.bind(transfer |rely, _| *rely -= 1);
These are at the moment 100% equal, the macro simply saves you from having to kind “rely
” 4 additional occasions.
With out having to repeat a lot of the documentation what occurs right here is fairly straight-forward: Clicking the button updates the state of the integer. The primary closure is being run on each state change, the rely
is diffed with the previous one and its DOM Textual content
node is up to date if needed.
I personally fairly like this mannequin for its simplicity. Whereas I’ve but to push it to the purpose the place it turns into actually unwieldy, I can think about {that a} extra sturdy resolution could be needed for sufficiently advanced purposes.
Going Ahead
For now Kobold is an entire minimal viable product. I reckon the View
trait, the view!
macro, and the #[component]
attribute macro are fairly steady by now. The subsequent steps could be:
- Ending assist for default values in parts.
- Help for keyed lists, in order that we will truly do correct apples-to-apples benchmarks.
- Probably a PR to Trunk with an choice to bundle all of the JavaScript recordsdata from
wasm-bindgen
with out resorting to additional instruments likeesbuild
.
When you’ve got come this far, thanks! Try the repo or this neat little QR code instance utilizing the fast_qr
-based kobold_qr
. In spite of everything, considered one of my principal motivations for doing this was pulling in advanced code like QR code era and have it work immediately within the browser.
Final, however not least…
Why “Kobold”?
As a result of I’m actually a colossal nerd and Kobolds are acquainted, intelligent, and small.