While a browser receives an HTML source file, it starts to parse it piece by piece to construct an object representation of the document called the DOM (Document Object Model) tree. Simultaneously, the DOM is being converted into a render tree, which represents what eventually is being painted. The document can start being rendered and painted in the browser before it is fully loaded. Unless it is blocked by CSS or JavaScript.
When the parser comes across CSS code, the rendering process is being blocked until the CSS is fully parsed. Similar as before, the browser now constructs a CSSOM (CSS Object Model) tree which associates the styles to each node. After parsing the CSS, a combination of the DOM and CSSOM are being used for continuing creating the render tree.
Even though the rendering process can be blocked by CSS, the DOM is still being parsed. Unless it comes across JavaScript. When the parser reaches a <script>
tag, the parsing stops, and the script is being executed.
This is the reason that a JavaScript file needs to be placed after the appearance of a referenced element in the script.
Previously, it has been best practice to always include the <script>
tags at the end of the document to make sure that all the elements are available and to not block the rendering and therefore painting process of the browser.
Nowadays, there are other options to avoid that JavaScript is being parser blocking. For example:
<script async src="script.js"> // Script is executed asynchronously, while the page continues parsing
<script defer src="script.js"> // Script is executed when the page has finished parsing
This image summarizes the many different approaches to load JavaScript code. More information can be found in the HTML5 specification
Basically, the DOM is an interface for HTML (and XML) documents which represents the page. It is dynamic, and the browsers provide an API to read and change the content, structure, and style of the document via JavaScript. This allows for changing parts of the website without the need of a refresh and therefore a repaint of the whole page.
JavaScript has access to a global object. In a browser, window
is the global object and represents the window/tab of the browser in which the script is running. One of its property is window.document
which serves as an entry point to the parsed DOM tree.
Because
window
is the global object, there is no need to reference its properties (e.g.document
) viawindow
. The property name can be used directly as the script will figure out the global object at runtime.
The document
interface can now be used to manipulate the DOM.
As an example let’s create an HTML file:
<!DOCTYPE html>
<html>
<head>
<title>My first web page</title>
</head>
<body>
<h1>Hello, world!</h1>
<p>How are you?</p>
</body>
</html>
The browser will parse this file to the following DOM:
<img alt=’DOM’ src=’./assets/img/dom.png’ width=300px>
This DOM can now be manipulated via the document
interface in JavaScript:
const $h1 = document.querySelector('h1'); // Accessing the <h1> element
console.log($h1.textContent); // Reading: Hello, world!
$h1.textContent = 'Hello, Puerro!'; // Manipulating
const $button = document.createElement('button'); // Creating new Element
document.body.appendChild($button); // Changing DOM structure
There are three different ways to access the content of a DOM element.
element.textContent
: represents the text content of a node as it is in the DOM. Therefore, it doesn’t include the HTML tags but keeps the content of non-visible elements. For example, the content of <script>
or <style>
tags.element.innerText
: similar to textContent
but uses CSS knowledge and only returns visible content. This has the disadvantages that reading a value with innerText
triggers a reflow to ensure up-to-date computed styles. This can be computationally expensive and should be avoided when possible.element.innerHTML
: represents the HTML source of the element. It should only be used when the intention is to work with HTML markup. Misusing it for text is not optimal for performance and it is vulnerable to XSS attacks.The element.innerHTML
method allows to build up a nested structure using the HTML markup language relatively easy.
document.body.innerHTML = `
<input type="number" />
<button type="button" onclick="console.log('Clicked')">Do it!</button>
`;
This gets slightly more challenging for appending or modifying nodes to an already existing DOM as all its child elements are being re-parsed and recreated completely. This means that saved references to nodes are no longer pointing to the supposed elements.
Re-parsing the whole structure of the element is also bad for the performance.
document.body.innerHTML = '<h1>Tomato</h1>';
const $h1 = document.querySelector('h1');
document.body.innerHTML += '<p>Lean as a Leek</p>'; // Whole body element is being reparsed
$h1.textContent = 'Puerro'; // Reference does not point to the DOM instance
There is a solution for this called element.insertAdjacentHTML
which does not re-parse all its child elements.
document.body.innerHTML = '<h1>Tomato</h1>';
const $h1 = document.querySelector('h1');
document.body.insertAdjacentHTML('beforeend', '<p>Lean as a Leek</p>'); // No complete reparsing
$h1.textContent = 'Puerro'; // Reference still works
This combination between innerHTML
and references is not very readable. Plus, when dealing with registering event listeners as well, it can get complicated. If a reference to a created element should exist at a later time, it is advisable to use the document.createElement
method.
const $input = document.createElement('input');
$input.setAttribute('type', 'number');
$input.setAttribute('value', 1);
const $button = document.createElement('button');
$button.setAttribute('type', 'button');
$button.textContent = 'Go';
$button.addEventListener('click', _ => console.log($input.value));
document.body.append($input, $button);
Puerro provides an abstraction to make it more convenient to create elements.
const $input = createDomElement('input', { type: 'number', value: 1 });
const $button = createDomElement('button', { type: 'button', click: _ => console.log($input.value) }, 'Go');
document.body.append($input, $button);
After the HTML has been parsed, rendered and painted, the browser is usually waiting for user interactions. For this to work the browser uses an event-driven programming model to notify the JavaScript code about what’s happening on the page.
There are a lot of different events. For example, when the DOM is finished with loading, clicking elements, typing on the keyboard, scrolling and many more.
In order react to an event, Event Handlers
are used. Event handlers are functions which are being called from the browser when an event occurs.
When an event is fired, the first parameter an handler receives is an Event
object which contains useful information and methods. The most used are:
target
: A reference to the target to which the event was originally dispatched.type
: The name of the event.stopPropagation()
: Stops the propagation of events further along in the DOM.preventDefault()
: Cancels the event.To register an event there are three possibilities.
The most legacy but direct way is to register event handlers directly in the HTML markup.
<button onclick="console.log('Button clicked');"></button>
But especially for larger projects this is considered a bad practice as it is hard to read and maintain because it doesn’t separate the view from the actions. It also requires that the functions are exposed globally, which is pollution to the global namespace.
A better way is to register the event handlers in the JavaScript code. It is similar to the inline event handlers, but it respects the separation of concerns and the scope is more controllable.
$button.onclick = event => console.log('Button clicked');
A drawback with this approach is that it is only possible to assign one listener to each event.
addEventListener()
The most modern approach is to use the element.addEventListener()
function. It allows to register as many event handlers as needed.
$button.addEventListener('click', event => console.log('Button clicked'));
With this approach it is also possible to remove listeners with the element.removeEventListener()
function.
Another advantage is the ability to choose between event bubbling and capturing.
When nodes are nested, a user interaction can trigger multiple events. Two different models exist to handle this:
With the following example, the events bubble. Meaning they are propagated upwards.
<body>
<div>
<button>Click Me!</button>
</div>
<script>
const $div = document.querySelector('div');
const $button = document.querySelector('button');
$div .addEventListener('click', _ => console.log('DIV clicked'));
$button.addEventListener('click', _ => console.log('BUTTON clicked'));
// Console Output:
// BUTTON clicked
// DIV clicked
</script>
</body>
To make sure that the DIV
event listeners triggers first, the methods useCapture
parameter needs to be true.
All the event handlers with useCapture
enabled run first (top down), afterwards the bubbling handlers (bottom up).
$div.addEventListener('click', _ => console.log('DIV clicked'), true);
To complete stop the propagation, the handler can call the stopPropagation()
method on the event object.
$button.addEventListener('click', event => {
console.log('BUTTON clicked');
event.stopPropagation();
});
Building forms is a widely used pattern for web applications. HTML provides a <form>
tag, which allows to group interactive controls together for submitting data to a server. When a form is being submitted, an HTTP Request with the specified method is sent to the specified resource. With this traditional approach, the page always will be refreshed and new rendered based on the response.
This approach is acceptable if we want to display a completely different view after submitting the form. However, for modern web application this is usually not desirable. Instead, it is better to use an Ajax request in the background without affecting the page and to manipulate the DOM based on the response.
Nevertheless, using the <form>
tag has many advantages and should still be used for grouping interactive controls:
In order to use the <form>
tag without it being submitted, an event handler has to be registered for the form’s submit event. In this handler the method event.preventDefault()
can be executed to prevent the form from submitting.
<body>
<form>
<input name="name" />
<input name="age" type="number" />
<button>Submit</button> <!-- Submits Form -->
<button type="reset">Reset</button> <!-- Resets Form -->
</form>
<script>
const $form = document.querySelector('form');
$form.onsubmit = event => {
// Proccess form elements at will (e.g. Ajax Request, Validation, DOM manipulation)
console.log(event.target.name.value); // Easy access on name value
console.log(event.target.age.value); // Easy access on age value
event.preventDefault(); // Prevent form submitting
};
</script>
</body>
A button can have 3 different types:
submit
,reset
andbutton
. The default type issubmit
which will attempt to submit form data when clicked. When the intention is to use a button without a default behaviour, explicitly specifytype="button"
.
A
<form>
can also be submitted by pressing enter or via JavaScript. Therefore, using a<button type="button">
with a click event handler won’t be enough. Plus receiving the target form in the event is a huge benefit.
When event handler functions receive events, they can in turn manipulate the DOM.
For a handler function to manipulate the DOM, references to the elements which have to be manipulated are needed. Those references can either be already available in the surrounding scope or can be created in the function itself.
const handleEvent = event => {
const $element = document.querySelector('div');
// manipulate $element
};
$element.addEventListener('click', handleEvent);
With the help of eta reduction, the parameter can be removed when there is only one argument or when using curried functions.
x => foo(x)
can be shortened tofoo
This gets problematic when the intention is to test this unit, since the DOM might not be available. Furthermore, this approach can quickly become difficult to maintain.
A better approach for a simplified testability is to receive the nodes which are being manipulated as a parameter.
The element which fires the event does not have to be passed as an argument because it is available through event.target
.
When new elements are being created, it is a good practice to return them for a more convenient testing.
import { createDomElement } from 'puerro';
export { appendInput, changeLabel };
const appendInput = ($input, $output) => _ => {
const $element = createDomElement('p', {}, $input.value);
$output.append($element);
return $element; // return for testing purposes
};
const changeLabel = $button => event => {
$button.textContent = 'Save: ' + event.target.value;
};
To use the handler functions, they simply have to be registered with the needed references as arguments.
import { changeLabel, appendInput } from './example';
const $input = document.querySelector('input');
const $button = document.querySelector('button');
const $output = document.querySelector('output');
$button.addEventListener('click', appendInput($input, $output));
$input .addEventListener('input', changeLabel($button));
To use the handler functions for testing, the needed elements need to be created.
import { describe, createDomElement } from 'puerro';
import { appendInput, changeLabel } from './example';
describe('Testable Units', test => {
test('appendInput', assert => {
// given
const $input = createDomElement('input', { value: 'Puerro' });
const $output = createDomElement('div');
// when
const $element = appendInput($input, $output)(); // event object not needed
// then
assert.is($output.children.length, 1);
assert.is($element.tagName, 'P');
assert.is($element.textContent, 'Puerro');
});
test('changeLabel', assert => {
// given
const $input = createDomElement('input', { value: 'Puerro' });
const $button = createDomElement('button', { type: 'button' });
// when
changeLabel($button)({ target: $input }); // mocking event object
// then
assert.is($button.textContent, 'Save: Puerro');
});
});
The above example can be found in the Puerro Examples.
This style of programming with direct DOM manipulations within the event handler functions is easy and intuitive. It can be used for various tasks:
This approach is getting harder to maintain when either frontend state is being introduced or there are many changing elements. This is especially true when the application starts growing.
When an event triggers a lot of changes, a reference to each dependent element needs to be managed. If multiple elements need to be updated constantly and in short time frames, it can start to become expensive to constantly query and update the DOM. Furthermore, when updates depend on data stored in the DOM, the decapsulation between view and model is not given.
For large application there probably will be redundant code and all DOM related accesses are scattered through the code.
← Introduction | Virtual DOM → |