Skip to content

Instantly share code, notes, and snippets.

@martypdx
Last active December 17, 2021 17:19
Show Gist options
  • Save martypdx/96b0a0900d2769a982ba36a066c1e38a to your computer and use it in GitHub Desktop.
Save martypdx/96b0a0900d2769a982ba36a066c1e38a to your computer and use it in GitHub Desktop.
AoT Template Extraction

AoT Template Extraction

Rather than creating DOM nodes in JavaScript, an AoT system can extract the html, deliver as html, and then only need reference the relevant DOM nodes in JavaScript. This has a number of performance benefits:

  1. It takes advantage of fast parsing of html by the browser and avoids the additional JavaScript bundle size and parsing tax.
  2. Only elements involved in binding need to be managed or even referenced
  3. Faster mechanisms like cloneNode can be used to produce cheaper copies
  4. Like html templates can be consolidated

Based on the current Svelte 3.0 output, it seems well poised to take advantage of this.

Assumptions

DOM Manipulation

There are three main types of DOM manipulation that any frontend library or framework must do:

  1. Set the value of text nodes
  2. Set properties and methods of elements. This includes registering event handlers
  3. Handling multiplicities of "blocks" of templates. Conditionals, looping, and component insertion

Svelte already handles the nested nature of #3, and it looks like the term "blocks" is also used.

Handling #1 and #2 can both be accomplished by referencing the required element, the textNode being a childNode of that element.

Example

Here's a basic template:

<section>
	<h1>Hello {name}!</h1>
	<p>
		{description}
	</p>
</section>

Which produces this svelte c and m (create and mount?):

function create_fragment(ctx) {
	var section, h1, t0, t1, t2, t3, p, t4;

	return {
        c() {
            section = element("section");
            h1 = element("h1");
            t0 = text("Hello ");
            t1 = text(name);
            t2 = text("!");
            t3 = space();
            p = element("p");
            t4 = text(description);
        },

        m(target, anchor) {
            insert(target, section, anchor);
            append(section, h1);
            append(h1, t0);
            append(h1, t1);
            append(h1, t2);
            append(section, t3);
            append(section, p);
            append(p, t4);
        },
        
    //...

Only the t1 and t4 are dynamic, the rest is static DOM. Looking at that template again:

<section>
	<h1>Hello {name}!</h1>
	<p>
		{description}
	</p>
</section>

We need to get access to the h1 (and from there the text node), and the p. Extracting that template as html, we need to 1) mark those elements:

<section>
	<h1 bind>Hello !</h1>
	<p bind></p>
</section>

And 2) text nodes can't be transmitted as html as the boundaries would be lost (the text needs to go between the Hello and the !) (presumably, based on svelte's code, we can consider the text content of the p as a single child text node). So we need to put a placeholder element (comments won't work as they aren't queryable, see next part):

<section>
	<h1 bind>Hello <text-node></text-node>!</h1>
	<p bind></p>
</section>

We now have inert html that can be place into a template element. For production bundling this should go into the html page itself with a unique id. (They can also be deduped, using a Map with the html itself as the key and the generated id as the value). Above the context function we create a renderer:

const renderer = getRenderer('r4iw0so');

function create_fragment(ctx) {

For development, you could just put the html inline:

const renderer = makeRenderer(`
<section>
    <h1 bind>Hello <text-node></text-node>!</h1>
    <p bind></p>
</section>
`);

function create_fragment(ctx) {

Here are getRenderer and makeRenderer [examples](https://github.com/martypdx/azoth/blob/master/src/dom.js, basically managing a map of ids to renderer functions:

import renderer from './renderer';

const template = document.createElement('template');
const htmlToFragment = html => {
    template.innerHTML = html;
    return template.content;
};

export const makeRenderer = html => {
    const fragment = htmlToFragment(html);
    return renderer(fragment);
};

const templates = new Map();

export const getRenderer = id => {
    if(templates.has(id)) return templates.get(id);

    // TODO: could fail on bad id...
    const templateEl = document.getElementById(id);
    const template = renderer(templateEl.content);

    templates.set(id, template);
    return template;
};

What's more interesting is the renderer function:

export default function renderer(fragment) {

    const nodes = fragment.querySelectorAll('text-node');
    let node = null;
    for(var i = 0; i < nodes.length; node = nodes[++i]) {
        nodes[i].replaceWith(document.createTextNode(''));
    }

    return function render() {
        const clone = fragment.cloneNode(true);
        return { 
            fragment: clone, 
            nodes: clone.querySelectorAll('[bind]') 
        };
    };
}

The first part of the function replaces <text-node> with actual text nodes. This only happens once per block.

The returned render function makes a copy using cloneNode(true) (true is deep clone), and then returns the fragment, and a nodelist of all elements marked bind. (querySelectorAll is superfast compared to anything you can write in JavaScript). querySelectorAll is speced to be be Depth-First order. This means we can calculate the index of the nodes of interest, and the index of the child text node. There are some nuances in parsing the template, as a I recall you have to wait for the tag to close, but it is all deterministic.

Putting it all together, showing explicit steps:

const render = getRenderer('r4iw0so');

function create_fragment(ctx) {
	var fragment, t1, t4;

	return {
		c() {
		    const { f, [el1, el2] } = render();
		    fragment = f;
		    t1 = el1.childNodes[1];
		    t1.textContent = name;
		    // maybe function like:
		    // t1 = childText(el1, 1, name);
		    // maybe optimization for elements with one text node child, but could be whatever:
		    el2.textContent = name;
		    t4 = el2;

		    // attributes and other element based binding would work the same as it does now:
	     	    // attr(p, "class", someClass);
		},

		m(target, anchor) {
			insert(target, fragment, anchor);
		},
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment