Skip to content

Latest commit

 

History

History
476 lines (354 loc) · 21 KB

DOCUMENTATION.md

File metadata and controls

476 lines (354 loc) · 21 KB

What Is µhtml (micro html) And How Does It Work

snow flake

A getting started guide with most common questions and answers, covered by live examples.


A Brief Introduction

While µhtml, on the surface, is a library that resemble some naive usage of innerHTML, it's actually far away from being an innerHTML replacement, as it's capable of handling events listeners, special and normal attributes, plus various kind of content, that will be properly parsed, normalized, and repeatedly updated at light speed, without trashing the previous content like innerHTML would do per each operation.

render(element, html`
  <h1 onclick=${() => console.log('🎉')}>
    Welcome to <em>µhtml</em> 👋
  </h1>
`);

As summary: µhtml is the tiniest declarative UI library of the Web, it's safe by default, and it's based on standard JS templates literals features.

Use Cases

Every time you use "vanilla JS" to deal with the DOM, you inevitably end up repeating over and over quite verbose code, and always to obtain the same result.

Following a classic <button> element with a click handler and some state:

const buttonState = {disabled: false, text: 'Click Me'};
const {disabled, text} = buttonState;
const {log} = console;

const button = document.createElement('button');
button.className = "clickable";
button.disabled = disabled;
button.textContent = text;
button.addEventListener('click', () => log('clicked'));

document.body.appendChild(button);

If this code looks familiar to you, it is highly possible your files contain most common helpers all over the place, such as const create = name => document.createElement(name) or similar.

All those micro utilities are cool and tiny, but the question is: "can they be declarative too?"

Following an example to obtain exact same result via µhtml, also live on codepen:

import {render, html} from '//unpkg.com/uhtml?module';

const buttonState = {disabled: false, text: 'Click Me'};
const {disabled, text} = buttonState;
const {log} = console;

render(document.body, html`
  <button class="clickable"
    onclick=${() => log('clicked')}
    .disabled=${disabled}
  >
    ${text}
  </button>
`);

As you can see, with µhtml you can declare UI in a similar way you would do with writing regular HTML, but with few extra essential features that makes it create DOM elements fun again:

  • event listeners are automatically handled, so that passing even a new function each time is ok, as the previous one, if different, is always removed. No more duplicated listeners by accident 🎉
  • attributes with a special meaning in the JS world, like disabled, which can be directly accessed as getters or setters, like we did before via button.disabled = value, instead of using a non semantic button.setAttribute("disabled", "") to set it disabled, and button.removeAttribute("disabled") to enabled it back, can be prefixed with a ., as it's done in .disabled=${value}
  • any other regular attribute can be used too, abstracting away the tedious el.setAttribute(...) dance, with the ability to remove attributes by simply passing null or undefined instead of an actual value, so that you could write disabled=${value || null} if using the . prefix is not your cup of tea
  • attributes that start with on... will be set as listeners right away, removing any previous listener if different from the one passed along. In this case, the onclick=${() => ...} arrow function would be a new listener to re-add each time
  • the content is always safe to pass as interpolation value, and there's no way to inject HTML by accident

Bear in mind, the content can also be another html chunk, repeatable in lists too, as the following example, also live in codepen shows:

const items = [
  {text: 'Web Development'},
  {text: 'Is Soo Cool'},
];

render(document.body, html`
  <ul>
    ${items.map(
      ({text}, i) => html`<li class=${'item' + i}>${text}</li>`
    )}
  </ul>
`);

As simple as it looks, you might wonder what kind of magic is involved behind the scene, but the good news is that ...

It's 100% JavaScript Standard: No Tooling Needed 🦄

The only real magic in µhtml is provided by an ECMAScript 2015 feature, known as Tagged Templates Literals.

When you prefix any template literal string with a function, without needing to invoke such function, the JavaScript engine executes these simple, but extremely useful, steps:

const tag = (template, ...values) => {
  // ℹ the function is invoked with these arguments
  // a *unique* array of strings around interpolations
  console.log(`Template: ${template}`);
  // and all the interpolations values a part
  console.log(`Values: ${values}`);
}

// ⚠ it's tag`...`, not tag()`...`
tag`This is a ${'template literals'} tagged ${'test'}`;

// Template: "This is a ", " tagged ", ""
// Values: "template literals", "test"

The unique part of the equation means that any template literal is always the same array, as long as it comes from the same scope, and the very same part of the script, example:

const set = new WeakSet;
const tag = template => {
  if (set.has(template))
    console.log('known template');
  else {
    set.add(template);
    console.log('new template');
  }
};

const scoped = () => tag`test`;

tag`test`;  // new template
tag`test`;  // new template
scoped();   // new template
scoped();   // known template
scoped();   // known template
tag`test`;  // new template

This is the fundamental concept that enables µhtml to be smart about never parsing more than once the exact same template, and it perfectly suits the "components as callback" pattern too:

// an essential Button component example
const Button = (text, className) => html`
  <button class=${className}>${text}</button>
`;

// render as many buttons as needed
render(document.body, html`
  Let's put some button live:
  ${Button('first', 'first')} <br>
  ${Button('second', '')}     <br>
  ${Button('third', 'last')}
`);

How Does The Parsing Work ?

This part is extremely technical and likely irrelevant for a getting started page, but if you are curious to understand what happens behind the scene, you can find all steps in here.

Internal Parsing Steps

Taking the essential Button(text, className) component example, this is how µhtml operates:

  • if the <button class=${...}>${...}</button> template is unknown:
    • loop over all template's chunks and perform these checks:
      • if the end of the chunk is name=", or name=', or name=, and there is an opened <element ... before:
        • substitute the attribute name with a custom µhtml${index}="${name}"
      • if the chunk wasn't an attribute, and the index of the loop is not the last one:
        • append an <!--µhtml${index}--> comment to the layout
      • otherwise append the chunk as is, it's the closing part
    • normalize all self-closing, not void, elements, so that the resulting joined layout contains <span></span> or <custom-element></custom-element> instead of <span /> or <custom-element />, which is another handy µhtml feature 😉
    • let the browser engine parse the final layout through the native Content Template element and traverse it in search of all comments and attributes that are only related to µhtml
    • per each crawled node, using an index that goes from zero to the length of passed values, as these are those to map and update in the future:
      • if the node is a comment, and its text content is exactly µhtml${index}, map recursively the position of that node to retrieve it later on, and move the index forward
      • if the node is not a comment:
        • while the node has an attribute named µhtml${index}, map the attribute value, which is the original name, and map the node to retrieve it later on, then move the index forward
      • if the node is a style or a textarea, and it contains <!--µhtml${index}-->, 'cause these elements cannot have comments in their content, map the node and flag it as "text content only", then move the index forward
      • if there are no more nodes to crawl, and the index haven't reached the loop length, throw an error passing the template, as something definitively went wrong
    • at this point we have a unique template reference, and a list of nodes to retrieve and manipulate, every time new values are passed along. Per each information, assign to each mapped node the operation to perform whenever new values are passed around: handle content, attributes, or text only.
    • weakly reference all these information with the template, and keep following these steps
  • retrieve the details previously stored regarding this template
  • verify in which part of the rendering stack we are, and relate that stack to the current set of details
  • if the stack is not already known:
    • clone the fragment related to this template
    • retrieve all nodes via the paths previously stored
    • map each update operation to that path
    • relate these information with the current execution stack to avoid repeating this next time, keep going with the next step
  • per each update available for this part of the stack, pass each interpolated value along, so that content, attributes, or text content previously mapped, can decide what to do with the new value
  • if the new value is the same as it was before, do nothing, otherwise update the attribute, text content, or generic content of the node, using in this latter case <!--µhtml${index}--> comment node reference, to keep updates confined before that portion of the tree

As result, each Button(text, className) component will simply invoke just two callbacks, where the first one will update its class attribute, while the second one will update its textContent value, and in both cases, only if different from the previous call.

This might not look super useful for "one-off" created elements, but it's a performance game changer when the UI is frequently updated, as in lists, news feeds, chats, games, etc.

I also understand this list of steps might be "a bit" overwhelming, but these describe pretty much everything that happens in the rabbit.js file, which also takes care of the whole "execution stack dance", which enables nested rendered, with smart diff, and through the µdomdiff module.

It's also worth mentioning I've been fine-tuning all these steps since the beginning of 2017, so maybe it was unnecessary to describe them all, but "the nitty-gritty" at least is now written down somewhere 😅

API In Details

The module itself exports these three functions: render, html, and svg.

The render(where, what) Utility

This function purpose is to update the content of the where DOM node, which could be a custom element, or any other node that can contain other nodes.

render(
  // where to render
  document.querySelector('#container'),
  // what to render
  html`content` || svg`content` || Node || callback
);

// Custom Element basic example
class MyComponent extends HTMLElement {
  connectedCallback() {
    // render content, it could also be
    // a Shadow root node
    render(this, html`My CE Content`);
  }
}

If the value of what is just a DOM node, and it's different from the one rendered before, it will clear the container and append it.

If the value of what is a callback, it will invoke it and use its result as content. Such result can be a Node, or the returning value of html or svg tags.

The html and svg Tags

As the name would suggest, html is the tag to use when HTML content is meant to be created, while svg should be used to created valid SVG nodes.

Beside this essential difference, both tags work in the exact same way, and both tags provide extra tags, such as .node and .for(ref[, id]).

The .node Tag

Both html.node and svg.node tags create a fresh new version of that specified content and return it.

// use node to generate new DOM content
const div = html.node`<div />`;

// the div is 100% a node
div.textContent = 'some µhtml content';
document.body.appendChild(div);

It is also possible to create multiple sibling nodes at once:

const fragment = html.node`
  <span>first</span>
  <span>second</span>
  <span>third</span>
`;

document.body.appendChild(fragment);

The only special feature of fragments created via html.node or svg.node, is that these will always return fragment.firstChild and fragment.lastChild nodes, even after being appended live, where native regular fragments would instead lose all their children.

µhtml fragments have also two special methods: valueOf(), that allow you to move all nodes initially assigned to the fragment somewhere else, or remove(), which would remove all nodes initially assigned in one shot.

// using the previous code example, then ...

document.body.removeChild(fragment.remove());

setTimeout(() => document.body.appendChild(fragment.valueOf()));

It is not super important to understand how to use fragments by hand, but these features are essential for the µhtml DOM diffing engine called µdomdiff, which is capable of updating, removing, or moving fragments around as needed.

The .for(ref[, id]) Tag

If you are familiar with the keyed and non-keyed rendering concepts, this method allows just that: you can reference a specific node, and its optional id, to any object. By default, µhtml uses a rendering stack to provide automatically, to each interpolation, and "always same index" during updates.

// non-keyed rendered view
const update = (items) => {
  render(
    document.querySelector('.list-items'),
    html`
    <ul>
      ${items.map(
        ({id, name}) =>
          html`<li data-id=${id}>${name}</li>`
      )}
    </ul>`
  );
};

const items = [
  {id: 1, name: 'Article X'},
  {id: 2, name: 'Article Y'},
  {id: 3, name: 'Article Z'},
];

update(items);

While most of the time it's OK to use non-keyed renders, there could be side effects when, instead of simple nodes, you have Custom Elements in the list, or you have special mutation observers somehow attached to the inner nodes.

In these cases, whenever the list changes, nodes that were previously there will simply be updated with new content, attributes, and the rest, but if the Custom Element had an attributeChangedCallback, as example, that does something expensive, such as fetching new data, as example, this callback will be inevitably called multiple times every time an article changes position in the list, or the list is sorted, it shrinks, or it expands.

But fear not, it is possible to relate a specific node through the tag returned by .for(...):

// *keyed* rendered view
const update = (items) => {
  const ref = document.querySelector('.list-items');
  render(ref, html`
    <ul>
      ${items.map(
        ({id, name}) =>
          html.for(ref, id)`<li data-id=${id}>${name}</li>`
      )}
    </ul>`
  );
};

const items = [
  {id: 1, name: 'Article X'},
  {id: 2, name: 'Article Y'},
  {id: 3, name: 'Article Z'},
];

update(items);

With latest example, live in codepen, you can follow nodes moving around without ever changing any of their attributes or content, and this is how, and why, keyed renders can be very important.

API: Attributes

Any element can have one or more attribute, either interpolated or not.

render(document.body, html`
  <div id="main"
        class=${`content ${extra}`}
        data-fancy=${fancy}>
    <p contenteditable=${editable}
        onclick=${listener}
        class="${['container', 'user'].join(' ')}">
      Hello ${user.name}, feel free to edit this content.
    </p>
  </div>
`);

These are the rules to follow for attributes:

  • interpolated attributes don't require the usage of quotes, but these work either ways. name=${value} is OK, and so is name="${value}" or even name='${value}'
  • you cannot have sparse attribute interpolations: always use one interpolation to define each attribute that needs one, but never write things like style="top:${x};left:${y}" as the parser will simply break with the error bad template. Use template literals within interpolations, if you want to obtain exact same result: style=${`top:${x};left:${y}`}
  • if the passed value is null or undefined, the attribute will be removed. If the value is something else, it will be set as is as value. If the attribute was previously removed, the same attribute will be placed back again. If the value is the same as it was before, nothing happens
  • if the attribute name starts with on, as example, onclick=${...}, it will be set as listener. If the listener changes, the previous one will be automatically removed. If the listener is an Array like [listener, {once:true}], the second entry of the array would be used as listener's options.
  • if the attribute starts with a . dot, as in .setter=${value}, the value will be passed directly to the element per each update. If such value is a known setter, either native elements or defined via Custom Elements, the setter will be invoked per each update, even if the value is the same
  • new: if the attribute starts with a ? question mark, as in ?hidden=${value}, the value will be toggled, accordingly with its truthy, or falsy, value.
  • if the attribute name is ref, as in ref=${object}, the object.current property will be assigned to the node, once this is rendered, and per each update. If a callback is passed instead, the callback will receive the node right away, same way React ref does.
  • if the attribute name is aria, as in aria=${object}, aria attributes are applied to the node, including the role one.
  • if the attribute name is .dataset, as in .dataset=${object}, the node.dataset gets populated with all values.

Following an example of both aria and .dataset cases:

// the aria special case
html`<div aria=${{labelledBy: 'id', role: 'button'}} />`;
//=> <div aria-labelledby="id" role="button"></div>

// the data special case
html`<div .dataset=${{key: 'value', otherKey: 'otherValue'}} />`;
//=> <div data-key="value" data-other-key="otherValue"></div>

API: HTML/SVG Content

It is possible to place interpolations within any kind of node, and together with text or other nodes too.

render(document.body, html`
  <table>
    ${lines.map((text, i) => html`
      <tr><td>Row ${i} with text: ${text}</td></tr>
    `)}
  </table>
`);

There are only two exceptional nodes that do not allow sparse content within themselves: the style element, and the textarea one.

// DON'T DO THIS
render(document.body, html`
  <style>
    body { font-size: ${fontSize}; }
  </style>
  <textarea>
    Write here ${user.name}
  </textarea>
`);

// DO THIS INSTEAD
render(document.body, html`
  <style>
  ${`
    body { font-size: ${fontSize}; }
  `}
  </style>
  <textarea>
  ${`
    Write here ${user.name}
  `}
  </textarea>
`);

Beside nodes where the content will be inevitably just text, like it is for style or textarea, as example, every other interpolation can contain primitives, as strings, numbers, or even booleans, or the returned value of html or svg, plus regular DOM nodes.

The only special case are Array of either primitives, or returned values from html or svg, and since 2.5 Function, invoked and resolved after invoke.

render(document.body, html`
  <ul>
    <li>This is ${'primitive'}</li>
    <li>This is joined as primitives: ${[1, 2, 3]}</li>
    <li>This is a callback: ${utility}</li>
    ${lines.map((text, i) => html`
      <li>Row ${i} with content: ${text}</li>
    `)}
  </ul>
`);

API: Rendering

The second what argument of the render(where, what) signature can be either a function, which returning value will be used to populate the content, the result of html or svg tags, or a DOM node, so that it is possible to render within a render.

const Button = selector => {
  const button = document.querySelector(selector);
  return count => render(button, html`Clicks: ${count}`);
};

const Clicker = selector => {
  const button = Button(selector);
  return function update(count) {
    return render(document.body, html`
      <div onclick=${() => update(++count)}>
        Click again:
        ${button(count)}
      </div>
    `);
  };
}

const clicker = Clicker('#btn-clicker');
clicker(0);