Compile Svelte in your head (Part 1)

SvelteJavaScript

Background

A while ago, @swyx came back to Singapore and visited us in Shopee Singapore (We're hiring!).

He gave an amazing sharing on Compile Svelte in Your Head (video) in the ReactKnowledgeable Originals.

I love his presentation and the title is so catchy, so I begged him to use the catchy title as this series of articles about the Svelte compiler. It will be about how Svelte sees your code and compiles it down to plain JavaScript.

Introduction

Lets refresh ourselves with how we write web app without any framework:

Creating an element

// create a h1 element
const h1 = document.createElement('h1');
h1.textContent = 'Hello World';
// ...and add it to the body
document.body.appendChild(h1);

Updating an element

// update the text of the h1 element
h1.textContent = 'Bye World';

Removing an element

// finally, we remove the h1 element
document.body.removeChild(h1);

Adding style to an element

const h1 = document.createElement('h1');
h1.textContent = 'Hello World';
// highlight-start
// add class name to the h1 element
h1.setAttribute('class', 'abc');
// ...and add a <style> tag to the head
const style = document.createElement('style');
style.textContent = '.abc { color: blue; }';
document.head.appendChild(style);
// highlight-end
document.body.appendChild(h1);

Listen for click events on an element

const button = document.createElement('button');
button.textContent = 'Click Me!';
// highlight-start
// listen to "click" events
button.addEventListener('click', () => {
  console.log('Hi!');
});
// highlight-end
document.body.appendChild(button);

These are code that you have to write, without using any framework or library.

The main idea of this article is to show how the Svelte compiler compiles the Svelte syntax into statements of codes that I've shown above.

Svelte syntax

Here I'm going to show you some basics of the Svelte syntax.

If you wish to learn more, I highly recommend trying Svelte's interactive tutorial.

So here is a basic Svelte component:

<h1>Hello World</h1>

Svelte REPL

To add style, you add a <style> tag:

<style>
  h1 {
    color: rebeccapurple;
  }
</style>
<h1>Hello World</h1>

Svelte REPL

At this point, writing Svelte component just feels like writing HTML, that's because Svelte syntax is a super set of the HTML syntax.

Let's look at how we add a data to our component:

<script>
  let name = 'World';
</script>
<h1>Hello {name}</h1>

Svelte REPL

We put JavaScript inside the curly brackets.

To add a click handler, we use the on: directive

<script>
  let count = 0;
  function onClickButton(event) {
    console.log(count);
  }
</script>
<button on:click={onClickButton}>Clicked {count}</button>

Svelte REPL

To change the data, we use assignment operators

<script>
  let count = 0;
  function onClickButton(event) {
    // highlight-next-line
    count += 1;
  }
</script>
<button on:click={onClickButton}>Clicked {count}</button>

Svelte REPL

Let's move on to see how Svelte syntax is compiled into JavaScript that we've seen earlier

Compile Svelte in your Head

The Svelte compiler analyses the code you write and generates an optimised JavaScript output.

To study how Svelte compiles the code, lets start with the smallest example possible, and slowly build up the code. Through the process, you will see that Svelte incrementally adds to the output code based on your changes.

The first example that we are going to see is:

<h1>Hello World</h1>

Svelte REPL

The output code:

function create_fragment(ctx) {
  let h1;

  return {
    c() {
      h1 = element('h1');
      h1.textContent = 'Hello world';
    },
    m(target, anchor) {
      insert(target, h1, anchor);
    },
    d(detaching) {
      if (detaching) detach(h1);
    },
  };
}

export default class App extends SvelteComponent {
  constructor(options) {
    super();
    init(this, options, null, create_fragment, safe_not_equal, {});
  }
}

You can break down the output code into 2 sections:

  • create_fragment
  • class App extends SvelteComponent

create_fragment

Svelte components are the building blocks of a Svelte application. Each Svelte component focuses on building its piece or fragment of the final DOM.

The create_fragment function gives the Svelte component an instruction manual on how to build the DOM fragment.

Look at the return object of the create_fragment function. It has methods, such as:

- c()

Short for create.

Contains instructions to create all the elements in the fragment.

In this example, it contains instructions to create the h1 element

h1 = element('h1');
h1.textContent = 'Hello World';

- m(target, anchor)

Short for mount.

Contains instructions to mount the elements into the target.

In this example, it contains instructions to insert the h1 element into the target.

insert(target, h1, anchor);

// http://github.com/sveltejs/svelte/tree/master/src/runtime/internal/dom.ts
export function insert(target, node, anchor) {
  target.insertBefore(node, anchor || null);
}

- d(detaching)

Short for destroy.

Contains instructions to remove the elements from the target.

In this example, we detach the h1 element from the DOM

detach(h1);

// http://github.com/sveltejs/svelte/tree/master/src/runtime/internal/dom.ts
function detach(node) {
  node.parentNode.removeChild(node);
}

The method names are short for better minification. See what can't be minified here.

export default class App extends SvelteComponent

Each component is a class, which you can import and instantiate through this API.

And in the constructor, we initialize the component with information that made up the component such as create_fragment. Svelte will only pass information that it is needed and remove them whenever it is not necessary.

Try removing the <h1> tag and see what happens to the output:

<!-- empty -->

Svelte REPL

class App extends SvelteComponent {
  constructor(options) {
    super();
    // highlight-next-line
    init(this, options, null, null, safe_not_equal, {});
  }
}

Svelte will pass in null instead of create_fragment!

The init function is where Svelte sets up most of the internals, such as:

  • component props, ctx (will explain what ctx is later) and context
  • component lifecycle events
  • component update mechanism

and at the very end, Svelte calls the create_fragment to create and mount elements into the DOM.

If you noticed, all the internal state and methods are attached to this.$$.

So if you ever access the $$ property of the component, you are tapping into the internals. You've been warned! 🙈🚨

Adding data

Now that we've looked at the bare minimum of a Svelte component, let's see how adding a data would change the compiled output:

<script>
	let name = 'World';
</script>
<h1>Hello {name}</h1>

Svelte REPL

Notice the change in the output:

function create_fragment(ctx) {
  // ...
  return {
    c() {
      h1 = element('h1');
      // highlight-next-line
      h1.textContent = `Hello ${name}`;
    },
    // ...
  };
}
// highlight-next-line
let name = 'World';

class App extends SvelteComponent {
  // ...
}

Some observations:

  • What you've written in the <script> tag is moved into the top level of the code
  • h1 element's text content is now a template literal

There's a lot of amazing things happening under the hood right now, but let's hold our horses for a while, because it's best explained when comparing with the next code change.

Updating data

Let's add a function to update the name:

<script>
	let name = 'World';
	function update() {
		name = 'Svelte';
	}
</script>
<h1>Hello {name}</h1>

Svelte REPL

...and observe the change in the compiled output:

function create_fragment(ctx) {
  return {
    c() {
      // highlight-start
      h1 = element('h1');
      t0 = text('Hello ');
      t1 = text(/*name*/ ctx[0]);
      // highlight-end
    },
    m(target, anchor) {
      insert(target, h1, anchor);
      append(h1, t0);
      append(h1, t1);
    },
    // highlight-start
    p(ctx, [dirty]) {
      if (dirty & /*name*/ 1) set_data(t1, /*name*/ ctx[0]);
    },
    // highlight-end
    d(detaching) {
      if (detaching) detach(h1);
    },
  };
}

// highlight-start
function instance($$self, $$props, $$invalidate) {
  let name = 'World';

  function update() {
    $$invalidate(0, (name = 'Svelte'));
  }

  return [name];
}
// highlight-end

export default class App extends SvelteComponent {
  constructor(options) {
    super();
    // highlight-next-line
    init(this, options, instance, create_fragment, safe_not_equal, {});
  }
}

Some observations:

  • the text content of <h1> element is now broken into 2 text nodes, created by the text(...) function
  • the return object of the create_fragment has a new method, p(ctx, dirty)
  • a new function instance is created
  • What you've written in the <script> tag is now moved into the instance function
  • for the sharp-eyed, the variable name that was used in the create_fragment is now replaced by ctx[0]

So, why the change?

The Svelte compiler tracks all the variables declared in the <script> tag.

It tracks whether the variable:

  • can be mutated? eg: count++,
  • can be reassigned? eg: name = 'Svelte',
  • is referenced in the template? eg: <h1>Hello {name}</h1>
  • is writable? eg: const i = 1; vs let i = 1;
  • ... and many more

When the Svelte compiler realises that the variable name can be reassigned, (due to name = 'Svelte'; in update), it breaks down the text content of the h1 into parts, so that it can dynamically update part of the text.

Indeed, you can see that there's a new method, p, to update the text node.

- p(ctx, dirty)

Short for u_p_date.

p(ctx, dirty) contains instructions to update the elements based on what has changed in the state (dirty) and the state (ctx) of the component.

instance variable

The compiler realises that the variable name cannot be shared across different instances of the App component. That's why it moves the declaration of the variable name into a function called instance.

In the previous example, no matter how many instances of the App component, the value of the variable name is the same and unchanged across the instances:

<App />
<App />
<App />

<!-- gives you -->
<h1>Hello world</h1>
<h1>Hello world</h1>
<h1>Hello world</h1>

But, in this example, the variable name can be changed within 1 instance of the component, so the declaration of the variable name is now moved into the instance function:

<App />
<App />
<App />

<!-- could possibly be -->
<h1>Hello world</h1>
<!-- highlight-next-line -->
<h1>Hello Svelte</h1>
<h1>Hello world</h1>
<!-- depending on the inner state of the component -->

instance($$self, $$props, \$\$invalidate)

The instance function returns a list of instance variables, which are variables that are:

  • referenced in the template
  • mutated or reassigned, (can be changed within 1 instance of the component)

In Svelte, we call this list of instance variables, ctx.

In the init function, Svelte calls the instance function to create ctx, and uses it to create the fragment for the component:

// conceptually,
const ctx = instance(/*...*/);
const fragment = create_fragment(ctx);
// create the fragment
fragment.c();
// mount the fragment onto the DOM
fragment.m(target);

Now, instead of accessing the variable name outside of the component, we refer to the variable name passed via the ctx:

t1 = text(/*name*/ ctx[0]);

The reason that ctx is an array instead of a map or an object is because of an optimisation related to bitmask, you can see the discussion about it here

\$\$invalidate

The secret behind the system of reactivity in Svelte is the $$invalidate function.

Every variable that has been

  • reassigned or mutated
  • referenced in the template

will have the $$invalidate function inserted right after the assignment or mutation:

name = 'Svelte';
count++;
foo.a = 1;

// compiled into something like
name = 'Svelte';
$$invalidate(/* name */, name);
count++;
$$invalidate(/* count */, count);
foo.a = 1;
$$invalidate(/* foo */, foo);

The $$invalidate function marks the variable dirty and schedules an update for the component:

// conceptually...
const ctx = instance(/*...*/);
const fragment = create_fragment(ctx);
// to track which variable has changed
const dirty = new Set();
const $$invalidate = (variable, newValue) => {
  // update ctx
  ctx[variable] = newValue;
  // mark variable as dirty
  dirty.add(variable);
  // schedules update for the component
  scheduleUpdate(component);
};

// gets called when update is scheduled
function flushUpdate() {
  // update the fragment
  fragment.p(ctx, dirty);
  // clear the dirty
  dirty.clear();
}

Adding event listeners

Let's now add an event listener

<script>
	let name = 'world';
	function update() {
		name = 'Svelte';
	}
</script>
<!-- highlight-next-line -->
<h1 on:click={update}>Hello {name}</h1>

Svelte REPL

And observe the difference:

function create_fragment(ctx) {
  // ...
  return {
    c() {
      h1 = element('h1');
      t0 = text('Hello ');
      t1 = text(/*name*/ ctx[0]);
    },
    m(target, anchor) {
      insert(target, h1, anchor);
      append(h1, t0);
      append(h1, t1);
      // highlight-next-line
      dispose = listen(h1, 'click', /*update*/ ctx[1]);
    },
    p(ctx, [dirty]) {
      if (dirty & /*name*/ 1) set_data(t1, /*name*/ ctx[0]);
    },
    d(detaching) {
      if (detaching) detach(h1);
      // highlight-next-line
      dispose();
    },
  };
}

function instance($$self, $$props, $$invalidate) {
  let name = 'world';

  function update() {
    $$invalidate(0, (name = 'Svelte'));
  }
  // highlight-next-line
  return [name, update];
}
// ...

Some observations:

  • instance function now returns 2 variables instead of 1
  • Listen to click event during mount and dispose it in destroy

As I've mentioned earlier, instance function returns variables that are referenced in the template and that are mutated or reassigned.

Since we've just referenced the update function in the template, it is now returned in the instance function as part of the ctx.

Svelte tries generate as compact JavaScript output as possible, not returning an extra variable if it is not necessary.

listen and dispose

Whenever you add an event listener in Svelte, Svelte will inject code to add an event listener and remove it when the DOM fragment is removed from the DOM.

Try adding more event listeners,

<h1
	on:click={update}
	on:mousedown={update}
	on:touchstart={update}>
  Hello {name}!
</h1>

Svelte REPL

and observe the compiled output:

// ...
// highlight-start
dispose = [
  listen(h1, 'click', /*update*/ ctx[1]),
  listen(h1, 'mousedown', /*update*/ ctx[1]),
  listen(h1, 'touchstart', /*update*/ ctx[1], { passive: true }),
];
// highlight-end
// ...
// highlight-next-line
run_all(dispose);

Instead of declaring and creating a new variable to remove each event listener, Svelte assigns all of them to an array:

// instead of
dispose1 = listen(h1, 'click', /*update*/ ctx[1]);
dispose2 = listen(h1, 'mousedown', /*update*/ ctx[1]);
dispose2 = listen(h1, 'touchstart', /*update*/ ctx[1], { passive: true });
// ...
dispose1();
dispose2();
dispose3();

Minification can compact the variable name, but you can't remove the brackets.

Again, this is another great example of where Svelte tries to generate compact JavaScript output. Svelte does not create the dispose array when there's only 1 event listener.

Summary

The Svelte syntax is a superset of HTML.

When you write a Svelte component, the Svelte compiler analyses your code and generates optimised JavaScript code output.

The output can be divided into 3 segments:

1. create_fragment

  • Returns a fragment, which is an instruction manual on how to build the DOM fragment for the component

2. instance

  • Most of the code written in the <script> tag is in here.
  • Returns a list of instance variables that are referenced in the template
  • $$invalidate is inserted after every assignment and mutation of the instance variable

3. class App extends SvelteComponent

  • Initialise the component with create_fragment and instance function
  • Sets up the component internals
  • Provides the Component API

Svelte strives to generate as compact JavaScript as possible, for example:

  • Breaking text content of h1 into separate text nodes only when part of the text can be updated
  • Not defining create_fragment or instance function when it is not needed
  • Generate dispose as an array or a function, depending on the number of event listeners.
  • ...

Closing Note

We've covered the basic structure of the Svelte's compiled output, and this is just the beginning.

If you wish to know more, follow me on Twitter.

I'll post it on Twitter when the next part is ready, where I'll be covering logic blocks, slots, context, and many others.

➡ ➡ Continue reading on Part 2.