Complete Guide to Lithent Virtual DOM Library Features
@superlucky84|December 25, 2023 (1y ago)444 views

Introduction
Lithent is a lightweight (3KB zip) virtual DOM UI library built on JSX.
It can be easily used by simply loading the script, without the need for a separate build tool. It is designed to dynamically update or remove frequently changing DOM areas by connecting them with a virtual DOM, even in already rendered HTML documents. Of course, it can also be used with a build tool, making it suitable for building SPA (Single Page Application) pages.
When used with a build tool, you can directly use JSX, while without a build tool, the library offers Tagged Templates, allowing you to work in a way that's very similar to JSX.
In addition to JSX and Tagged Templates, the library provides fTags
, which allows you to define the virtual DOM by calling functions.
The basic idea behind the development is to define and reuse components' states and functionality using higher-order functions and closures.
Table of Contents
- Basic Features
- Mounting
- Updating
- Renewing
- Rendering
- Mount Callback
- Update Callback
- Portals
- Advanced Features
- State Helper
- Store Helper
- Cache Update Helper
- Effect Helper
- Computed Helper
- Next Tick Helper
- lTag (Markup Support via Tagged Templates)
- fTags (Markup Support via Function Call Style)
Basic Features
Lithent is divided into two categories: essential features and extended features that combine the essential functionalities for virtual DOM use.
The essential features are those that help create a virtual DOM, update it, and connect it to the actual HTML document.
The basic features include:
- Mounting
- Updating
- Renewing
- Rendering
- Mount Callback
- Update Callback
- Portals
Mounting
The user creates a component by executing the mount
function provided by Lithent. The mount is responsible for defining the component's state and functionality, and it is executed only once when the component is first created as a virtual DOM.
To explain in more detail, the user defines a function that specifies the component's state and functionality. When the mount
function, which takes this function as an argument, is executed, it returns the component.
In this process, the user-defined "function that defines the component's state and functionality" is referred to as the mount function.
The mount function defines variables and functions within its own scope. The variables and functions defined here become the component's state and functionality. The mount function returns an updater. The updater is a function that returns JSX, which will be explained in more detail in the next section.
When defining the mount function, the first argument passed to it is the renew function. The renew function is responsible for updating the component. The second argument is props, which contains the values passed from the component's attributes.
Here is an example of defining the mount function and creating a component. The component has a state called count
and a function called increase
. It then returns a function that generates a JSX template. When using TypeScript, the mount function can define the type of props through generics.
import { h, mount } from 'lithent';
import { state, computed } from 'lithent/helper';
const Component =
mount <
{ increaseCount: number } >
((renew, props) => {
let count = 0;
const increase = () => {
count += props.increaseCount;
renew();
};
return () => <button onClick={increase}>count: {count}</button>;
});
render(<Component increaseCount={1} />, document.getElementById('root'));
Updating
The updating function is the one returned by the mount function, which is defined within the mount itself. This function returns JSX, and when executed, it creates the virtual DOM object. It's named updating because this function is executed every time the component needs to be updated.
Let's take a look again at the component I showed as an example when explaining the mount function.
const Component = mount((renew, props) => {
let count = 0;
const increase = () => {
count += props.increaseCount;
renew();
};
return props => (
<button onClick={increase}>
count: {count.value}, increaseCount: {props.increaseCount}
</button>
);
});
render(<Component increaseCount={1} />, document.getElementById('root'));
The important thing to note is that the updater
is a function.
The updater
is executed whenever the component's state needs to be reflected in the DOM. Since it is defined within the mount
function, it can access and use the variables and functions from the mount
via closures.
The updater
receives props
as its first argument. Of course, using closures, it can also reference the props
within the mount
. Please see the example code below.
import { h, Fragment, render, mount } from 'lithent';
const Child =
mount <
{ count: number } >
((_r, props) => {
const { count: countFromMounter } = props;
return ({ count: countFromUpdater }) => (
<>
<div>count: {props.count}</div>
<div>count: {countFromMounter} ("call by value" not working)</div>
<div>count: {countFromUpdater}</div>
</>
);
});
const Parent = mount(renew => {
let count = 0;
const change = () => {
count += 1;
renew();
};
return () => (
<>
<Child count={count} />
<button onClick={change}>Increase</button>
</>
);
});
The updater
of the Child component can reference the value in props
in three different ways. Here, the value countFromMounter
, which is pre-extracted from the mount
props, is already assigned to a new memory address. Therefore, even when the updater
is executed again, it cannot reflect the updated value from the parent props
. This situation is not confusing for developers who are proficient in JavaScript and have a clear understanding of how closures work.
Renew
The renew
is the renew function
provided as the first argument to the mount
function. When renew
is executed, the component's new state is reflected and rendered. The user can use it directly in event handler functions defined within the mount
. Since we've already shown many examples of how to use it in previous examples, we will omit the example code here.
The renew
can also be used for state helper
or store helper
, and more details will be explained later.
Render
Lithent provides the render function
. The render function
connects the virtual DOM with the actual real DOM area where the virtual DOM will be rendered.
You can easily understand it by looking at the example below, without needing much explanation.
/* index.html
<div>
<span>1</span>
<span>3</span>
<span>5</span>
</div>
*/
import { h, render, mount } from 'lithent';
const Component = mount<{ value: number }>(() => ({ value }) => <span>{value}<span>);
render(<Component value={2} />, element, element.querySelector('span:nth-of-type(2)'));
const destroy = render(<Component value={4} />, element, element.querySelector('span:nth-of-type(3)'));
When a DOM like the commented-out index.html
is drawn, render
is used to place the virtual DOM corresponding to positions 2 and 4 in the correct order.
The second argument of render
specifies where the virtual DOM should be rendered, meaning under which parent element. The third argument allows you to determine before which element it should be rendered, similar to insertBefore(DOM API)
. If the third argument is omitted, it defaults to appendChild
.
After the render
function is executed, it returns a destroy
function. When the destroy
function is executed, the area drawn by render
is unmounted.
Mount Callback
When a DOM object is created from a component and appears in the browser, it is called "mounting." The opposite concept, "unmounting," refers to when a component is removed from the browser.
You can specify certain functions to be executed when a component is mounted or unmounted. This can be registered using the mountCallback
function provided by Lithent.
As shown in the example below, you can register a function to be executed once the component is mounted, and the function returned by mountCallback
is registered as a callback to be executed during unmounting. If no follow-up actions are needed when unmounting, you can omit the return.
import { h, mount, mountCallback } from 'lithent';
const Children = mount((_r, props) => {
mountCallback(() => {
console.log('mounted');
return () => {
console.log('unmount');
};
});
return () => <span>Children</span>;
});
Update Callback
When a component's state changes and the renew
function is executed, the component is updated. Before the update process starts, the update callback is executed. After the update finishes and the changes are reflected in the DOM object, the updated callback is executed.
The update callback can be registered using updateCallback
provided by Lithent, and the function returned by updateCallback
is registered as a callback that runs after the component's update is completed. If no follow-up actions are needed after the update, you can omit the return.
const Children =
mount <
{ count: number } >
((_r, props) => {
updateCallback(
() => {
console.log('clean up');
return () => console.log('updated');
},
() => [props.count]
);
return ({ count }) => <span>child updated count: {count}</span>;
});
In the example code above, unlike mountCallback
, the updateCallback
has a second argument. This is for executing the callback only when a specific state within the component changes. It is defined as a function that returns an array, and you can place the values you want to detect changes for in that array.
When using props
in the callback or dependency array, you must include the props.
prefix for each instance to ensure accurate comparison. This is because the mount
function is executed only once when the component is mounted, and the callback functions or dependency check functions defined inside it access the props
values using closures each time the component updates. This is similar to the issue explained in the updater section earlier.
Portal
A portal allows you to render a child element to a different part of the DOM.
Here’s how you use it.
import { h, mount, portal } from 'lithent'
const Component = mount(renew => {
let count = 0;
const change = () => {
count += 1;
renew();
};
return () => (
<Fragment>
<button onClick={change}>Update</button>
{ portal( <Children count={count} />, document.getElementById('portal-area') as HTMLElement) }
</Fragment>
);
});
Advanced Features
Lithent is great with just its basic features, but when combined with additional functionality, it becomes even more convenient to use.
We’ve pre-written some useful features that users might find beneficial. You can directly import and use this code, or refer to it to create your own extensions.
All implementations of the helper code can be found in the repository.
State Helper
The approach where the user manually determines when the component should be updated by executing the renew
function every time the state changes is useful and gives control over the update timing. However, if you don’t need to control the update timing so precisely and simply want the component to update whenever a value changes, you can use the state helper
.
The following code shows how to use it easily.
import { h, Fragment, render, mount } from 'lithent';
import { state, computed } from 'lithent/helper';
const Component = mount(renew => {
const count = state < number > (1, renew);
const increase = () => {
count.value += 1;
};
return () => (
<>
<button type="text" onClick={increase}>
increase
</button>
<span>count: {count.value}</span>
</>
);
});
The state helper
binds the renew
function to a specific state.
The code below is the implementation of the state helper
. The principle behind it is simple. The state
takes an initial value and the renew
function, and returns an object with getter
and setter
. This object allows checking the value and, when the value is changed, it triggers the execution of renew
.
export const state = <T>(
value: T,
renew: () => boolean
): {
value: T;
v: T;
} => {
let result = value;
return {
get value() {
return result;
},
set value(newValue: T) {
result = newValue;
renew();
},
};
};
store helper
The store helper
shares an object that can be used globally across all components. Components that receive this store
object not only get the shared value, but also can share their renew
permissions with the store helper
.
The store helper
allows users to define the data structure they want in the form of an object. In the example below, the text
property is defined as a string type and the count
property is defined as a number type. When the store helper
is executed, it returns an assigner
that assigns the shared data to the component. In the example below, assignShardStore
is the assigner
.
The assigner
is used within the component's mounter and is executed after receiving the component's renew
as its first argument. The assigner
provides shared data to the component through a proxy object.
When a specific property within the shared proxy object is changed, all components sharing that value will be updated.
By using the second argument of the assigner
function, you can choose to only share specific properties from the store
. The second argument is in the form of a function, and as shown in the example below, you can define the values you want to use and return them as an array. If you want to use all properties, you can simply omit the second argument.
import { h, Fragment, render, mount } from 'lithent';
import { store } from 'lithent/helper';
/*
<div>
<span>1</span>
<span>2</span>
<span>3</span>
</div>
*/
const assignShardStore = store<{ text: string; count: number }>({ text: 'sharedText', count: 3 });
const Component = mount(r => {
// The value of "shardStore.count" is null.
// To get the value, you must include it in the second argument, the function return array.
// If you omit the second argument, then all values in the store are fetched.
const shardStore = assignShardStore(r, (store) => [store.text]);
const changeInput = (event) => {
shardStore.text = event.target.value;
};
return () => <textarea type="text" onInput={changeInput} value={shardStore.text} />;
});
render(<Component />, element, element.querySelector('span:nth-of-type(2)'));
render(<Component />, element, element.querySelector('span:nth-of-type(3)'));
The implementation method of the store helper
is similar to that of the state helper
. However, unlike the state helper
, the store helper
can hold multiple renew
functions from different components. Therefore, it is important to continuously check and remove the renew
function of any components that have been unmounted. This can be implemented by leveraging the fact that executing a renew
function that has already been removed will return false
.
You can check the code at the repository.
cacheUpdate Helper
By wrapping the updater with the cacheUpdate
function, you can prevent unnecessary re-renders for unchanged states.
Whether it's a component's prop
, state
, or any other form of state, when an attempt to re-render occurs, it compares the previous state with the updated state. If the values are the same, it prevents any further updates.
You can easily understand how to use it by looking at the example below. In this example, only the value of count1
is checked against the previous state and cached. Only count1
is checked, and even if count2
changes, the component will not be updated.
import { h, Fragment, render, mount } from 'lithent';
import { cacheUpdate } from 'lithent/helper';
const Component = mount(renew => {
const count1 = state < number > (0, renew);
const count2 = state < number > (0, renew);
const insCount1 = () => {
count1.value += 1;
};
const insCount2 = () => {
count2.value += 1;
};
return cacheUpdate(
() => [count1.value],
() => (
<Fragment>
depth1: {count1} - {count2}
<button onClick={insCount1}>insCount1</button>
<button onClick={insCount2}>insCount2</button>
</Fragment>
)
);
});
render(<Component />, document.getElementById('root'));
effect Helper
The effect Helper
combines the functionality of mountCallback
and updateCallback
to provide an effect similar to React's useEffect
.
The first argument runs after the mount or update. The second argument runs before the component is unmounted or updated. The third argument allows you to specify dependencies that will trigger the update only when certain values change; if omitted, the callback runs on every update.
An important note is that the third argument must be a function that returns an array. Also, since the effect Helper
accesses all values of the mounter through closures, it is important to check the call by value
and call by reference
states of the values you intend to use.
import { h, Fragment, render, mount } from 'lithent';
import { state, effect } from 'lithent/helper';
const Children = mount((r, props) => {
const count = state < number > (0, r);
const change = () => {
count.v += 1;
};
effect(
() => console.log('INJECT'),
() => console.log('CLEAN UP'),
() => [count.v]
);
return () => (
<>
<button onClick={change} type="button">
increase
</button>
<span>count: {count.v}</span>
</>
);
});
computed Helper
The computed Helper
is a tool that simplifies templates when complex calculations are used repeatedly within JSX, making the template appear more manageable by pre-calculating values for easy use.
Below is a simple example of how to use the computed Helper
.
import { h, Fragment, render, mount } from 'lithent';
import { computed } from 'lithent/helper';
const Component = mount(renew => {
let count = 0;
const increase = () => {
count += 1;
renew();
};
const computed =
computed <
number >
(() => {
return [1, 3, 5, 7, 9].reduce(
(accumulator, current) => accumulator + current * count,
0
);
});
return () => (
<Fragment>
<button type="text" onClick={increase}>
increase
</button>
<span>computed: {computed.value}</span>
</Fragment>
);
});
render(<Component />, document.getElementById('root'));
nextTick Helper
The nextTick Helper
is a utility that waits for the next DOM update flush. After a component's change request, the user can use the nextTick Helper
to ensure that the virtual DOM is fully applied to the actual DOM before proceeding with subsequent tasks. For example, it can be used to test the state of a component after a specific event has triggered changes.
When multiple component update requests happen simultaneously or several components need updates at the same time, Lithent uses queueMicrotask to minimize unnecessary internal operations, gathering all re-rendering requests and executing them in a timely manner.
The nextTick Helper
adds a callback to the microtask queue at the very end, ensuring it runs after all rendering requests in the browser's microtask queue have been completed.
nextTick().then(() => {
expect(testWrap.outerHTML).toBe(
'<div><button>insCount1</button><button>insCount2</button><span>depth1: 0 - 0</span> </div>'
);
});
lTag (Tagged Templates Markup Support)
By using lTag
, you can develop using regular JavaScript syntax similar to JSX, without the need for a separate transpiler.
lTag
is a Tagged template pre-bound to Lithent's h(createElement)
and follows the HTM convention.
It can be used as shown in the example below.
<script src="https://cdn.jsdelivr.net/npm/lithent@1.7.0/dist/lithent.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lithent@1.7.0/helper/dist/lithentHelper.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lithent@1.7.0/tag/dist/lithentTag.umd.js"></script>
<script>
const { h, Fragment, render, mount } = lithent;
const { state } = lithentHelper;
const { lTag } = lithentTag;
const Component = mount((renew, props) => {
const count = state(0, renew);
const change = () => {
count.value += props.propValue;
};
// Updater
return () => lTag`
<${Fragment}>
<li>count: ${count.value}</li>
<button onClick=${change}>increase</button>
<//>
`;
});
// appendChild or insertBefore
// The third argument is an optional value for insertBefore.
const destroy = render(
lTag`<${Component} propValue=${1} />`,
document.getElementById('root'),
document.getElementById('#insert-before-this-element')
);
</script>
fTags (Function Call Style Markup Support)
By using fTags
, you can define the virtual DOM using function calls, without directly using JSX or h (createElement)
. No separate transpiler is required.
Instead of using the default mount
function to create components, fMount
is used, and instead of the default Fragment component
, the fFragment function
is used.
For standard tags like div
, section
, p
, you can simply use the corresponding functions from the fTag
object.
It can be used as shown in the example below.
<script src="https://cdn.jsdelivr.net/npm/lithent@1.9.0/dist/lithent.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lithent@1.9.0/helper/dist/lithentHelper.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lithent@1.9.0/ftags/dist/lithentFTags.umd.js"></script>
<script>
// import { render } from 'lithent';
// import { fTags, fFragment, fMount } from 'lithent/ftags';
const { render } = lithent;
const { fTags, fMount, fFragment } = lithentFTags;
const { section, div, p, br, strong } = fTags;
const fTagComponent = fMount((_r, props, children) => {
return () =>
fFragment(
'first inner',
div({ style: 'border: 1px solid red' }, 'second inner'),
props.firstProp,
...children
);
});
render(
fTagComponent(
{ firstProp: 3 },
div({ style: 'border: 1px solid green' }, `Fchildren1`),
'Fchildren2',
br()
),
document.getElementById('root')
);
</script>