How lit-html works

Published - 5 min read

Unlike most modern front-end JavaScript libraries, lit-html does not use a virtual DOM. Instead, it utilizes tagged template literals, like this:

js
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 receives an array of strings and values, such as the expression name in the example above.

TemplateResult serves as a container to obtain a TemplateInstance from a cache and is also responsible for creating a HTMLTemplateElement by joining all the static strings with a special comment node.

By removing this abstraction, the code above can be simplified to something like this:

js
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 new object. The render function takes this new object and 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.

During the first render, a NodePart is created and stored in a WeakMap, using the node as the key. This ensures that if the node is removed from the DOM, the NodePart instance will also be garbage collected, preventing memory leaks.

If we remove the abstraction of render, we get this:

js
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 its construction, it traverses the template element and replaces special comment nodes with empty comment nodes. This indicates where values will be injected in the future.

NodePart

NodePart inherits from the parent class Part. It is responsible for using native JavaScript methods to render the 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:

js
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, its _clone method will be invoked. However, the name is misleading, as it appears to create a native browser DocumentFragment instance by traversing the template and generating the necessary nested NodeParts. The newly created DocumentFragment instance is then appended to the node.

Updating a TemplateInstance with new values causes all the sub NodeParts to update 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 the main NodePart and TemplateInstance:

js
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 performs minimal work by keeping track of “holes” where new values can occur and replacing 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.