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.
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);
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
}
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: