Write your own DOM element factory for TypeScript
The DOM API allows manipulating the HTML document in a browser. It's simple to use, but the code is not very readable. Here's the code needed to create 2 elements and set an attribute:
function foo() {
let div = document.createElement("div");
let anchor = document.createElement("a");
anchor.href = "https://www.meziantou.net";
div.appendChild(anchor);
return div;
}
The JSX syntax introduced by React allows to mix JavaScript and HTML-ish. The goal is the create a code that is easy to write and to read. JSX will be compiled by a preprocessor such as Babel (or TypeScript as we'll see later) to a valid JavaScript code. Using JSX, the previous function can be written as:
function foo() {
return (
<div>
<a href="https://www.meziantou.net">meziantou</a>
</div>
);
}
If you use React, the code will be converted to:
function foo() {
return React.createElement("div", { "class": className },
React.createElement("a", { href: "https://www.meziantou.net" }, "meziantou"));
}
React.createElement
creates a virtual DOM that will be translated to real DOM at the end. The idea of the virtual dom is to reduce the number of DOM operations, but it's not very important for this post… For simplicity, just imagine React.createElement
is similar to document.createElement
.
TypeScript supports the JSX syntax, so you can take advantage of the type checker, IDE autocompletion, and refactoring. TypeScript can preserve the JSX syntax (in case you want to use another precompiler), replace it with React.createElement
, or using a custom factory. The last option allows you to use JSX without using React, as long as you provide your factory.
A factory is just a function with the following declaration:
interface AttributeCollection {
[name: string]: string | boolean;
}
var Fragment;
function createElement(tagName: string, attributes: AttributeCollection | null, ...children: any[]): any
So, it's not very complicated to implement this function. You just need to use document.createElement
, set the attributes, and add the children. However, there are some points of attention.
- JSX does not allow the attribute
class
. Instead, you have to useclassName
. So, the factory has to handle this case. - Using JSX, you can register an event handler using
<a onclick={...}></a>
. However, thesetAttribute
function only accepts a string value. So, you have to handle this case by usingaddEventListener
. - Fragments (
<>...</>
) are replaced byfactory.createElement(factory.Fragment, null, ...)
. So, we can use a special name to create aDocumentFragment
in thecreateElement
function.
namespace MyFactory {
const Fragment = "<></>";
export function createElement(tagName: string, attributes: JSX.AttributeCollection | null, ...children: any[]): Element | DocumentFragment {
if (tagName === Fragment) {
return document.createDocumentFragment();
}
const element = document.createElement(tagName);
if (attributes) {
for (const key of Object.keys(attributes)) {
const attributeValue = attributes[key];
if (key === "className") { // JSX does not allow class as a valid name
element.setAttribute("class", attributeValue);
} else if (key.startsWith("on") && typeof attributes[key] === "function") {
element.addEventListener(key.substring(2), attributeValue);
} else {
// <input disable /> { disable: true }
// <input type="text" /> { type: "text"}
if (typeof attributeValue === "boolean" && attributeValue) {
element.setAttribute(key, "");
} else {
element.setAttribute(key, attributeValue);
}
}
}
}
for (const child of children) {
appendChild(element, child);
}
return element;
}
function appendChild(parent: Node, child: any) {
if (typeof child === "undefined" || child === null) {
return;
}
if (Array.isArray(child)) {
for (const value of child) {
appendChild(parent, value);
}
} else if (typeof child === "string") {
parent.appendChild(document.createTextNode(child));
} else if (child instanceof Node) {
parent.appendChild(child);
} else if (typeof child === "boolean") {
// <>{condition && <a>Display when condition is true</a>}</>
// if condition is false, the child is a boolean, but we don't want to display anything
} else {
parent.appendChild(document.createTextNode(String(child)));
}
}
}
Finally, you need to change the tsconfig.json
file to indicate the TypeScript compiler how to convert JSX:
{
"compilerOptions": {
"jsx": "react", // use the React mode, so call the factory
"jsxFactory": "MyFactory.createElement" // The name of the factory function
}
}
You can now create a file with the extension .tsx
, and use the JSX syntax. Hope this helps you writing DOM code!
If you want to see a real usage of a custom factory, you can check my Password Manager project on GitHub.
Do you have a question or a suggestion about this post? Contact me!