9.6 KiB
Architecture
The goal of this document is to make it easier for contributors (and anyone who’s interested!) to understand the architecture of the framework.
The whole Leptos framework is built from a series of layers. Each of these layers
depends on the one below it, but each can be used independently from the ones
built on top of it. While running a command like cargo leptos new --git leptos-rs/start
pulls in the whole framework, it’s important to remember that
none of this is magic: each layer of that onion can be stripped away and
reimplemented, configured, or adapted as needed, incrementally.
Everything that follows will assume you have a good working understanding of the framework. There will be explanations of how some parts of it work or fit together, but these are not docs. They assume you know what I’m talking about.
The Reactive System: leptos_reactive
The reactive system allows you to define dynamic values (signals), the relationships between them (derived signals and memos), and the side effects that run in response to them (effects).
These concepts are completely independent of the DOM and can be used to drive any kind of reactive updates. The reactive system is based on the assumption that data is relatively cheap, and side effects are relatively expensive. Its goal is to minimize those side effects (like updating the DOM or making a network requests) as infrequently as possible.
The reactive system is implemented as a single data structure that exists at
runtime. In exchange for giving ownership over a value to the reactive system
(by creating a signal), you receive a Copy + 'static
identifier for its
location in the reactive system. This enables most of the ergonomics of storing
and sharing state, the use of callback closures without lifetime issues, etc.
This is implemented by storing signals in a slotmap arena. The signal, memo,
and scope types that are exposed to users simply carry around an index into that
slotmap.
Items owned by the reactive system are dropped when the corresponding reactive scope is dropped, i.e., when the component or section of the UI they’re created in is removed. In a sense, Leptos implements a “garbage collector” in which the lifetime of data is tied to the lifetime of the UI, not Rust’s lexical scopes.
The DOM Renderer: leptos_dom
The reactive system can be used to drive any kinds of side effects. One very common side effect is calling an imperative method, for example to update the DOM.
The entire DOM renderer is built on top of the reactive system. It provides a builder pattern that can be used to create DOM elements dynamically.
The renderer assumes, as a convention, that dynamic attributes, classes,
styles, and children are defined by being passed a Fn() -> T
, where their
static equivalents just receive T
. There’s nothing about this that is
divinely ordained, but it’s a useful convention because it allows us to use
zero-overhead derived signals as one of several ways to indicate dynamic
content.
leptos_dom
also contains code for server-side rendering of the same
UI views to HTML, either for out-of-order streaming (src/ssr.rs
) or
in-order streaming/async rendering (src/ssr_in_order.rs
).
The Macros: leptos_macro
It’s entirely possible to write Leptos code with no macros at all. The
view
and component
macros, the most common, can be replaced by
the builder syntax and simple functions (see the counter_without_macros
example). But the macros enable a JSX-like syntax for describing views.
This package also contains the Params
derive macro used for typed
queries and route params in the router.
Macro-based Optimizations
Leptos 0.0.x was built much more heavily on macros. Taking its cues
from SolidJS, the view
macro emitted different code for CSR, SSR, and
hydration, optimizing each. The CSR/hydrate versions worked by compiling
the view to an HTML template string, cloning that <template>
, and
traversing the DOM to set up reactivity. The SSR version worked similarly
by compiling the static parts of the view to strings at compile time,
reducing the amount of work that needed to be done on each request.
Proc macros are hard, and this system was brittle. 0.1 introduced a
more robust renderer, including the builder syntax, and rebuilt the view
macro to use that builder syntax instead. It moved the optimized-but-buggy
CSR version of the macro to a more-limited template
macro.
The view
macro now separately optimizes SSR to use the same static-string
optimizations, which (by our benchmarks) makes Leptos about 3-4x faster
than similar Rust frontend frameworks in its HTML rendering.
The optimization is pretty straightforward. Consider the following view:
view! { cx, <main class="text-center"> <div class="flex-col"> <button>"Click me."</button> <p class="italic">"Text."</p> </div> </main> }
Internally, with the builder this is something like
Element { tag: "main", attrs: vec![("class", "text-center")], children: vec![ Element { tag: "div", attrs: vec![("class", "flex-col")], children: vec![ Element { tag: "button", attrs: vec![], children: vec!["Click me"] }, Element { tag: "p", attrs: vec![("class", "italic")], children: vec!["Text"] } ] } ] }
This is a bunch of small allocations and separate strings, and in early 0.1 versions we used a
SmallVec
for children and attributes and actually caused some stack overflows.But if you look at the view itself you can see that none of this will ever change. So we can actually optimize it at compile time to a single
&'static str
:r#"<main class="text-center"> <div class="flex-col"> <button>"Click me."</button> <p class="italic">"Text."</p> </div> </main>"#
Server Functions (leptos_server
, server_fn
, and server_fn_macro
)
Server functions are a framework-agnostic shorthand for converting a function, whose body can only be run on the server, into an ad hoc REST API endpoint, and then generating code on the client to call that endpoint when you call the function.
These are inspired by Solid/Bling’s server$
functions, and there’s
similar work being done in a number of other JavaScript frameworks.
RPC is not a new idea, but these kinds of server functions may be.
Specifically, by using web standards (defaulting to POST
/GET
requests
with URL-encoded form data) they allow easy graceful degradation and the
use of the <form>
element.
This function is split across three packages so that server_fn
and
server_fn_macro
can be used by other frameworks. leptos_server
includes some Leptos-specific reactive functionality (like actions).
leptos
This package is built on and reexports most of the layers already
mentioned, and implements a number of control-flow components (<Show/>
,
<ErrorBoundary/>
, <For/>
, <Suspense/>
, <Transition/>
) that use
public APIs of the other packages.
This is the main entrypoint for users, but is relatively light itself.
leptos_meta
This package exists to allow you to work with tags normally found in
the <head>
, from within your components.
It is implemented as a distinct package, rather than part of
leptos_dom
, on the principle that “what can be implemented in userland,
should be.” The framework can be used without it, so it’s not in core.
leptos_router
The router originates as a direct port of solid-router
, which is the
origin of most of its terminology, architecture, and route-matching logic.
Subsequent developments (like animated routing, and managing route transitions
given the lack of useTransition
in Leptos) have caused it to diverge
slightly from Solid’s exact code, but it is still very closely related.
The core principle here is “nested routing,” dividing a single page into independently-rendered parts. This is described in some detail in the docs.
Like leptos_meta
, it is implemented as a distinct package, because it
can be replaced with another router or with none. The framework can be used
without it, so it’s not in core.
Server Integrations
The server integrations are the most “frameworky” layer of the whole framework.
These do assume the use of leptos
, leptos_router
, and leptos_meta
.
They specifically draw routing data from leptos_router
, and inject the
metadata from leptos_meta
into the <head>
appropriately.
But of course, if you one day create leptos-helmet
and leptos-better-router
,
you can create new server integrations that plug them into the SSR rendering
methods from leptos_dom
instead. Everything involved is quite modular.
These packages essentially provide helpers that save the templates and user apps from including a huge amount of boilerplate to connect the various other packages correctly. Again, early versions of the framework examples are illustrative here for reference: they include large amounts of manual SSR route handling, etc.
cargo-leptos
helpers
leptos_config
and leptos_hot_reload
exist to support two different features
of cargo-leptos
, namely its configuration and its view-patching/hot-reloading
features.
It’s important to say that the main feature cargo-leptos
remains its ability
to conveniently tie together different build tooling, compiling your app to
WASM for the browser, building the server version, pulling in SASS and
Tailwind, etc. It is an extremely good build tool, not a magic formula. Each
of the examples includes instructions for how to run the examples without
cargo-leptos
.