Skip to content

Instantly share code, notes, and snippets.

@developit
Last active July 25, 2023 12:45
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save developit/1409519fe1d62fb02a64b35a2e2fb66f to your computer and use it in GitHub Desktop.
Save developit/1409519fe1d62fb02a64b35a2e2fb66f to your computer and use it in GitHub Desktop.

Rendering Interactive HTML using Preact

It's possible to render HTML in Preact using the dangerouslySetInnerHTML prop, however doing so bypasses the Virtual DOM entirely. This may be reasonable for static HTML, but interactivity can be a little painful to graft on without VDOM.

There is another technique available that melds HTML to Virtual DOM without such limitations.

Enter DOMParser

An alternative approach taken by libraries like preact-markup is to parse the HTML using the browser's DOMParser API, then convert it to Virtual DOM and render that. It's also surprisingly simple.

First we need to parse the HTML into a DOM structure:

const html = `<button class="foo">Click Me</button>`;

const dom = new DOMParser().parseFromString(html, 'text/html');

Then, we pass that DOM tree to a function that will recurse through it and produce the equivalent Virtual DOM tree:

// convert the DOM tree to a Virtual DOM tree:
const vdom = convert(dom.body.firstElementChild);

Our convert() function takes a DOM node and returns the equivalent Virtual DOM node.

It also calls itself on each of the DOM node's children, creating a nested structure:

import { h } from 'preact';

// convert a DOM node to a Virtual DOM node:
function convert(node) {
  if (node.nodeType === 3) return node.data; // Text nodes --> strings
  let props = {}, a = node.attributes; // attributes --> props
  for (let i=0; i<a.length; i++) props[a[i].name] = a[i].value;
  return h(node.localName, props, [].map.call(node.childNodes, convert)); // recurse children
}

Finally, we can render the resulting Virtual DOM tree using Preact.

The tree we created works just like JSX - it can be nested in other trees or returned from a component:

import { render } from 'preact';

// render it:
render(vdom, document.body);

// or render it inside some other JSX:
render(<div class="content">{vdom}</div>, document.body);

Adding Dynamism

We can process the Virtual DOM nodes our convert() function creates in order to inject functionality. In Preact, all Virtual DOM elements created by h() can be intercepted using a vnode options hook:

import { options } from 'preact';

let old = options.vnode;
options.vnode = vnode => {
  // Example 1: add a click handler to <button action="toggle"> elements:
  if (vnode.props.action === 'toggle') {
    vnode.props.onclick = function() {
      this.classList.toggle('active');
    };
  }
  
  // Example 2: convert <a> to <Link>
  if (vnode.type === 'a') vnode.type = Link;

  if (old) old(vnode);  // call the next hook
};

This works, but any modifications apply to the entire application, since options hooks are global.

To inject functionality only into our HTML-derived Virtual DOM tree, we can process the tree as we create it. We're already "walking" the tree to convert() DOM nodes to Virtual DOM nodes, which provides a nice place to intercept them:

function convert(node) {
  if (node.nodeType === 3) return node.data; // Text nodes --> strings
  let props = {}, a = node.attributes;
  for (let i=0; i<a.length; i++) props[a[i].name] = a[i].value;
+ if (props.action === 'toggle') props.onclick = function() { this.classList.toggle('active') };
  return h(node.localName, props, [].map.call(node.childNodes, convert)); // recurse children
}

Putting it all together

This is the technique used by the little render-html.js library provided in this Gist. A list of actions can be passed into the <Html> component, which are available to be bound as event handlers by HTML using on:foo="action" attributes:

import { Html } from './render-html.js';

const html = `<button on:click="greet" data-greeting="Bill">Click Me</button>`;
                      // ^ on click, call the greet action

const actions = {
  greet(e) {
    alert('Hello ' + this.getAttribute('data-greeting'));
  }
};

render(<Html html={html} actions={actions} />, document.body);

For a demo of this working in practise, here's an HTML-based VDOM editor:

https://jsfiddle.net/developit/narb8qmo/

import { h } from 'preact';
let actions; // current actions to apply when walking the tree
function Html(props) {
const dom = new DOMParser().parseFromString(props.html, 'text/html');
actions = props.actions || {};
return [].map.call(dom.body.childNodes, convert);
}
function convert(node) {
if (node.nodeType === 3) return node.data;
let attrs = {};
for (let i=0; i<node.attributes.length; i++) {
const { name, value } = node.attributes[i];
const m = name.match(/^(?:on:|data-on-?)(.+)$/); // <a on:click="go" data-on-mouseover="blink">
if (m && actions[value]) attrs['on'+m[1]] = actions[value];
else attrs[name] = value;
}
return h(node.localName, attrs, [].map.call(node.childNodes, convert));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment