Post: Svelte - A compiler for Vue or React
In the era where formidable FE frameworks like React and Vue are competing, a new FE framework (though a bit late to call it new..) has emerged, and I decided to post about it. Actually, I learned about it last year, but for various reasons, I am writing only now. I only knew roughly the concept, but it feels like it’s time to delve a bit deeper into it.
Svelte?
Svelte is a compiler aimed at Vue or React. What does that mean? The modern frameworks like Vue and React use their syntax to construct the DOM and parse it at runtime. Naturally, the cost of executing the code delivered to the browser is transferred to the end-user. Hence, Svelte starts with the concept of compiling at build time instead of at runtime.
They call themselves a compiler, but in fact, I think of it as a framework since it still requires runtime.
Moreover, it is said that instead of using techniques like tracking changes through a VDOM (Virtual DOM), Svelte writes code that surgically updates the DOM when the state changes.
Instead of using techniques like virtual DOM diffing, Svelte writes code that surgically updates the DOM when the state of your app changes.
Let’s delve a bit into what this means. Below is a basic click & count example written with Svelte.
<script> let cnt = 0; const onclick = () => { cnt += 1; }; </script>
<button on:click={onclick}>{cnt}</button>
When compiled with the Svelte REPL, it generates code like the following.
/* App.svelte generated by Svelte v3.38.2 */
import {
SvelteComponent,
append,
detach,
element,
init,
insert,
listen,
noop,
safe_not_equal,
set_data,
text
} from "svelte/internal";
function create_fragment(ctx) {
let button;
let t;
let mounted;
let dispose;
return {
c() {
button = element("button");
t = text(/*cnt*/ ctx[0]);
},
m(target, anchor) {
insert(target, button, anchor);
append(button, t);
if (!mounted) {
dispose = listen(button, "click", /*onclick*/ ctx[1]);
mounted = true;
}
},
p(ctx, [dirty]) {
if (dirty & /*cnt*/ 1) set_data(t, /*cnt*/ ctx[0]);
},
i: noop,
o: noop,
d(detaching) {
if (detaching) detach(button);
mounted = false;
dispose();
}
};
}
function instance($$self, $$props, $$invalidate) {
let cnt = 0;
const onclick = () => {
$$invalidate(0, cnt += 1);
};
return [cnt, onclick];
}
class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, instance, create_fragment, safe_not_equal, {});
}
}
export default App;
Let’s follow the process of generating this button. It creates a class App inheriting SvelteComponent, initializing it with this, options, instance, create_fragment, safe_not_equal function, and though what they are is yet unknown, an empty object. instance and create_fragment are code generated by Svelte, and safe_not_equal is a function included in the Svelte runtime.
Visiting Svelte GitHub, to see what safe_not_equal is…
export function safe_not_equal(a, b) {
return a != a ? b == b : a !== b || ((a && typeof a === 'object') || typeof a === 'function');
}
…we find it’s a function like this. As predicted by the name, it seems to be a strict not equal comparison function. Thus, we realized the fourth parameter of init is a predicator function that guesses if a value has changed. Before moving on, I was curious about what exactly safe_not_equal does. Given the precedence of parenthesis operations, followed by or operations, and then ternary operations, it goes like…
a != a ? b == b : (a !== b || ((a && typeof a === 'object') || (typeof a === 'function')))
if (a != a) {
return b == b;
} else {
return a !== b || ((a && typeof a === 'object') || (typeof a === 'function'));
}
First, a != a looks very unfamiliar, but to cut to the conclusion, (function(){}), {}, [], NaN
satisfies this. The
first three are anonymous functions, objects, arrays respectively because assignment operations occur separately on the
left and right hand, so they don’t apply to the function parameter a. Hence, the first line checks if a is NaN. Why
particularly NaN? Because NaN, standing for Not-a-number, is a predefined special number value defined such that it is
not equal to any value, including itself. Thus, b == b means true unless b is NaN.
Next, if a is not NaN, it checks if a and b are strictly not equal, a && typeof a===’object’, typeof a === ‘function’ are or’ed. The beginning and end are intuitive. What does the middle mean? Before the && operation, each term is passes through ToBoolean, where null is an object but returns false when passed ToBoolean. (undefined has undefined type.) Hence, it means true if a is null.
In summary, if a is NaN and b is not NaN, it returns true, otherwise, if a and b are strictly not equal it returns true, if a and b are strictly equal but a is not null and is an object, it returns true, if a and b are strictly equal but a is a function, it also returns true.
So, it is a strict not equal function that can compare NaN, and treats non-null objects or functions as different values. Anyway, this means if you accidentally use a non-null object or function as a state and utilize safe_not_equal, it could lead to unnecessary invocations.
Finally, we can move on to the init function. If you look, the signature of the init function is as follows…
function init(component, options, instance, create_fragment, not_equal, props, dirty = [-1])
The first this we inserted is the component, options are options, and the last one is props, and we found the names of the three functions in the middle.
$$.fragment = create_fragment ? create_fragment($$.ctx) : false;
This is the part where it calls create_fragment. Remember $$.fragment.
$$.ctx = instance
? instance(component, options.props || {}, (i, ret, ...rest) => {
const value = rest.length ? rest[0] : ret;
if ($$.ctx && not_equal($$.ctx[i], $$.ctx[i] = value)) {
if (!$$.skip_bound && $$.bound[i]) $$.bound[i](value);
if (ready) make_dirty(component, i);
}
return ret;
})
: [];
And this part calls the instance.
Let’s look at the JS code created by Svelte along with the instance function call. When calling the instance function, it first passes the currently initializing component, the props entered as options, and finally, it passes some anonymous function and stores the return value of instance in $$.ctx.
function instance($$self, $$props, $$invalidate) {
let cnt = 0;
const onclick = () => {
$$invalidate(0, cnt += 1);
};
return [cnt, onclick];
}
In the automatically generated code, it creates a closure called onclick, calling the $$invalidate function with 0 and cnt += 1 values inside this closure. And this instance function returns cnt and the onclick closure in an array.
Going back to the calling part of instance, looking at the anonymous function passed as a parameter, considering the parameters passed from the instance function, i=0, ret= cnt+=1. The first if statement checks if \(.ctx is not null or undefined etc., and if not, it uses the not_equal function passed during init to compare the current\).ctx\[i\] value and the new value(if rest exists rest\[0\], otherwise ret) and executes the internal statement if the change is not equal. Let’s skip $$.bound and look at make_dirty.
function make_dirty(component, i) {
if (component.$$.dirty[0] === -1) {
dirty_components.push(component);
schedule_update();
component.$$.dirty.fill(0);
}
component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));
}
dirty is a member of the component initialized with dirty=[-1]
as seen in the init function signature. The if statement
part is simple to understand. If the dirty
flag is -1
, it adds the component to something that looks like a queue called
dirty_components
, calls schedule_update
, and fills the dirty
flag with 0
.
The bottom part is a bit hard to understand. 31
is the binary 11111
, dividing i
by 11111(2)
using bitwise to round down
to that many numbers and shifting left by that many bits… this part needs more study.
Let’s briefly go back to the create_fragment
definition.
c()
{
button = element("button");
t = text(/*cnt*/ ctx[0]);
}
,
m(target, anchor)
{
insert(target, button, anchor);
append(button, t);
if (!mounted) {
dispose = listen(button, "click", /*onclick*/ ctx[1]);
mounted = true;
}
}
,
We can see it creating necessary elements in the c(create)
function.
function element<K extends keyof HTMLElementTagNameMap>(name: K) {
return document.createElement<K>(name);
}
function text(data: string) {
return document.createTextNode(data);
}
Truly, it’s just native DOM objects.
In the m(mount)
function, it uses the unknown target, the button made above, and anchor to call insert. Having created
DOM objects simply, it’s expected to use native DOM API to insert into the tree, append, and attach event listeners.
function insert(target: Node, node: Node, anchor?: Node) {
target.insertBefore(node, anchor || null);
}
function append(target: Node, node: Node) {
target.appendChild(node);
}
function listen(node: EventTarget, event: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | EventListenerOptions) {
node.addEventListener(event, handler, options);
return () => node.removeEventListener(event, handler, options);
}
As expected. The c(create)
, m(mount)
functions are called in the following part of init.
if (options.target) {
if (options.hydrate) {
const nodes = children(options.target);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
$$.fragment && $$.fragment!.l(nodes);
nodes.forEach(detach);
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
$$.fragment && $$.fragment!.c();
}
if (options.intro) transition_in(component.$$.fragment);
mount_component(component, options.target, options.anchor, options.customElement);
flush();
}
m(mount)
is called within the mount_component
function.
Anyway, if you delve into schedule_update()
, it goes like this.
export const dirty_components = [];
export const intros = {enabled: false};
export const binding_callbacks = [];
const render_callbacks = [];
const flush_callbacks = [];
const resolved_promise = Promise.resolve();
let update_scheduled = false;
export function schedule_update() {
if (!update_scheduled) {
update_scheduled = true;
resolved_promise.then(flush);
}
}
export function tick() {
schedule_update();
return resolved_promise;
}
We see the earlier seen dirty_component
and schedule_update
. schedule_update
calls the flush function then on the
Promise.then
object, resolved_promise
.
This might be a bit hard to understand too. Basically, Promise.resolve
is a thing that calls the function passed to then
with the value you put in it first. If you put nothing, it calls without parameters. Why not call flush directly?! You
might ask. But this is to add a new task to the js event loop. Like setTimeout(function()..., 0);
, but Promise is a task
that goes into the microTask queue, so it has a higher priority. I’ll cover the JS event loop again later.
Anyway, since tick also calls schedule_update
, and make_dirty
calls it too, we can guess that Svelte updates (1)
periodically, (2) when $$invalidate
is used to detect a changed value.
Now let’s look at the flush function.
for (let i = 0; i < dirty_components.length; i += 1) {
const component = dirty_components[i];
set_current_component(component);
update(component.$$);
}
Ah. As expected, it uses the earlier inserted dirty_components
for updates. It passes the $$
member of the component to
be updated as a parameter to the update function.
function update($$) {
if ($$.fragment !== null) {
$$.update();
run_all($$.before_update);
const dirty = $$.dirty;
$$.dirty = [-1];
$$.fragment && $$.fragment.p($$.ctx, dirty);
$$.after_update.forEach(add_render_callback);
}
}
$$.before_update
and $$.after_update
etc., seem to be callbacks specified in the component.
First, it backs up the previous dirty value and then initializes it to -1
.
Then, it passes the earlier seen $$.ctx
, the context containing the state and callback, and the previous dirty value to
$$.fragment.p
. The create_fragment definition where this $$.fragment is located is beginning to fade from memory, so
let’s attach it again below…
return {
c() {
button = element("button");
t = text(/*cnt*/ ctx[0]);
},
m(target, anchor) {
insert(target, button, anchor);
append(button, t);
if (!mounted) {
dispose = listen(button, "click", /*onclick*/ ctx[1]);
mounted = true;
}
},
p(ctx, [dirty]) {
if (dirty & /*cnt*/ 1) set_data(t, /*cnt*/ ctx[0]);
},
i: noop,
o: noop,
d(detaching) {
if (detaching) detach(button);
mounted = false;
dispose();
}
};
Done. Finally reached the part where it changes values! The set_data function is as follows.
function set_data(text, data) {
data = '' + data;
if (text.wholeText !== data) text.data = data;
}
Truly, it surgically reacts to changes. We could confirm that the entire process from action to reflecting changes in the state back to the UI is generated at build time.
Conclusion
Today, we took a slight peek at how Svelte can implement Reactive Programming at build time. Although not fully explored, it seems I could roughly understand the Svelte Lifecycle. It feels like, definitely, when compared to the diffing method of components like React, it seems advantageous at runtime when the number of components and nodes in the tree increases. Also, it seems to have the advantage of being lighter at runtime than React, which operates with a separate scheduler. However, if rendering or callbacks become complex, occupying the call stack for long periods, it may have disadvantages. If there’s a chance, we should measure runtime size and execution time, etc.