Today, I want to discuss an interesting concept that leverages JavaScript even before the actual framework loads.
Imagine this scenario: you’re developing an e-commerce shop that caters to both B2B and B2C customers. The customer can toggle between these options in your shop, and their choice is stored locally. Now, you want to ensure that the correct prices are displayed when they search for products.
customer.type === 'b2b' ? price.b2b : price.b2c
LGTM - Done.
Of course, we can easily write a condition in our component from our chosen framework to display the price. But what are the implications of this approach?
The price will only be available after our framework has been completely downloaded and loaded. What consequences might this have?
- Delayed Rendering and Flicker Effect: Since the price calculation occurs only after the framework loads, there can be a noticeable delay before the correct price is displayed. This can lead to an undesirable layout shift and cause a flicker effect, where users briefly see the wrong price before the correct version appears. Both impair the user experience and can lead to confusion.
- Framework Dependency: The logic is tightly coupled with the framework, meaning the price display only functions once the entire framework is loaded and initialized. This can extend the Time to Interactive (TTI).
- Lack of Preloading: The implementation doesn’t exploit the opportunity to determine and apply the customer type before the framework loads, missing a chance for optimization.
- Redundant Execution: In this case, the price logic executes separately for each product, which can unnecessarily burden the JavaScript engine when handling many products.
- SEO: As prices are dynamically generated, search engines might have difficulty indexing the content.
Okay. We need a way to place and execute our price selection logic before the framework is initialized. But what does it actually mean for the framework to be initialized?
Let’s take a quick detour to understand the initialization process of JavaScript frameworks. Let’s take React as an example, although similar principles apply to other frameworks. The bootstrapping process of a typical React application involves several critical phases:
- Initially, the React core library is loaded. This includes the basic reconciliation algorithms and the Virtual DOM
- The application code, including all defined components, is parsed and loaded into memory
- React constructs its initial virtual DOM tree based on the component hierarchy
- Finally, the components are rendered, and the real DOM is updated
This process, although highly optimized, can take some time depending on application complexity and network conditions. Techniques like code splitting are used by bundlers and frameworks to optimize this process. But that’s not the topic here.
We need to get ahead of the fourth point, before the components are rendered.
Possible Solution?
Okay, let’s assume we have our prices statically available in our HTML. Both prices are available and need to be displayed according to the user’s choice.
<div>
<span class="b2b">10</span>
<span class="b2c">12</span>
</div>
The user has already made their selection during a previous visit, whether they want to see B2B or B2C prices.
const customerType = localStorage.getItem('customerType');
Let’s jump to the point where our framework is supposed to connect with our HTML page.
<head>
<script>
(function() {
// executed first
})();
</script>
</head>
<body>
<h1>Hello</h1>
<script src="framework.js"></script>
</body>
How does the browser execute this? It runs our code from top to bottom. For this reason, our IIFE is first in line. Then the HTML parsing is completed. Lastly, our external script, the framework, is loaded and executed. So to interact with our code, we need to set in before the framework is initialized.
<head>
<script>
const customerType = localStorage.getItem('customerType');
if (customerType === 'B2B') {
document.querySelectorAll('.b2b').forEach(function (el) {
el.style.display = 'inline';
});
} else if (customerType === 'B2C') {
document.querySelectorAll('.b2c').forEach(function (el) {
el.style.display = 'inline';
});
}
</script>
</head>
<body>
<h1>Hello</h1>
<script src="framework.js"></script>
</body>
Okay, if our script in the header is executed before the body, we can simply execute our functionality in the header and we’ve achieved our goal. Unfortunately, this isn’t possible because, as mentioned earlier, the parsing of the HTML only takes place after the execution of our script. What exactly does this parsing mean?
- The browser reads the HTML code line by line from top to bottom
- The text is broken down into individual units (tokens), such as tags, attributes, and text contents
- DOM creation: A tree structure is created from these tokens - the Document Object Model (DOM). Each HTML element becomes a node in this tree.
- Parallel to the DOM, a render structure is built, which determines how the page is visually presented.
- When the parser encounters a script tag, the execution of the script is started. This can briefly interrupt the parsing process.
- The browser also loads external resources such as images, stylesheets, and scripts.
- After the entire HTML code has been parsed, the DOM is fully constructed.
Okay, so we need a way to load our script after the browser has loaded our document. Let’s look at the DOMContentLoaded listener.
<head>
<script>
document.addEventListener("DOMContentLoaded", function () {
const customerType = localStorage.getItem('customerType');
if (customerType === 'B2B') {
document.querySelectorAll('.b2b').forEach(function (el) {
el.style.display = 'inline';
});
} else if (customerType === 'B2C') {
document.querySelectorAll('.b2c').forEach(function (el) {
el.style.display = 'inline';
});
}
});
</script>
</head>
<body>
<h1>Hello</h1>
<script src="framework.js"></script>
</body>
The
DOMContentLoaded
event fires when the HTML document has been completely parsed, and all deferred scripts and have downloaded and executed. It doesn’t wait for other things like images, subframes, and async scripts to finish loading.
This means our framework would definitely be loaded before the listener is executed.
- The framework script has been loaded and executed, but the complete initialization of the framework may not be finished yet.
- There’s a small “timing window” between the triggering of
DOMContentLoaded
and the completion of framework initialization. - Our code, which reacts to
DOMContentLoaded
, is executed in this timing window. It runs after the framework script has loaded and executed, but before the framework has completed its full initialization and DOM manipulation.
<head>
<script>
document.addEventListener("DOMContentLoaded", function () {
console.log('Firing DOMContentLoaded')
});
</script>
</head>
<body>
<script src="framework.js"></script>
</body>
// framework.js
console.log("Loading Framework");
setTimeout(function () {
console.log("Init Framework");
}, 1000);
If we look at this in a simplified way and assume that our framework takes a second to initialize or possibly makes asynchronous calls or similar, a small time window opens in which we can execute our price logic.
// Loading Framework
// Firing DOMContentLoaded
// Init Framework
Alternative approach: The readystatechange
event
As a possible alternative to the DOMContentLoaded
listener, we can mention the readyStateChange Event. This is an event that is triggered during the loading process of the page and offers the possibility to react even earlier.
document.addEventListener('readyStateChange', function() {
if (
document.readyState === 'interactive' ||
document.readyState === 'complete')
) {...}
});
The readystatechange
has different states. interactive
occurs when the HTML document has been fully loaded and parsed. At this point, the DOM is built and accessible. External resources such as images, stylesheets, or frames might not be fully loaded yet. complete
is reached when the entire document and all dependent resources are fully loaded.
It makes sense to have both states in the if-statement. By including interactive
, the code can potentially be executed earlier without having to wait for all resources to be fully loaded. This is exactly what we’re aiming for. Adding complete
is still a safeguard, in case for some reason interactive
was skipped or not correctly recognized.
Can Race Conditions Occur?
Yes. When using DOMContentLoaded
or readystatechange
events, there is theoretically the possibility of race conditions. These can occur when our code and the framework initialization simultaneously access or manipulate the same DOM elements. Depending on the use case, it makes sense to think about whether a dedicated strategy for possibly handling these race conditions makes sense.
What about defer?
Defer is an attribute in HTML that is often used by frameworks to allow delayed execution of the script until after the HTML document has been fully loaded.
<script src="framework.js" defer></script>
The script is downloaded in parallel with parsing the HTML document without blocking the loading process. If multiple scripts with defer
are used, they are executed in the order in which they appear in the document.
However, defer
only works with scripts that have a src
attribute, not with inline scripts.
For our use case, where we want to execute code before framework initialization, defer
alone is not sufficient. While it ensures that the script is executed after parsing (if it’s not a local script), it doesn’t provide control over the exact timing of execution in relation to framework initialization.