SubtlefloSubtlefloSubtlefloSubtleflo

Complete Guide to Lithent Virtual DOM Library Features

December 25, 2023 (1y ago)444 views

Introduction

Github, Homepage

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

  1. Basic Features
    • Mounting
    • Updating
    • Renewing
    • Rendering
    • Mount Callback
    • Update Callback
    • Portals
  2. Advanced Features
    • State Helper
    • Store Helper
    • Cache Update Helper
    • Effect Helper
    • Computed Helper
    • Next Tick Helper
  3. lTag (Markup Support via Tagged Templates)
  4. 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>