Skip to content

Instantly share code, notes, and snippets.

@TELEUZI
Last active August 17, 2024 09:09
Show Gist options
  • Save TELEUZI/410d19772481d98b06e0b41ebf89fff1 to your computer and use it in GitHub Desktop.
Save TELEUZI/410d19772481d98b06e0b41ebf89fff1 to your computer and use it in GitHub Desktop.

Creating markup with JS

Before reading

Before reading, make sure you are familiar with the following concepts:

Introduction to the Problem

In the world of web development, where interactivity plays a key role, the creation of HTML elements using JavaScript is becoming an integral part of website creation. The need for dynamically generated elements on web pages is driven by many factors, from adaptability and responsiveness to efficient data management.

Static web pages created using static HTML limit the ability of the user to interact with the content. In this context, JavaScript acts as the key for diversity and flexibility, allowing developers to create, modify, and delete HTML elements dynamically, according to user needs and application requirements. This article discusses various methods of creating HTML elements using pure JavaScript, justifying the need for this approach and identifying the advantages and disadvantages of each method.

Naive implementation ✨

Template Strings is an ES6 standard innovation that provides a convenient and readable way to insert variables and expressions into strings that can also be used to create HTML elements using the innerHTML property. Example:

const bodyElement = document.body;
const title = 'Welcome to our website!';
const content = 'This element comes with additional classes.';

const template = `
    <h1 class="custom-header">${title}</h1>
    <p class="content--hidden">${content}</p>
    <button>Click me!</button>
`;

bodyElement.innerHTML += template;

You can quickly create some pieces of HTML markup in Javascipt files this way, but usage of the innerHTML property to create and update HTML elements has some issues that must be considered when using it. Here are some of them:

  1. Security: One of the main problems with using innerHTML is the potential security vulnerability. Markup added via innerHTML that contains user input or data must undergo validation and malware cleanup to avoid attacks like script injection (XSS). Improper input processing can lead to client-side execution of malicious code.

  2. Rendering and loss of state: Using innerHTML results in a complete redraw of the element and descendants, which will be inefficient, especially when dealing with large and complex DOM structures. Moreover, when innerHTML is updated, related events and data will be lost, making it difficult to maintain the state of the element.

  3. Excessive updates: When innerHTML is used to update an element, its HTML code is overwritten, regardless of whether only some of the content has changed. This leads to redundant updates and performance degradation, especially with frequent content manipulation.

  4. Code complexity: innerHTML works with HTML strings, which can make code less clear and error-prone, especially when inserting dynamically generated content. It also creates difficulties in handling special cases, such as escaping characters or dealing with markup features.

An additional difficulty in supporting this method comes when we need to dynamically update an element that has already been created, but we don't have a reference to it:

bodyElement.innerHTML += `
    <h1 class="custom-header">${title}</h1>
    <p class="content--hidden">${content}</p>
    <button>Click me!</button>
`;
document.querySelector('button').addEventListener('click', () => {
 document.querySelector('p').classList.toggle('content--hidden');
});

That said, if we had created the p and button elements ourselves, rather than using innerHTML, we would not need to search the DOM tree using document.querySelector(), since we could access them directly.

Using DOM search methods such as querySelector() and similar methods can be convenient, but there are good reasons not to use them:

  1. Performance: DOM search methods can be slow, especially when performing complex queries on large documents. Selector-based searches can traverse a large number of elements, which affects performance.

  2. Weak code readability: Using selectors in code can make it less readable, especially if the selectors are complex or long.

  3. Code fragility: Changing the DOM structure can affect the selectors, which makes the code fragile: if the page structure changes, the selectors may stop finding the right elements.

  4. Limited reusability: Code that uses selectors is less reusable because it is bound to a specific DOM structure. This makes it difficult to use the code in other projects or even within the same project with a changed structure.

  5. Ease of code duplication: When repeatedly searching for the same elements in different parts of the code, it is easy to allow code duplication.

  6. Not best practice: Modern frameworks do not encourage the use of DOM search techniques, offering their own tools.

Instead of innerHTML, it is recommended to use safe and flexible methods such as document.createElement(), append(), the textContent property, and others that provide the ability to create and update DOM elements with safety, efficiency, and state preservation in mind.

Using direct element creation and referencing, such as through assigning to an identifier, allows you to write more stable and performant code. However, the choice between using selectors and directly referencing elements depends on the specific application context and requirements.

Manual labor 🤜

Let's implement the same functionality using the createElement() method:

const headerElement = document.createElement('h1');

// Set the text content of the element
headerElement.textContent = title;

// Add the class "custom-header"
headerElement.classList.add('custom-header');

const additionalClassElement = document.createElement('p');
additionalClassElement.textContent = content;
additionalClassElement.classList.add('content--hidden');

const buttonElement = document.createElement('button');
buttonElement.textContent = 'Click me!';
buttonElement.classList.add('button');
buttonElement.addEventListener('click', () => {
 additionalClassElement.classList.toggle('content--hidden');
});

// Add elements to the DOM
bodyElement.append(headerElement, additionalClassElement, buttonElement);

The amount of code has increased, but now there are no threats associated with using innerHTML and you can interact with elements without having to search the DOM. In this example only the paragraph and button are dynamic, the header remains static, but it also had to be created using this method.

If you have static elements whose state does not depend on user actions, you can create them using template strings, but to insert them into the DOM use the insertAdjacentHTML method, which accepts the position to insert the element and a string of HTML. The method has security issues like innerHTML, but is suitable for static markup because:

It does not reparse the element it is being used on, and thus it does not corrupt the existing element it is being used on, and thus it does not corrupt the existing elements inside that element. This avoids the extra step of serialization, making it much faster than direct innerHTML manipulation.

Example:

const headerElement = `<h1 class="custom-header">${title}</h1>`;
bodyElement.insertAdjacentHTML('afterbegin', headerElement);
bodyElement.append(additionalClassElement, buttonElement);

Advantages

  1. Security: Using document.createElement() and the textContent property provides secure element creation, as it prevents the introduction of malicious code related to improper handling of HTML strings, as can happen when using innerHTML.

  2. Effectiveness: The document.createElement() method allows you to create an empty element without directly adding it to the DOM. This is useful if you need to manipulate the element before adding it to the page. Adding elements using append() is more efficient than a complete redraw with innerHTML.

  3. Saving State: Using separate methods for creating, setting text content, and adding an element allows more flexibility in managing the state and updating parts of the DOM without losing events and data.

Disadvantages

  1. More code: Creating elements requires more code, especially when describing complex structures.

  2. More operations: Using separate methods results in more operations to achieve the same result, compared to the more compact syntax of template strings.

  3. Readability of HTML code: In case the element structure is complex and compactness is prioritized, using template strings is a better option.

Don't waste time 🐵

It's not hard to see that the process of creating elements using document.createElement() is almost identical for all elements:

  1. Create an element
  2. Assign classes
  3. Assign text
  4. ???
  5. PROFIT!

Let's use the DRY principle and create a function to create an element without boilerplate code:

The createElement() function takes an options parameter object to create an HTML element and add it to the parent element if provided:

/**
 * Function to create an HTML element with specified parameters.
 * @param {Object} options - Object containing parameters for creating the element.
 * @param {string=} options.tag - HTML element tag (default is 'div').
 * @param {string=} options.text - Text content of the element (default is an empty string).
 * @param {HTMLElement=} options.parent - Parent HTML element to which the created element is appended (default is null).
 * @param {Array=} options.classes - Array of classes to be added to the created element (default is an empty array).
 * @returns {HTMLElement} - Created HTML element.
 */
function createElement(options) {
 // Default values
 const { tag = 'div', text = '', parent, classes = [] } = options;

 const element = document.createElement(tag);
 element.textContent = text;

 // Adding classes if provided
 if (classes.length > 0) {
  element.classList.add(...classes);
 }

 // Adding the element to the parent element if necessary
 if (parent != null) {
  parent.appendChild(element);
 }

 return element; // Returning the created element for further manipulations
}

This function provides flexibility in creating and adding elements in the DOM by allowing you to specify the element's tag, text, parent, and classes. Let's use the function for the previous button example:

const paragraphElement = createElement({
 tag: 'p',
 text: content,
 classes: ['content--hidden'],
});

const buttonElement = createElement({
 tag: 'button',
 text: 'Click me!',
 classes: ['custom-button'],
});
buttonElement.addEventListener('click', () => {
 paragraphElement.classList.toggle('content--hidden');
});

bodyElement.append(paragraphElement, buttonElement);

An alternative to passing a parent element is to pass child elements for the element being created:

function createElement(options) {
 const { tag = 'div', text = '', children = [], classes = [] } = options;
 const element = document.createElement(tag);
 element.textContent = text;
 if (classes.length > 0) {
  element.classList.add(...classes);
 }
 element.append(...children);
 return element;
}

Let's implement as an example the structure of the navigation element nav -> ul -> li -> a, whose elements will be created dynamically using data from a previously prepared array of objects:

const menuItems = [
 { text: 'Home', href: '#' },
 { text: 'About', href: '#about' },
 { text: 'Contacts', href: '#contact' },
];

function createMenu1(menuItems) {
 return createElement(
  {
   tag: 'nav',
  },
  createElement(
   {
    tag: 'ul',
   },
   ...menuItems.map(item =>
    createElement(
     {
      tag: 'li',
     },
     createElement({
      tag: 'a',
      text: item.text,
      href: item.href,
     })
    )
   )
  )
 );
}
// Use function for creating menu elements
const navigation = createMenu1(menuItems);
document.body.append(navigation);

The nesting of elements is controlled arbitrarily, let us to divide the creation of elements into separate stages:

function createMenu2(menuItems) {
 const navElement = createElement({
  tag: 'nav',
 });
 const ulElement = createElement({ tag: 'ul' });
 ulElement.append(
  ...menuItems.map(item => {
   const liElement = createElement({ tag: 'li' });
   const aElement = createElement({
    tag: 'a',
    text: item.text,
    href: item.href,
   });
   liElement.append(aElement);
   return liElement;
  })
 );
 navElement.append(ulElement);
 return navElement;
}

The choice between passing parent/children/parent+children as parameters, as well as the degree of declarativeness when creating elements depends on preferences and the chosen code style.

When running the previous example in a browser, you may notice that links created using createElement() do not work, because the function does not handle the href argument passed when creating a link element in any way. To solve the problem, you can either change the implementation of the createElement() function, or create a new one that extends the functionality of the previous one using function composition. The Open-Closed principle of SOLID says that the second option is better. Realization:

/**
 * Creates an HTML link element with the given text and href attributes.
 *
 * @param {Object} options - An object containing the following properties:
 * @param {string} [options.text=''] - The text content of the link element to be created.
 * @param {string} [options.href='#'] - The href attribute of the link element to be created.
 * @returns {HTMLElement} - The created link element with the specified text and href attributes.
 */
function createLinkElement(options) {
 const { text = '', href = '#' } = options;
 const element = createElement({
  tag: 'a',
  text,
  classes: ['link'],
 });
 element.setAttribute('href', href);
 return element;
}

In this way, you can create separate functions for elements that need specific functionality (buttons, links, inputs, forms).

Although the createElement() function provides a basic way to create and add elements in the DOM, there are some problems with using it:

  1. Lack of encapsulation: The createElement() function does not provide a mechanism to encapsulate the logic and state of a component. In the parameter object, the properties of the element are passed in, but they are not associated with the methods or logic of the component.

  2. State management complexity: Without explicit state management, it becomes more difficult to create components that can respond to state changes and update the interface accordingly.

  3. Complexity of interfacing with other components: In a component-based approach, there are simpler mechanisms for interaction between components, such as through property passing (props). The createElement() function does not provide such built-in mechanisms.

  4. Limited reusability: Any HTML elements are created in a uniform way, but some elements have their own behavior logic that this function does not take into account.

  5. State tracking complexity: The createElement() function does not provide mechanisms to track the state of a component, making it difficult to react to interface changes and updates.

  6. Complicated event handling: Event handling in the createElement() function requires adding listeners directly, which makes the code less structured and readable.

The createElement() function provides a basic level of abstraction for creating elements, but when developing more complex applications or components, the use of classes and a component-based approach can provide a higher level of structured, reusable, and maintainable code.

Component-Based Architecture 🏆

Component-Based Architecture (CBA) is a software architecture framework in which a system is broken down into independent, reusable components, each of which performs a specific function. This approach facilitates software development, testing, and maintenance.

In a component-oriented architecture, an application is built from many small, self-contained components that represent logical blocks of functionality. These components can interact with each other to create a complex application.

Some key aspects of Component-Based Architecture include:

  1. Division into components: An application is divided into many components, each representing a different piece of functionality.

  2. Independence of components: Each component functions autonomously and is independent of the internal implementation of other components. This allows changes to be made to one component without affecting other parts of the system.

  3. Interfaces and communication: Components communicate with each other through well-defined interfaces. The interfaces define the ways in which the components communicate and control data.

  4. Reusability: Components are designed to be reusable in different contexts. This helps in reducing code duplication and speeds up the development process.

  5. Maintenance and testing: Components can be tested and used independently of the rest of the system. This simplifies testing of individual parts of the application and improves the overall maintainability of the system.

  6. Component types: Components can be represented as UI elements in web development (e.g., button, form) or more complex functional units (e.g., user management system).

The usage of component-oriented architecture can simplify the development process, provide clear code organization and increase the reusability of components, which makes the system flexible and scalable.

Let's consider creating components using classes. The constructor of the component class will be the previously described createElement() function, the element created inside the constructor will be stored in a private field of the class, moreover we provide to users of our component an interface with methods that duplicate the functionality of HTMLElement, while extending it. This approach is similar to using the Proxy pattern.

The basic implementation of a class for components can look as follows:

/**
 * Represents a component for creating and managing HTML elements with additional functionalities.
 * @class
 */
class Component {
 /**
  * @type {Array<Component>} - An array to store child components.
  */
 #children = [];

 /**
  * @type {HTMLElement} - The HTML node associated with the component.
  */
 #node = null;

 /**
  * Creates a new Component.
  * @constructor
  * @param {Object} options - The options for creating the component.
  * @param {string=} options.tag - HTML element tag (default is 'div').
  * @param {string=} options.className - CSS class name for the element.
  * @param {string=} options.text - Text content of the element.
  * @param {...Component} children - Child components to be appended.
  */
 constructor({ tag = 'div', className = '', text = '' }, ...children) {
  const node = document.createElement(tag);
  node.className = className;
  node.textContent = text;
  this.#node = node;

  if (children) {
   this.appendChildren(children);
  }
 }

 /**
  * Appends a child component to the current component.
  * @param {Component} child - The child component to be appended.
  */
 append(child) {
  this.#children.push(child);
  this.#node.append(child.getNode());
 }

 /**
  * Appends an array of child components to the current component.
  * @param {Array<Component>} children - Array of child components to be appended.
  */
 appendChildren(children) {
  children.forEach(el => {
   this.append(el);
  });
 }

 /**
  * Returns the HTML node associated with the component.
  * @returns {HTMLElement} - The HTML node.
  */
 getNode() {
  return this.#node;
 }

 /**
  * Returns an array of child components.
  * @returns {Array<Component>} - Array of child components.
  */
 getChildren() {
  return this.#children;
 }

 /**
  * Sets the text content of the component.
  * @param {string} content - The text content to be set.
  */
 setTextContent(content) {
  this.#node.textContent = content;
 }

 /**
  * Sets an attribute on the component's HTML node.
  * @param {string} attribute - The attribute to set.
  * @param {string} value - The value to set for the attribute.
  */
 setAttribute(attribute, value) {
  this.#node.setAttribute(attribute, value);
 }

 /**
  * Removes an attribute from the component's HTML node.
  * @param {string} attribute - The attribute to remove.
  */
 removeAttribute(attribute) {
  this.#node.removeAttribute(attribute);
 }

 /**
  * Toggles the presence of a CSS class on the component's HTML node.
  * @param {string} className - The class name to toggle.
  */
 toggleClass(className) {
  this.#node.classList.toggle(className);
 }

 /**
  * Adds an event listener to the component's HTML node.
  * @param {string} event - The event type to listen for.
  * @param {EventListener} listener - The callback function to be executed when the event occurs.
  * @param {boolean|AddEventListenerOptions} [options=false] - An options object specifying characteristics of the event listener.
  */
 addListener(event, listener, options = false) {
  this.#node.addEventListener(event, listener, options);
 }

 /**
  * Removes an event listener from the component's HTML node.
  * @param {string} event - The event type for which to remove the listener.
  * @param {EventListener} listener - The listener function to be removed.
  * @param {boolean|EventListenerOptions} [options=false] - Options that were used when adding the listener.
  */
 removeListener(event, listener, options = false) {
  this.#node.removeEventListener(event, listener, options);
 }

 /**
  * Destroys all child components associated with the current component.
  */
 destroyChildren() {
  this.#children.forEach(child => {
   child.destroy();
  });
  this.#children.length = 0;
 }

 /**
  * Destroys the current component and removes its HTML node from the DOM.
  */
 destroy() {
  this.destroyChildren();
  this.#node.remove();
 }
}

The state of the class is the created element and the list of child elements, which can be managed using the class methods. It is worth noting that if Javascript had a protected access modifier, it would be conceptually necessary to use it, since all components will inherit from the Component class and it is important for them to have direct access to their own element.

The Component class provides encapsulation and abstraction from the details of the DOM implementation, it is this class that should implement the DOM methods. To extend the functionality of the component, we will use class inheritance instead of function composition. Let's implement the button component:

class Button extends Component {
 constructor({ className, text, onClick }) {
  super({ tag: 'button', className, text });
  if (onClick) {
   this.onClick = onClick;
   this.addListener('click', this.onClick);
  }
 }

 destroy() {
  this.removeListener('click', this.onClick);
  super.destroy();
 }
}

The button is created to be clicked by the user, so the button component constructor accepts a click event handler. The destroy() method is used to remove an element from the DOM, and it is overridden in the button component class to remove the event listener. Let's recall the example with changing the class of the p element by clicking on the button and rewrite it using components:

// Save a reference to the element we will be modifying
const paragraph = new Component({
 tag: 'p',
 className: 'content',
 text: content,
});

const app = new Component(
 {
  className: 'app',
 },
 new Component({
  tag: 'h1',
  className: 'title',
  text: title,
 }),
 paragraph,
 new Button({
  className: 'btn',
  text: 'Click me!',
  onClick: () => {
   paragraph.toggleClass('content--hidden');
  },
 })
);

document.body.append(app.getNode());

Now, unlike the createElement() solution, we have a set of methods that allow us to conveniently manipulate the DOM without going into the details of browser implementation, and to create components with internal logic that interact by calling each other's methods.

Let's create a menu item using class components that was previously created using the createMenu* functions, while adding a simplified implementation of the active link selection functionality:

class Menu extends Component {
 constructor({ className, items }) {
  super({ tag: 'nav', className });
  this.appendChildren(items);
 }

 toggleActiveItem(item) {
  this.getChildren().forEach(child => {
   if (child === item && !child.isActive) {
    child.addActiveClass();
   } else if (child !== item && child.isActive) {
    child.removeActiveClass();
   }
  });
 }
}

class MenuItem extends Component {
 activeClassName = 'menu__item--active';

 constructor({ className, text, href, onItemClicked = () => {} }) {
  super({ tag: 'li', className });
  this.append(
   new Link({
    className: 'menu__link',
    text,
    href,
    onClick: event => {
     onItemClicked(this);
    },
   })
  );
 }

 get isActive() {
  return this.getNode().classList.contains(this.activeClassName);
 }

 addActiveClass() {
  this.toggleClass(this.activeClassName);
 }

 removeActiveClass() {
  this.toggleClass(this.activeClassName);
 }
}

class Link extends Component {
 constructor({ className, text, href, onClick }) {
  super({ tag: 'a', className, text });
  this.onClick = onClick;
  this.setAttribute('href', href);
  if (onClick) {
   this.onClick = onClick;
   this.addListener('click', this.onClick);
  }
 }

 setHref(href) {
  this.setAttribute('href', href);
 }

 destroy() {
  this.removeListener('click', this.onClick);
  super.destroy();
 }
}

The code is a set of classes for managing user interface components such as menus and menu items. Each component, such as Menu, MenuItem and Link, is a subclass of the base class Component.

  • Menu represents a navigation menu and has a method toggleActiveItem(), which changes the active state of the selected menu item and updates the state of other items accordingly.

  • A MenuItem represents an individual menu item that can be active or inactive. It contains addActiveClass() and removeActiveClass() methods to control the active state.

  • Link represents a hyperlink within a menu item. It contains an setHref() method to change the link address and an onClick() function that is called when the link is clicked.

To embed the application on the page, let's create an App class that will be the entry point.

const menuItems = [
 { text: 'Home', href: '#' },
 { text: 'About', href: '#about' },
 { text: 'Contacts', href: '#contact' },
];

class App {
 menu = null;

 constructor() {
  this.menu = new Menu({
   className: 'nav nav--main',
   items: menuItems.map(item => {
    return new MenuItem({
     className: 'menu__item',
     text: item.text,
     href: item.href,
     onItemClicked: item => {
      this.menu.toggleActiveItem(item);
     },
    });
   }),
  });
 }

 render(root) {
  root.append(this.menu.getNode());
 }
}

Each menu item can interact with the outside world using callback functions (callback functions). For example, when you click on a menu item, you can call the onItemClicked() function, which passes the item itself as a parameter for further actions with it.

This approach allows you to create flexible and reusable components of the user interface, interacting with each other using callbacks to handle events and change states. An alternative to the approach will be the realization of an event system, where some components will be sources of events, and other components will subscribe to these events. Such an interaction scheme is realizable using the patterns Observer, EventEmitter, Event Bus.

For the laziest of the lazy 🦥

Component creation can be shortened even further by creating utility functions by the names of the tags they represent:

const div = (className, ...children) =>
 new Component({ tag: 'div', className }, ...children);
const p = (className, text) => new Component({ tag: 'p', className, text });

const h1 = (className, text) => new Component({ tag: 'h1', className, text });

const button = (className, text, onClick) =>
 new Button({ className, text, onClick });

And so on... Then a more realistic example with menu creation could look like this:

function createMenu(menuItems) {
 return nav(
  ['nav', 'nav--main'],
  ul(
   ['menu', 'menu--horizontal'],
   ...menuItems.map(item =>
    li(['menu__item'], a(['menu__link'], item.text, item.href))
   )
  )
 );
}

The button example will be rewritten in a couple lines:

const paragraph = p('paragraph', content)
const app = div('app', h1('title', title), paragraph, button('btn', 'Click me!', () => {
  paragraph.toggleClass('content--hidden')
})
document.body.append(app.getNode())

Thanks for your attention!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment