The Svelte compiler: How it works

Joshua Nussbaum - Feb 14 '20 - - Dev Community

Want to learn more about Svelte? I just launched a few short video courses

Most front-end frameworks rely on a diffing engine that syncs the visual DOM with an in-memory copy of the DOM.

Svelte is different. It's a compiler. It generates code (JavaScript) that updates the visual tree directly, without diffing.

Think of it as converting html like <h1>Hello World</h1> into:

const element = document.createElement('h1')
element.textContent = "Hello World"
document.body.appendChild(element)
Enter fullscreen mode Exit fullscreen mode

Now, why would you want to do that?

Because of data binding.

It means we can write <h1>{someValue}</h1> declaratively and and we don't need to write imperative statements like element.textContent = someValue every time someValue changes. Svelte generates the synchronization code for us.

How the compiler works

The compiler takes in .svelte files, parses them into an AST Abstract Syntax Tree, analyzes the tree, and generates Javascript and CSS.

Disclaimer: the examples below are simplified for brevity.

Parsing tags

Recall that the anatomy of a .svelte file is similar to a .html file:

<script>// js goes here</script>

<style>/* css goes here */<style>

<!-- More (visual) html tags here -->
<h1>...</h1>
<p>...</p>
Enter fullscreen mode Exit fullscreen mode

The first step is to parse the document and create 3 buckets for tags: <script> tags, <style> tags, and visual tags (everything else).

Parsing CSS

The <style> tags are parsed out so that we can add a unique prefix to each CSS rule.

For example:

h1 {
  color: teal;
}
Enter fullscreen mode Exit fullscreen mode

Turns into:

h1.random-code-abc123 {
  color: teal;
}
Enter fullscreen mode Exit fullscreen mode

The unique prefix is added to avoid clashing with CSS rules defined in other components.

The package css-tree is used to walk the CSS and inspect the expressions.

import {parse, walk, generate} from 'css-tree'

// parse CSS source to AST
const input = '.example { color: teal }'
const ast = parse(input)
const randomPrefix = 'xyz123'
const selectors = []

// traverse AST and looking for selectors
walk(ast, node => {
  // check if this node is a selector
  if (node.type === 'Selector') {
    // capture this node, so we can modify it later
    selectors.push(node)
  }
})

// modify the AST
selectors.forEach(selector => {
  // add a `ClassSelector` with name `.xyz123`
  // it will turn `.example` into `.example.xyz123`
  selector.children.insertData({
    type: 'ClassSelector',
    name: randomPrefix
  })
})

// generate CSS text from AST
const output = generate(ast)

// print the CSS text
console.log(output)
//> .example.xyz1234{color:teal}
Enter fullscreen mode Exit fullscreen mode

Parsing JavaScript

Svelte parses the <script> tags to extract exports statements (which are props) and to find reactive statements.

The JavaScript source code is turned into AST using the acorn package.

For example, say you define a prop export let name. All the export let statements can be located by walking the AST with estree-walker:

import {parse} from 'acorn'
import {walk} from 'estree-walker'

// define source code with 2 exported props
const sourceCode = "export let title, color"

// parse the source code
// enable `sourceType: 'module'` since want to allow exports 
const ast = parse(sourceCode, {sourceType: 'module'})

// walk the AST
walk(ast, {
  enter(node) {
    // check if this node is a "named export"
    if (node.type === 'ExportNamedDeclaration') {

      // named exports can have many names, so map the ids
      const props = node.declaration.declarations.map(declaration => declaration.id.name)

      // print 'em
      console.log(`We got props: ${props.join(', ')}`)
      //> We got props: title, color
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Parsing visual tags

The remaining tags are visual tags like <h1>, <p>, etc..

Svelte uses it's own tag parser, but you can use parse5 to do the same job:

import { parseFragment } from 'parse5'

const source = "<h1 class='snazzy'>Hello World!</h1>"
const fragment = parseFragment(source)

fragment.childNodes.forEach(node => {
  console.log(node)
})
Enter fullscreen mode Exit fullscreen mode

It outputs:

{
  nodeName: 'h1',
  tagName: 'h1',
  attrs: [ { name: 'class', value: 'snazzy' } ],
  namespaceURI: 'http://www.w3.org/1999/xhtml',
  childNodes: [
    {
      nodeName: '#text',
      value: 'Hello World!',
      parentNode: ...
    }
  ] 
}
Enter fullscreen mode Exit fullscreen mode

This gives us the complete tree of our HTML document, which we'll use to generate the equivalent JavaScript code.

Putting it all together

Say we have a simple .svelte file like this:

<script>
  export let name;

  function handleClick(e) {
    e.preventDefault()
    alert(`Hello ${name}!`)
  }
</script>

<h1 class="snazzy" on:click=handleClick>Hello {name}!</h1>
Enter fullscreen mode Exit fullscreen mode

The compiler generate a .js that looks like this:

// target: this is the target element to mount the component
// props: a list of props, defined with `export let`
export default function component({ target, props }) {
  // code generated to extract the props into variables:
  let { name } = props; 

  // all functions are copied directly from the <script> tag
  function handleClick(e) {
    e.preventDefault();
    alert(`Hello ${name}!`);
  }

  // variables are declared for each element and text node:
  let e0, t1, b2, t3;

  // returns an object with lifecycle functions to create, mount, detach and update the component. 
  return {
    // called when the components is created
    // creates elements/nodes, adds attributes and wires up event handlers
    create() {
      e0 = document.createElement("h1")
      t1 = document.createTextNode("Hello ")
      b2 = document.createTextNode(name)
      t3 = document.createTextNode("!")

      e0.setAttribute("class", "snazzy")
      e0.addEventListener("click", handleClick)
    },

    // called when the component is mounted to the `target`
    // it just appends things
    mount() {
      e0.appendChild(t1)
      e0.appendChild(b2)
      e0.appendChild(t3)

      target.append(e0)
    },

    // called to change the value of props
    update(changes) {
      // check if name changed
      if (changes.name) {
        // update `name` variable and all binding to `name`
        b2.data = name = changes.name
      }
    },

    // called to remove the component from the DOM
    detach() {
      e0.removeEventListener("click", handleClick)
      target.removeChild(e0)
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Now we can mount this component into the DOM:

import MyComponent from './component'

// instantiate the component
const component = MyComponent({
  target: document.body,
  props: {name: "World"}
})

// create the nodes
component.create()

// append the nodes into the target
component.mount()
Enter fullscreen mode Exit fullscreen mode

Summary

Svelte is a compiler that parses .svelte files, analyzes them and then generates a JavaScript file. The JavaScript file contains the logic to mount the component, handle events, and patch the DOM when values change.

For learning sake, I built a tiny version of the compiler: https://github.com/joshnuss/micro-svelte-compiler
It only does a fraction of the real compiler, but it's a useful learning tool.

TIP #1: If you want to see further examples, take a look at the JS Tab in the Svelte REPL.
TIP #2: AST Explorer is a great learning tool for inspecting AST.

Happy coding!

Want more?

If you'd like to learn more about Svelte, check out my short video courses

. . . . . . . . . . . . . . . . . . .