How lit-html works

Unlike most modern front end JavaScript libraries, lit-html does not use a virtual dom. Instead, it uses tagged template literals, as such:

import { html, render } from 'lit-html';

// A lit-html template uses the `html` template tag:
const sayHello = (name) => html`<h1>Hello ${name}</h1>`;

render(sayHello('World'), document.body);

The tagged template literal html passes its arguments to a TemplateResult class. This means that TemplateResult is getting an array of strings and values which would be expressions like name in the example above.

TemplateResult is both a container type to obtain a TemplateInstance from a cache and also in charge of creating a HTMLTemplateElement by joining all the static strings with a special comment node.

By removing this abstraction, the above code is roughly de-sugared to something like this:

import { render } from 'lit-html';

const templateStringsArray = ['<h1>Hello ', '</h1>'];
const sayHello = (name) => {
  const templateElement = document.createElement('template');
  templateElement.innerHTML = '<h1>Hello <!--{{lit-5060303367651138}}--></h1>';
  return {
    templateStringsArray,
    values: [name],
    templateElement,
  };
};

render(sayHello('World'), document.body);

When we call sayHello with a new name, it creates a brand new object. The render function takes in this new object, along with the node to render into, which is document.body in this case.

If the node has already been rendered, it simply sets the new object and commits.

On the first render though, a NodePart will be created and stored in a WeakMap with the node as the key. This ensures that if the node is removed from the dom, the NodePart instance will be garbage collected as well, preventing any memory leak.

If we remove the abstraction of render, we end up with this:

import { parts, NodePart } from 'lit-html';

const templateStringsArray = ['<h1>Hello ', '</h1>'];
const sayHello = (name) => {
  const templateElement = document.createElement('template');
  templateElement.innerHTML = '<h1>Hello <!--{{lit-5060303367651138}}--></h1>';
  return {
    templateStringsArray,
    values: [name],
    templateElement,
  };
};

const node = document.body;
let part = parts.get(node);
if (part === undefined) {
  node.innerHTML = ''; // empty node
  part = new NodePart();
  parts.set(node, part);
}
const templateResult = sayHello('World');
part.setValue(templateResult);
part.commit();

Let's dig into what NodePart does.

Template

Template is a container that keeps track of values' indices and the template element.

During construction, it traverses the template element and replaces the special comment nodes with empty comment nodes to indicate where to inject values in the future.

NodePart

NodePart inherits from the parent class Part. It is in charge of native JavaScript methods to render a dom.

Its setValue method simply stores the new value as a __pendingValue. The heavy lifting is done in the commit method, which will call various helpers based on their type, in this case, __commitTemplate.

__commitTemplate will either create or update the internal instance of the TemplateInstance class. Deconstruction of the above methods results in the following code:

import { parts, TemplateInstance } from 'lit-html';

const templateStringsArray = ['<h1>Hello ', '</h1>'];
const sayHello = (name) => {
  const templateElement = document.createElement('template');
  templateElement.innerHTML = '<h1>Hello <!--{{lit-5060303367651138}}--></h1>';
  return {
    templateStringsArray,
    values: [name],
    templateElement,
  };
};
const node = document.body;
let part = parts.get(node);
if (part === undefined) {
  node.innerHTML = '';
  part = {};
  parts.set(node, part);
}
const template = sayHello('World');

if (part.templateInstance === undefined) {
  part.templateInstance = new TemplateInstance(template);
  const fragment = part.templateInstance._clone();
  node.appendChild(fragment);
}
part.templateInstance.update(template.values);

If a TemplateInstance is created, it's _clone method will be called. However, it is badly named, as it seems to create a browser native DocumentFragment instance instead by walking the template and creating necessary nested NodeParts. The newly created DocumentFragment instance is then appended into the node.

Updating a TemplateInstance with new values results in all the sub NodeParts updating as well, replacing their original dom node markers in the document. This is very efficient as only the nodes that are changed by values are replaced. The static nodes are not touched.

This is a simplification of the template algorithm after we remove main NodePart and TemplateInstance:

import { parts, TemplateInstance } from 'lit-html';

const templateStringsArray = ['<h1>Hello ', '</h1>'];
const sayHello = (name) => {
  const templateElement = document.createElement('template');
  templateElement.innerHTML = '<h1>Hello <!--{{lit-5060303367651138}}--></h1>';
  return {
    templateStringsArray,
    values: [name],
    templateElement,
  };
};
const node = document.body;
let part = parts.get(node);
if (part === undefined) {
  node.innerHTML = '';
  part = {};
  parts.set(node, part);
}
const template = sayHello('World');

if (part.templateInstance === undefined) {
  part.templateInstance = {
    template,
    subparts: [],
  };
  const fragment = document.importNode(template.templateElement.content, true);
  node.appendChild(fragment); // mount the entire fragment
  const subPart = fragment.firstChild.childNodes[1]; // reference to the comment node
  subpart.insertAdjacentElement('beforebegin', template.values[0]);
  part.templateInstance.subparts = [subPart];
}

// replace all the parts with new values
for (let i = 0; i < template.values.length; i++) {
  const subpart = part.templateInstance.subparts[i];
  subpart.previousSibling.replaceWith(template.values[i]);
}

Conclusion

In essence, lit-html is doing minimal work by keeping track of "holes" where new values could occur and replace them. This has the benefit of doing less JavaScript work at the expense of more inefficient dom operations.

Of course, in this investigation, we also skipped over various things such as updating of attributes, event listeners, efficient list updating, and security aspects. I highly encourage reading lit-html's code to get a better understanding of its mechanisms. Although it is a little confusing due to the layers of indirection, there are plenty of comments that explain higher-level concerns and operations.