2021-07-19
let container = document.createElement("div")
let header = document.createElement("h1")
header.textContent = "Example header"
header.style = "color: red; font-size: 26px;"
let paragraph = document.createElement("p")
paragraph.textContent = "This is an example paragraph"
paragraph.classList.add("body-text")
container.appendChild(header)
container.appendChild(paragraph)
document.body.appendChild(container)
This is how you would create a div with a header and a paragraph, both containing some text
and attributes. This is very verbose. We can do better than this. Here's a suggestion:
document.body.appendChild(
<div>
<h1 style="color: red; font-size: 26px;">Example header</h1>
<p class="body-text">This is an example paragraph</p>
</div>
)
Wouldn't it be a lot more readable and intuitive being able to write something like this, and have it compile down to the pure JS we saw above?
Well, it turns out you can do exactly that, without much effort, thanks to custom JSX runtimes. You can choose how to handle the conversion from JSX to JS.
I wrote the simple library jsx2js that
performs exactly this conversion. Your JSX will be turned into to pure DOM-calls at compile-time, making for good use in dynamic environments where a low footprint is important, like 3rd-party scripts, widgets and even entire webpages.
I'll describe how you could write your own library like this, and also how to use it.
You can view the source code here. It's really simple and you can implement your own version if you want.
The idea is to create a custom JSX runtime
that uses document.createElement() instead of the usual React.createElement.
The JSX runtime expects one important function: jsx(). It takes 2 arguments: tag name (p, div etc.),
and an object containing children elements, and all props (style, class etc.).
We can now write a basic function that does exactly this:
export const jsx = (tag, { children, ...props }) => {
const element = document.createElement(tag);
for (const key in props) {
element.setAttribute(key, props[key]);
}
appendChild(element, children);
return element;
};
We need to define appendChild as well. The parent is always a HTMLElement in this case,
and the child could be either a Node (TextNode or HTMLElement), a string, or an
iterable (Array or NodeList for example) containing nodes and strings
(in which case the function calls itself recursively for every element in the iterable):
const appendChild = (parent, child) => {
if (!child) return;
if (
typeof child !== "string" &&
child[Symbol.iterator] &&
!(child instanceof Node)
) {
// It's an iterable
for (let i = 0; i < child.length; i++) appendChild(parent, child[i]);
return;
}
// It's a Node or a string
parent.appendChild(
child instanceof Node ? child : document.createTextNode(child)
);
};
Cool! Now, what if we want to support custom components? Like this:
const App = ({ children }) => (
<div>
{children}
</div>
)
document.body.appendChild(<App />)
In this case, the App function will be passed along instead of a tag name.
So we should handle this case as well, and simply call the App function with the
correct arguments like this:
export const jsx = (tagOrComponent, { children, ...props }) => {
if (typeof tagOrComponent === "function") return tagOrComponent({ children, ...props });
const element = document.createElement(tagOrComponent);
for (const key in props) {
element.setAttribute(key, props[key]);
}
appendChild(element, children);
return element;
};
Now the JSX runtime will transpile this:
const App = () => {
return <h1>Hello world</h1>
}
document.body.append(<App />)
Into this:
import {jsx as _jsx} from 'jsx2js';
const App = () => {
return _jsx('h1', { children: 'Hello world' });
}
document.body.append(_jsx(App, {}))
Which, as we saw above, are just normal function calls to document.createElement()!
So now, how do we actually transpile this?
For doing the actual transpilation, you'll need a project with Babel set up.
This could be a Parcel project or whatever
bundler you prefer.
You'll then need to install jsx2js and Babel's @babel/plugin-transform-react-jsx:
$ npm i -D jsx2js @babel/plugin-transform-react-jsx
Now, add (or configure if it already exists) your .babelrc
{
// ...
"plugins": [
// ...
[
"@babel/plugin-transform-react-jsx",
{
"runtime": "automatic",
"importSource": "jsx2js"
}
]
]
}
Now you're ready to start writing JSX!
const ExampleComponent = ({
children,
title = 'Default title',
...props
}) => {
return (
<div class="component" {...props}>
<h2>{title}</h2>
<div>{children}</div>
</div>
);
};
const Counter = ({}) => {
let clicks = 0
const clickSpan = <span>0</span>
const increment = () => {
clicks++
clickSpan.innerText = clicks
}
const button = (
<button>
Clicked {clickSpan} times
</button>
)
button.addEventListener("click", increment)
return button
}
document.body.append(
<div>
<ExampleComponent title="JSX without React">
<p>Example child</p>
</ExampleComponent>
<Counter />
<p>It just works</p>
</div>
)