The Motivation
Svelte is like Rust for me in that I had heard good things for years before I even gave it a try.
Svelte is like Rust for me in that I loved it immediately.
It's really very simple to write in, the basic idea is engaging, and after trying it I wanted so badly for it to succeed. For context I have professional experience with vanilla JavaScript, React, and Angular. After transitioning a small app from Angular to Svelte, here were my takeaways:
- It's very much like Vue
- Single file structure
- Framework provides a lot out of the box
- It's got a better pattern for reactive programming than React
- I'm guessing Svelte is a large part of the motivation behind React's new React without memo project
- It's had five years to become popular, and yet it has not.
- I can only guess at this, but I'd say Svelte was simply late to the game. So much of the web is written using other frameworks, and no one wants to re-write it all
- Initially Svelte touted itself as providing all the functionality you'd expect from a big JS framework with way less code. That's turned out to not be the full story, and an app with lots of components will end up being less LOC if written in, say, React than Svelte
To appease my most recent hyperfixation I was listening to anything with Rich Harris (the creator of Svelte) in it. One such piece of media was JS Party 205. In it Rich had a throwaway line about maybe someday rewriting the compiler in Rust. It came across as a "it'd be nice to have, but it's definitely not a priority" project. Which makes sense. The Svelte compiler is already mostly TypeScript, so it's got enough static typing to be safe and is also very easy for new people to contribute to, a must for any open source project. Rich is wise to stick to improving the Developer Experience first before doing a largely unnecessary rewrite. React is proof that if your JS framework makes it quick to develop apps people will use it regardless of bundle size.
But that doesn't stop this project from being very tempting to take on, and it definitely doesn't stop me from trying. So this is going to be the start of a new series in which I chronicle my journey of rewriting a compiler (which I've never done before) for a project I've barely used. What I'm hoping to get out of it:
- A better understanding of:
- Svelte
- JavaScript
- Compilers
- Rust
- Tackling big projects
- A compiler that can do a significant subset of what the Svelte compiler can do, but much much faster
- I don't yet care for Server-Side Rendering, so that should cut the work a bit
- Smaller bundle sizes
- Code splitting can only do so much
- Not caring about bundle sizes is how we end up with 5MB of JS
- A list of changes I'd like to make to the Svelte compiler so even if my compiler doesn't get used the community can still benefit
- Can we decrease bundle size?
- Can we speed up compilation?
- Are there any edge-cases not currently handled well?
From Scratch
I've written a little Rust, but never professionally, so I'm sure what I write is going to be ugly to those in the know. But it compiles, which is half the battle.
Before starting this project let's take a quick peek at the Svelte repo. Oh, huh, looks like the compiler smaller than I would have thought, weighing in at only ~800KB (if you include the runtime), but 800KB of code to rewrite is still fairly massive for a weekend warrior like myself. So, for my initial attempt I crafted the world's smallest svelte app:
<span>Hello world!</span>
<span>Goodbye!</span>I ran that through a recent Svelte compiler to get:
/* generated by Svelte v3.44.3 */
import {
SvelteComponent,
detach,
element,
init,
insert,
noop,
safe_not_equal,
space,
} from "svelte/internal";
function create_fragment(ctx) {
let span0;
let t1;
let span1;
return {
c() {
span0 = element("span");
span0.textContent = "Hello world!";
t1 = space();
span1 = element("span");
span1.textContent = "Goodbye";
},
m(target, anchor) {
insert(target, span0, anchor);
insert(target, t1, anchor);
insert(target, span1, anchor);
},
p: noop,
i: noop,
o: noop,
d(detaching) {
if (detaching) detach(span0);
if (detaching) detach(t1);
if (detaching) detach(span1);
},
};
}
class Component extends SvelteComponent {
constructor(options) {
super();
init(this, options, null, create_fragment, safe_not_equal, {});
}
}Alright, seems simple enough. What if I just write a very very very basic compiler that parses the DOM tree looking for Elements and then spits out the resultant JavaScript as it chugs along? If we merge all the compilation steps we avoid running through the file multiple times, a plus for performance. So I gave it a shot, and got something that produced exactly the same output, but only for the simplest cases. After, I realized that, while this approach would yield something very very fast, doing everything in one step made the program fairly difficult to change. I think I'd be better off sacrificing just a little performance in exchange for modularity. Let's shelve this attempt for now and try something more traditional.
A Rewrite
What if I just copied the Svelte compiler, written in TypeScript, changed all of the file extensions from .ts to .rs, and fixed all the bugs? It'd be a slog, sure, but at the end of the day I'd have a compiler that was very nearly the same, but presumably more performant.
This attempt I gave a real try, spending maybe three nights just chugging away. What I got was just more and more errors, which was fairly disheartening. I also realized just how different Rust is, and that a 1-to-1 rewrite would result in something that wouldn't be as ideal as it could be. For example, take this very simple TypeScript class:
class Node extends BaseNode {
name: String? = null,
children: Node[] = [],
}The equivalent Rust struct might look like:
struct Node {
base: BaseNode,
name: Option<String>,
children: Vec<Node>
}I don't think that's great. What fields belong on Node? Which belong on BaseNode? Plus TypeScript's got a garbage collector, so for parity any T should probably be wrapped in an Rc<T>. Plus everything's mutable, so we'd want to wrap that in a RefCell<T>.
struct Node {
base: BaseNode,
name: Option<String>,
children: Vec<Rc<RefCell<Node>>>
}While probably fine, this just looks it'd like a pain to work with. I think we can do better. These nodes don't really reference each other, so we don't actually need an Rc, and without that we wouldn't need a RefCell. Also I'm up to 1337 errors, so I'm pretty happy ending this attempt here.
On the Shoulders of Giants
Starting from characters and parsing that into an AST that could render a Svelte program could definitely open open possibilities for some low-level optimizations, but even just parsing a normal HTML document is not a small project, and Svelte includes JavaScript, TypeScript, Handlebars-esque blocks, CSS, and SCSS, too. Doing all that at once would be the mother of all slogs. But, luckily, I don't have to! There exist many, many pre-build alternatives. The Svelte compiler itself even uses a couple.
So here's the new plan:
- Create a basic Rust lib and make sure I can get communication working to/from JavaScript via FFI in Node
- Send a svelte file as a string through a DOM parser to get a basic AST
- Convert that AST to our own custom one
- This is where we should do any conversions we might want to the original AST, like squashing child Text nodes, splitting nodes or inserting nodes, etc.
- Render that custom AST to a string
- We should eventually keep track of other things, warnings, CSS, etc., but for now we're simply trying to replicate the JavaScript string
- We should keep in mind that whatever we do here we'll need to do something very similar when rendering for SSR, so we should do as much AST manipulation as possible before this step. This should be a simple "let's render a string"
But before we really get started let's see what kind of performance we're getting out of the original compiler. I'm only looking to test execution times, so to ignore however long it takes to load everything into Node's runtime we'll simply do two runs per test: once to ensure everything's loaded, and the other we'll actually time. How long does Svelte take to compile this simple program?
<span>Hello world!</span>On my machine this is about 5ms. That seems... high? I think? I haven't written a compiler before, but computers are fast and this program is absolutely tiny. Maybe there's some cost to compile anything, regardless of program size? Let's see how it scales. What if we do 10x?
<span>Hello world!</span>
<span>Hello world!</span>
<span>Hello world!</span>
<span>Hello world!</span>
<span>Hello world!</span>
<span>Hello world!</span>
<span>Hello world!</span>
<span>Hello world!</span>
<span>Hello world!</span>
<span>Hello world!</span>I should note this renders extra calls to a space() function by default (I assume so the resultant DOM looks nice), so to avoid this I remove all newlines during tests. Anyway, with newlines removed this takes 16ms. Hmmm.... let's do a few more:
Number of spans | Time to render |
|---|---|
| 1 | 5ms |
| 10 | 16ms |
| 100 | 62ms |
| 1000 | 432ms |
Okay, so half a second for 1000 elements isn't super great, but it's still probably okay. How often are you writing components this big anyhow? And any half-decent system would only recompile whenever you make changes. Still, fast compiles make for happy devs, so this is the mark we're trying to beat.
The only other order of business for now is the name. I did what I always do and looked up some synonyms for similar projects, in this case Svelte. After some browsing I landed on Lithe is far from done, but it can easily render the example programs above. In release mode, 1000 lithe, so that's what I'll use for now until I find some other JS project already picked that name.Click for spoilers
spans takes just 5ms to run (!!!), most of which is simply the initial DOM parsing provided by an external library. Time is lost initializing FFI and shuttling strings to/from Node, but even so it turns out Rust is really really fast (who knew?). As we add more features lithe can only slow down, but for now I'll take this as a sign that this work might actually be useful.
Optimize for the minifier
halfnelson did a lovely investigation of how large Svelte projects scale. I'm not looking to rehash that per se, but I am interested in how well the current Svelte compiler's output gets minified.
For this investigation I'll be pulling as many .svelte files as I can. Let's use the Copilot strategy and pull from GitHub repos with permissive enough licenses.
Even doing this was a bit of a project. I figured this might come up again, so I made the code clean enough that I wasn't embarrassed to release it. You can see what I came up with at the github repo. I used Rust with the lovely octocrab crate to handle the Github API, git2 to handle downloading the repositories, and a little bit of tokio for async and multi-threading. I'm very happy with the result, though I did run into rate-limiting for the GitHub API fairly quickly even with a personal access token.
Even so, I was able to come out of that with 220 repos without much trouble, from which I extracted 20MB of raw .svelte files. Once I removed the empties and the duplicates I was left with a respectable 18MB worth of files. I ran those files through the svelte compiler, skipping any that wouldn't compile. I didn't expect to run this too often, so I ended up using ruby to call a shell script that told node to compile a particular file with some options, which I ran over every file twice: once with minification and once without. While I ended up with a workflow that took several minutes to run it was the fastest for me to write.
After a cuppa I had this:
| Type | Total size (KB) |
|---|---|
raw .svelte files | 18420 |
| compiled | 23188 |
| minified | 15764 |
| gzipped | 10836 |
Very interestingly there were two files whose minified size was actually slightly larger than the original. This makes me think I could find a better minifier than minify. Even after playing with the available options, going so far as to enable some unsafe operations, I still couldn't get that number down. I might try another minifier entirely (node-minify looks cool), but for now I'll keep what I've got.
I think that's enough for now. Once I get a compiler going I can use this to measure just how well my output fairs against Svelte's, both raw and after minification. I believe there's some room for improvement if we try and output code that's easy for the minifier to minify. For example, I've noticed the Svelte compiler outputs something sort of like this for detaching a component (heavily edited for simplicity):
import { detach } from "./svelte";
let a;
let b;
let c;
function fn(do_detach) {
if (do_detach) detach(a);
if (do_detach) detach(b);
if (do_detach) detach(c);
}Which get minified to:
import { detach } from "./svelte";
let a, b, c;
function fn(t) {
t && detach(a), t && detach(b), t && detach(c);
}If, instead, the source was:
import { detach } from "./svelte";
let a;
let b;
let c;
function fn(do_detach) {
if (do_detach) {
detach(a);
detach(b);
detach(c);
}
}Which, in this example at least, is functionally equivalent. I haven't dug into Svelte enough to know if this is always going to be a safe transformation. In any case, this can be minified to:
import { detach } from "./svelte";
let a, b, c;
function fn(t) {
t && (detach(a), detach(b), detach(c));
}A whole 4 characters saved! I know, I know, it doesn't seem like much, but that's basically free and is a function of how many variables your svelte program has. One last consideration is aliasing. We could, for example, imagine a minifier smart enough to output this:
import { detach as d } from "./svelte";
let a, b, c;
function fn(t) {
t && (d(a), d(b), d(c));
}We could also eschew aliasing and just have a separate svelte library that only has these minified functions to avoid the "as x" bit:
import { d } from "./svelte-mini";
let a, b, c;
function fn(t) {
t && (d(a), d(b), d(c));
}The downside of some of these optimizations, of course, is that it becomes harder and harder to debug in production. But if we're already at the point of minifying I say it's no holds barred. While minimizing the compiled svelte files seems like an obvious win it may not actually amount to much given how good gzip is. We'll need to run out output through gzip to know if we'll actually be sending fewer bytes down the wire. The examples above are so small that gzip doesn't actually help. The original version is unchanged at 114 bytes while the hand-optimized one is actually larger after compression, growing from 91 to 113 bytes.Extra considerations
What about WASM?
Excuses
I know I haven't talked much about the state of Lithe except to mention how fast it is while run natively, but that's only because Lithe is still very very bare bones. It parses the contents of svelte files into an HTML AST, which it then transforms into a more Svelte-y AST, which it then runs through to generate the final output. And it only works for the simplest of svelte files.
Thinking Ahead
But before I write more about the internals of Lithe or implement any new features, I thought it'd be a fun diversion to think ahead to when this is a smash success and absolutely everyone wants it. How are we going to package and release it? The obvious (and my initial) answer was as a native extension that we talk to via FFI. But how will that be received? JavaScript developers like fast build times, even at the very start. Projects like Snowpack are successful exactly because of that. No frontend engineer wants to wait for the latest compiler to be downloaded and build on their local machine, especially if that requires the Rust toolchain.
An Aside
I never knew, but apparently tsc is, itself, written in TypeScript. Which really calls into question this whole endeavor. If something as successful as tsc (which I never felt was all that slow) is written in TypeScript, why can't Svelte? The move to Rust has never been a sure one, and this might just be another reason against. Perhaps the performance limitations I'm trying to solve are more algorithmic than they are technical.
What's WASM again?
In any case, assuming Lithe is alive and well and we want to release it to impatient developers, we could maybe release native binaries. This seems (to me, anyway) to be something of a hassle, plus your consumers need to have the utmost trust in you. So what about WASM? It's cross-platform, I assume Node can run it, and I can only imagine that it's cutoff from anything dangerous like network or file IO by default.
How to
I set to work slightly modifying Lithe to output a WASM module rather than a native Rust lib. There were only two non-obvious changes I had to make. First, I needed to set my WASM's package.json's type to be module to get it to load correctly:
{
"name": "lithe",
"version": "0.1.0",
"files": [
"lithe_bg.wasm",
"lithe.js",
"lithe_bg.js",
"lithe.d.ts"
],
"module": "lithe.js",
"types": "lithe.d.ts",
"sideEffects": false,
"type": "module", // <- new
}I also had to refrain from using std::time, though luckily I was only using that for detailed performance tracing. With those two small changes done it just... worked. I am now able to import my compiler with a wondrously simple:
import { wasm_compile } from "./wasm/lithe.js";Wow. And the timings were better than I could have hoped: just 4ms for 1000 span's, which is just as fast as native!!!
I checked my enthusiasm, and then I checked the output to make sure it was actually doing what it should (it was). Then to make sure I was actually doing the timing correctly (I was) I tried 10,000 span's, and that took only 42ms. Svelte, for comparison, had long since overflowed its stack.
I should note that I don't actually think WASM is just as fast as native. Maybe it is in some instances, but the times involved here are so small and the testing itself so basic that I can't confidently prove anything. But what I can do is predict that, when all is said and done, that the WASM version of Lithe is probably going to run about as fast as the native version most of the time. But even if it's 20x slower that's still far faster than Svelte's TypeScript compiler and that's good enough for me.
Conclusion
It might be too early to tell, but just from this it's looking like releasing this compiler via WASM might be totally viable. And the icing on the cake? The current version of Lithe, when compiled to WASM, weighs in at a mere I did have to enable node's 268K. 268K! That's even smaller than a native hello world in Rust. Color me impressed.Disclaimer
--experimental-wasm-modules flag to get my WASM binary to even load. I'm hoping that flag goes away and WASM is supported by default, but I suppose there's a chance WASM goes the way of flash.
Apples to Apples
Confession Time
My performance testing methodology so far has been extremely flawed. It serves mostly as an indicator for how rigorous performance testing might go when I eventually get around to it. Which I think is reasonable, but it doesn't have to be quite so unfair to Svelte. Svelte is doing quite a lot more than Lithe, so I really shouldn't be comparing them side by side. It'd be good to re-run some earlier tests, but with the JavaScript equivalent of what Lithe is doing. Which, let's be honest, is mostly just parsing the HTML into an AST.
Disambiguation
It's good to have names for things, so let's call this super-basic JavaScript version LitheJS.
The Specifics
Lithe has been using the html_parser crate. For LitheJS I'll use the very popular Fast HTML Parser. It's got 2.3M downloads a week, and it's even got Fast in the name. If anything's going to give Lithe a run for its money it's going to be that.
The Methodology
We'll do the same as before: super simple HTML files, just N <span>Hello world!</span> elements. No nesting, no scripting, and I'm not even going to ask LitheJS to produce any output or perform any transformations. I just want to know: how long does it take to parse the HTML into an AST, and how does that compare with Lithe?
My thought is if Lithe can still beat out LitheJS, even after giving it all these advantages, then we're on the right track.
Notably, though, I'm not going to bother improving any of the rest of my methodology just yet. I'm not averaging multiple runs, I'm not going to nest HTML elements, I'm measuring performance directly in Node, all results are in ms, etc.
The Results
Number of spans | Svelte | Lithe - native | Lithe - WASM | LitheJS |
|---|---|---|---|---|
| 1 | 7ms | 3ms | 1ms | 1ms |
| 10 | 10ms | 1ms | 1ms | 1ms |
| 100 | 108ms | 4ms | 3ms | 1ms |
| 1000 | 348ms | 6ms | 9ms | 3ms |
| 10,000 | N/A (SO) | 53ms | 81ms | 125ms |
Wow, that Fast HTML Parser really is fast! At least until you get into really big files. But let's dig into that a bit more. Right now I'm just parsing these file contents and throwing away the result:
function simple_html_parser(contents: String) {
const root = parse(contents);
}I haven't read the source, but I suppose it's possible that Fast HTML Parser is being super lazy and not fully parsing the HTML string it was given until it has to. Let's make it work a little harder:
function simple_html_parser(contents: String) {
const root = parse(contents);
const result = contents.toString();
return result;
}Let's re-run those last couple of tests. I included a range here since the results were highly variable:
Number of spans | LitheJS |
|---|---|
| 1000 | 8ms-18ms |
| 10,000 | 58ms-140ms |
Alright, so it's a little bit slower, but not by that much. I'm guessing there's no lazy evaluation going on here: Fast HTML Parser is just fast. For fun I added timing inside the native version of Lithe just around the HTML parsing bit. And if we get rid of the toString() call in our simple_html_parser we can compare just the DOM parsing.
Number of spans | LitheJS (just HTML parsing) | Lithe - native (just HTML parsing) |
|---|---|---|
| 100 | 1ms | 0ms |
| 1000 | 4ms-13ms | 1ms |
| 10,000 | 41ms-142ms | 18ms-27ms |
And with that we're finally comparing like with like. And the results look how I'd expect. If we take out the FFI overhead and only parse the HTML then the Rust version is looking to be a bit faster, especially for larger files.