Compile Svelte in your head (Part 3)

SvelteJavaScript

⬅ ⬅ Previously in Part 2.

Previously, I detailed how $$invalidate works, described how bitmask was used in $$invalidate, and explained how reactive declarations work as well.

In this article, we are going to look into 3 DOM related directives:

  • on: for event handlers
  • bind: for bindings
  • use: for actions

To make sure we are on the same page, let's first explain how these 3 directives work.

The on:, bind: and use:

on: event handlers

You can use the on: directive to listen to any event on an element:

<script>
  function handleMouseMove(event) {}

  function handleClick(event) {}
</script>

<!-- You can pass in as variable -->
<div on:mousemove={handleMouseMove} />

<!-- or you can inline the event handler -->
<div on:mousemove={event => { /*...*/ }} />

<!-- You can modify event handler with modifiers  -->
<div on:click|stopPropagation|once={handleClick}>

bind: bindings

The bind: directive allows you to bind a variable to a property of an element.

Updating the variable will modifying the property of the element, conversely, modifying the property of the element via interacting with the element will, in turn, update the variable.

<script>
  let name, yes;
</script>

<!-- You can bind `name` to input.value -->
<!-- Changing `name` will update input.value to be the value of `name` and -->
<!-- changing input.value will update `name` to be input.value -->
<input bind:value={name} />

<!-- You can bind input.checked for a checkbox input -->
<input type="checkbox" bind:checked={yes} />

use: actions

The use: directive is called "Action". It provides you an interface to enhance your element.

You pass a function to the use: directive of an element and the function will be called when your element is mounted.

The function should return an object in which the destroy method of the object will be called when the element is unmounted.

<script>
  function doSomething(element) {
    // do something with the element
    return {
      destroy() {
        // cleanup
      }
    }
  }
</script>

<div use:doSomething />

This is useful when you want to interface with 3rd-party libraries:

<script>
  import Draggable from 'the-draggable-library';

  function doSomething(element) {
    const draggable = new Draggable(element);
    draggable.start();
    return {
      destroy() {
        draggable.stop();
      }
    }
  }
</script>

<div use:doSomething />

You can pass in parameters to the use: directive, to bring in reactivity into your actions

<script>
  import Draggable from 'the-draggable-library';

  let options = { foo: true, bar: true };

  function doSomething(element, options) {
    const draggable = new Draggable(element, options);
    draggable.start();

    return {
      update(options) {
        draggable.update(options);
      },
      destroy() {
        draggable.stop();
      }
    }
  }
</script>

<div use:doSomething={options} />

<label>
  <input type="checkbox" bind:checked={options.foo} />
  Foo
</label>
<label>
  <input type="checkbox" bind:checked={options.bar} />
  Bar
</label>

You can visit Svelte's interactive tutorial to learn more about:

The Vanilla JS

Now, let's refresh ourselves with how we can implement an event handler, bindings, and actions without using any framework.

Event handler

As mentioned in the Part 1 of the series, we can use element.addEventListener to listen to events.

element.addEventListener('click', handleClick);

The event listener takes in an optional 3rd argument, which allows you to specifies the characteristics of the event handler:

element.addEventListener('click', handleClick, {
  capture: true, // triggered before any child element
  once: true, // triggered at most once
  passive: true, // indicates that will never call `preventDefault` to improve performance
});

event.preventDefault

event.preventDefault allows you to prevent the default behavior of the event, for example submitting form for <button type="submit" /> or navigating to the target for <a href="...">.

element.addEventListener('click', event => {
  event.preventDefault();
});

event.stopPropagation

event.stopPropagation allows you to prevent event to continue propagate.

<div on:click={event => {
  console.log('click not triggered');
}}>
  <div on:click={event => {
    event.stopPropagation();
    console.log('click');
  }}>
  </div>
</div>

To remove the event listener, you need to call element.removeEventListener with the same event type, listener and capture/useCapture flag. You can check out the MDN docs on "Matching event listeners for removal".

element.removeEventListener('click', handleClick);

Bindings

Binding is to synchronise between the value of a variable and a property of an element.

To synchronise the variable to a property of an element, we need to observe the value of the variable. When it changes, apply it to the property of the element.

You can check out my previous article "Reactivity in Web Frameworks" on how we get notified when the value of a variable changed.

On the other hand, to synchronise the property of an element to a variable, we listen to an event of the element, depending on the property, and update the value of the variable when it happens.

// binding variable `checked` with the checkbox `checked` property
let checked;
let input = document.querySelector('#checkbox');

// synchronise variable `checked` to checkbox `checked` property
observe(checked, newValue => {
  input.checked = newValue;
});

// synchronise checkbox `checked` property to variable `checked`
// listen to `change` event for `checked` property
input.addEventListener('change', event => {
  checked = input.checked;
});

Some observations:

- The name of the event and the property name of the element may not be the same.

In this example, we listen to "change" event for the checkbox checked property.

- It is almost impossible to bind a property of an element, if there's no event fired from the element to indicate the property has changed

A recent example I found out is the HTMLDialogElement. It has "close" but not "open" event, which makes it hard to implement bind:open on the dialog element.

Maybe an alternative would be using MutationObserver, which I haven't seen any usage of it in Svelte codebase yet.

Actions

Action is a function that gets called when your element is created and mounted onto the DOM.

The function returns an object, with 2 methods:

  • update, which gets called when the parameters change
  • destroy, which gets called when the element is removed from the DOM
function actionFn(element, parameter) {
  return {
    update(newParameter) {},
    destroy() {},
  };
}

// When element is mounted onto the DOM
let parameter = 1;
const actionObj = actionFn(element, parameter);

// When parameter changes
parameter = 2;
actionObj.update(parameter);

// When element is removed from the DOM
actionObj.destroy();

The Compiled JS

Now let's take look at how Svelte compiles on:, bind: and use: directives into output JavaScript.

on: directive

<script>
  function onChange() {}
</script>

<input on:change={onChange} />

Svelte REPL

The output code:

/* App.svelte generated by Svelte v3.22.2 */
// ...
function create_fragment(ctx) {
  let input;
  let dispose;

  return {
    c() {
      input = element('input');
    },
    m(target, anchor, remount) {
      insert(target, input, anchor);
      if (remount) dispose();
      dispose = listen(input, 'change', /*onChange*/ ctx[0]);
    },
    d(detaching) {
      if (detaching) detach(input);
      dispose();
    },
  };
}

function instance($$self) {
  let i = 0;
  function onChange() {
    i++;
  }
  return [onChange];
}

// ...

Some observations:

  • Svelte adds event handler, listen(...), in the _m_ount method.
  • Svelte removes event handler, dispose(), in the _d_estroy method.

As pointed out in Part 1 #listen and dispose, to optimise for minification, the dispose variable could be a function or an array of functions, depending on having one or many event handlers.

We will discuss remount in the future, as it is related to remounting elements while reordering items within each block.

Event modifiers

Event handlers can have modifiers that alter their behavior.

<script>
	let i=0;
	function onClick() {
		i++;
	}
</script>

<button on:click|preventDefault={onClick} />
<button on:change|stopPropagation={onClick} />
<button on:change|once={onClick} />
<button on:change|capture={onClick} />

<!-- Chain multiple modifiers -->
<button on:click|preventDefault|stopPropagation|once|capture={onClick} />

Svelte REPL

The output code:

/* App.svelte generated by Svelte v3.22.2 */
// ...
function create_fragment(ctx) {
  // ...
  return {
    c() { /* ... */ },
    m(target, anchor, remount) {
      // ...
      dispose = [
        listen(button0, "click", prevent_default(/*onClick*/ ctx[0])),
        listen(button1, "change", stop_propagation(/*onClick*/ ctx[0])),
        listen(button2, "change", /*onClick*/ ctx[0], { once: true }),
        listen(button3, "change", /*onClick*/ ctx[0], true),
        listen(
          button4,
          "click",
          stop_propagation(prevent_default(/*onClick*/ ctx[0])),
          { once: true, capture: true }
        ),
      ];
    },
    // ...
  };
}

Some observations:

  • Svelte handles different modifiers differently.
  • For capture, once, and passive modifiers, which they are part of the options for element.addEventListener, they will be passed as options into the listen function.
  • For stopPropagation, preventDefault, and self modifiers, the event handler is decorated with respective decorator functions.

An example implementation of the prevent_default decorator function:

function prevent_default(fn) {
  return function(event) {
    event.preventDefault();
    return fn.call(this, event);
  };
}

bind: directive

<script>
	let checked = false;
	function updateChecked() {
		checked = true;
	}
</script>

<input type="checkbox" bind:checked />

Svelte REPL

The output code:

/* App.svelte generated by Svelte v3.22.2 */
// ...
function create_fragment(ctx) {
  let input;
  let dispose;

  return {
    c() { /* ... */ },
    m(target, anchor, remount) {
      insert(target, input, anchor);
      input.checked = /*checked*/ ctx[0];
      if (remount) dispose();
      dispose = listen(input, 'change', /*input_change_handler*/ ctx[1]);
    },
    p(ctx, [dirty]) {
      if (dirty & /*checked*/ 1) {
        input.checked = /*checked*/ ctx[0];
      }
    },
    d(detaching) {
      if (detaching) detach(input);
      dispose();
    },
  };
}

function instance($$self, $$props, $$invalidate) {
  let checked = false;

  function updateChecked() {
    $$invalidate(0, (checked = true));
  }

  function input_change_handler() {
    checked = this.checked;
    $$invalidate(0, checked);
  }

  return [checked, input_change_handler];
}

Some observations:

  • To synchronise the value of the variable to the property of the element:
    • Svelte wraps the update of the variable checked with $$invalidate(...)
    • In the u_p_date method, if the variable checked is updated, Svelte sets input.checked to the value of the variable checked.
  • To syncrhonise the property of the element to the variable
    • Svelte creates an input handler that reads the this.checked property of the input and calls $$invalidate(...) to update it.
    • Svelte sets up listen(...) in the _m_ount method and dispose(...) in the _d_estroy method for the input handler

use: directive

<script>
	let i = '';
	function action() {}
  function updateI() {
    i++;
  }
</script>

<div use:action={i} />

Svelte REPL

The output code:

/* App.svelte generated by Svelte v3.22.2 */
// ...
function create_fragment(ctx) {
  // ...
  let action_action;

  return {
    c() { /* ... */ },
    m(target, anchor, remount) {
      insert(target, div, anchor);
      if (remount) dispose();
      dispose = action_destroyer(
        (action_action = action.call(null, div, /*i*/ ctx[0]))
      );
    },
    p(ctx, [dirty]) {
      if (action_action && is_function(action_action.update) && dirty & /*i*/ 1)
        action_action.update.call(null, /*i*/ ctx[0]);
    },
    d(detaching) {
      if (detaching) detach(div);
      dispose();
    },
  };
}

Some observations:

  • Creating action_action object by calling the action function in the _m_out method
  • When the paramter change, call the action_action.update method with the updated parameter in the u_p_date method
  • action_destroyer returns the dispose function. The dispose function makes sure that action_action.destroy is a function before calling it.

The order of directives

As both the bind: and the on: directives add event listeners to the element, the order of adding event listener may have nuance side effects.

Imagine the following scenario:

<script>
  let before = ''
  let after = '';
  function uppercase(event) {
    // modifying the input.value
    event.target.value = event.target.value.toUpperCase();
  }
</script>

<!-- bind after adding input listener -->
<input on:input={uppercase} bind:value={after} /> {after}

<!-- bind before adding input listener -->
<input bind:value={before} on:input={uppercase} /> {before}

The input.value accessed by the implicit event handler of the bind: directive depends on whether on:input handler gets called before or after.

If the implicit event handler of the bind: directive is called before the event handler, the bound value is the value of the input before applying the toUpperCase() transformation.

Although action: directive itself does not add event listener to the element, but it is possible to be added by the user code:

<script>
  let before = ''
  let after = '';
  function uppercaseAction(element) {
    function fn(event) {
      event.target.value = event.target.value.toUpperCase()
    }
    element.addEventListener('input', fn);
    return {
      destroy() {
        element.removeEventListener('input', fn);
      }
    };
  }
</script>

<!-- bind after adding action -->
<input use:uppercase bind:value={after} /> {after}

<!-- bind before adding action -->
<input bind:value={before} use:uppercase /> {before}

Although it is not officially documented, (I couldn't find it on the docs), the order of declaring the directives on:, bind: and use: on an element does matter to provide a consistent behavior.

Try out the following example in the REPL:

<script>
  let checked;
  function onChange() {}
  function action() {}
</script>

<input
  type=checkbox
  bind:checked
  on:change={onChange}
  use:action
/>

Svelte REPL

Try reordering the bind:, on: and use: directives and see how it affects the output JS:

// ...
function create_fragment(ctx) {
  let input;
  let action_action;
  let dispose;

  return {
    c() { /* ... */ },
    m(target, anchor, remount) {
      // ...
      dispose = [
        // bind:checked
        listen(input, 'change', /*input_change_handler*/ ctx[1]),
        // on:change={onChange}
        listen(input, 'change', onChange),
        // use:action
        action_destroyer((action_action = action.call(null, input))),
      ];
    },
    // ...
  };
}

If you are interested to learn more about ordering directives, the edge cases it fixed and the regression bugs it caused, you can start with this Github issue.

Closing Note

In this article, we explored how on:, bind: and use: directives work.

We first looked at how we can implement them without using any framework. After that, we walked through how Svelte compiles the directives into JavaScript.

We've also talked about how the order of declaring directives on an element matters.

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.

⬅ ⬅ Previously in Part 2.