Skip to content

Loading External JavaScript

This page provides guides for how best to load external JavaScript with performance in mind. This includes techniques for achieving this, such as deferring script loading and using facades, as well how to handle for Alpine components.

Overview

The problem with loading external JavaScript files is that it usually has a negative impact on Google page ranking metrics, such as PageSpeed Insights.

This is generally because scripts block the rendering process, which slows down the overall and perceived visual page load. For this reason it is a good idea to defer loading external libraries until they are actually required.

Depending on the script/library, it may be best to defer loading until one of the below events occur:

  1. The user interacts with page (anywhere on the page)
  2. The user interacts with a particular part of the page (for example, clicks on a video 'play' button, or opens a modal)
  3. The user scrolls down to a certain part of the page (example given, where the script/library is used on page)

Example: programmatically loading a script

Before we look at further techniques, it's common for external libraries vendors to request you add an external script request to the <head>, such as:

Do not use this, it will lower the page rank metrics

<script type="text/javascript" src="https://www.example.com/render-blocking-script.js"/>

Instead, load the script programmatically when it is needed.
To achieve this, follow the outline below:

const script = document.createElement('script')
script.src = 'https://www.example.com/render-blocking-script.js';
script.type = 'text/javascript';
document.head.append(script);

Be sure to properly escape dynamically generated URL strings

This is a basic example. When working with programmatically generated Magento URLs, they should always be escaped using $escaper->escapeUrl() inside .phtml template files if they are used as a HTML attribute value, or $escaper->escapeJs() if they are used in a JavaScript string.

Deferring scripts until a user interacts with the page

Many external scripts have nothing to with the user interface (UI) or experience (UX), yet because they block the rendering of the page, they impact performance. This affects the time it takes until the user can interact with the page and the perceived visual page load.

This applies to commonly used marketing and analytical tools, such as (but not limited to):

  • Google Analytics (and alternative solutions)
  • Google Tag Manager (and alternative solutions)
  • Bing Universal Event Tracking
  • Tracking Pixels (e.g. Meta/Facebook)
  • Digital marketing/email campaign tracking scripts (e.g. Mailchimp)
  • User monitoring scripts (e.g. HotJar)

Info

Live Chat solutions can also be included here, but as they do generally impact the UI, there may be occasions where a facade is a better solution (see below).

Instead of loading the external scripts on page load, it is often better to defer these until the user interacts with the page.
This is essentially any touch, mouse or keyboard interaction, which you can listen for using native JavaScript event listeners.
A further, unintended benefit to this approach is it helps filter out bots from your analytics, given they don't make the same interactions as a human user.

Example: deferring scripts until any user interaction with init-external-scripts

The init-external-scripts event is triggered when any of the standard interaction events (touchstart, mouseover, wheel, scroll, keydown) are fired, and it also handles the cleanup, so the event is only fired once.

// The below shows an example of loading Google Tag Manager
// However, you could load/run any external script you need here
window.addEventListener('init-external-scripts', () => {
  // Load Google Tag Manager (where `GTM-XXXXXX` is your container ID)
  (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
      new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
  })(window,document,'script','dataLayer','GTM-XXXXXX');
}, {once: true, passive: true});

Given many use cases for these scripts is analytical, on the order success page, the event is fired on page load, rather than waiting for interaction.
This is to ensure conversion data is always tracked (even though it's almost impossible to not interact with the page, including if you are closing the tab/window).

The init-external-scripts event is only available in Hyvä Themes versions 1.1.20 and 1.2.0 and above

For compatibility with earlier Hyvä versions, add the callback to any user interaction event directly:

(events => {
  const loadMyLibrary = () => {
    events.forEach(type => window.removeEventListener(type, loadMyLibrary))
    // Load the library programatically here
  };
  events.forEach(type => window.addEventListener(type, loadMyLibrary, {once: true, passive: true}))
})(['touchstart', 'mouseover', 'wheel', 'scroll', 'keydown'])

Example: deferring scripts until a specific user interaction

The following example will load a script when a form input is focused.
Other user interactions won't trigger the library to load.

<form x-data="initMyForm($el)" x-init="init()">
    <input type="text" name="example">
    <!-- other fields -->
</form>

<script>

    function initMyForm(form)
    {
      // Function to load external script. Return promise to be able to take action when loaded
      function load() {
        return new Promise(resolve => {
            const script = document.createElement('script');
            script.type = 'text/javascript';
            script.src = '<?= $escaper->escapeJs($block->getViewFileUrl('Example_Module::js/library.js')) ?>';
            script.async = true;
            script.onload = resolve;
            document.head.appendChild(script);
        })
      }

      return {
        init() {
          const inputs = Array.from(form.elements);

          const initExternalScript = () => {
            // Remove event listener from all fields, so it is loaded once only
            inputs.forEach(input => {
              input.removeEventListener('focus', initExternalScript)
            });

            // Load the external library and then call a method that does something with it
            load().then(() => this.doSomethingWithTheLibrary());
          };

          // Add onfocus event listener to every form element
          inputs.forEach(input => {
            input.addEventListener('focus', initExternalScript, {once: true, passive: true})
          })
        },
        doSomethingWithTheLibrary() {...}
      }
    }
</script>

The above code works. However, often scripts need to be loaded on any visitor interaction.
To simplify and standardise this process, Hyvä has its own custom init-external-scripts event you can listen to.

Facade Approach

In some cases external scripts may not only hamper performance on page load, but also impact the UI/UX of the page and can't be loaded after user interaction, because then some elements on the page may be missing.
And adding them later would cause layout shifts.

In these cases, it's still better to load/run the external scripts on user interaction, either on the entire page (i.e., any interaction), or on a specific element.
However, a facade should be created that takes up the same amount of space on the page to avoid shifts. In addition, it could also be styled to look visually similar.

The facade is displayed on page load and then swapped out with the 'real' version following interaction.

Examples of external resources that can benefit from a facade approach include:

  • Live chat widgets
  • Search providers
  • Videos (e.g., YouTube, Vimeo)

Example: live chat facade

Most live chat solutions have a button/icon/link that triggers opening the live chat.
However, this button is often rendered after the external script has loaded and run. This in itself can cause layout shifts.

Therefore, if you want to defer loading/running of the live chat script, you need to recreate the button, which the user can then see and interact with (which will then trigger the external script loading).

<button data-role="init-live-chat"
        class="btn btn-primary /* add styles here to reserve space, recreate button display and avoid layout shifts */">
    <?= $escaper->escapeHtml(__('Click to chat!')) ?>
</button>

<script>
    const liveChatButton = document.querySelector('[data-role="init-live-chat"]');
    liveChatButton.addEventListener('click', () => {
        // Implement live chat 
        // This may be a script include or embed code, depending on the vendor

        // Programmatically trigger 'click' of actual live chat button to open panel/window (but ensure live chat library has loaded first)
        liveChatButton.click();
    }, {once: true});
</script>

Info

The button class/ID/data-* (or other attribute used) and its placement within the DOM must match that of your live chat providers so that it is replaced when loading the external script.
In addition, as shown in the example, you will likely need to trigger the click of the real button programmatically, in order to stop the user needing to double-click/tap.

How to use example code

Be aware the above code is only an educational example.
You may want to change the events that trigger the library to load to suit your requirements.

Browser Cache and multiple Tabs

The above examples do not take into account that scripts usually are cached by the browsers HTTP cache once they have been loaded.
If you want to load cached scripts automatically after the page completes, you can set a flag in local storage and trigger the script directly on page load if the flag is set.
This can also be used to execute external scripts if multiple tabs of your site are open in a browser.
However, be aware that the HTTP request overhead is not the only thing impacting core web vitals.
If a script is large, even executing cached scripts on page load can have a severe impact, so if in doubt, try to avoid setting a flag in local storage to trigger direct execution, or take care the trigger flag will only be evaluated when init-external-scripts is dispatched.

Videos

For videos, more details and recommended libraries for creating facades can be found on Chrome Developers.

For a Magento specific, and Hyvä supported, implementation for YouTube, MageQuest have published a small open source module that adds support for use within Page Builder (or manually): magequest/magento2-module-lite-youtube.

Loading external scripts on scroll

Lazy loading external scripts on scroll is useful for areas on the page that are 'below the fold' and aren't required unless the user actually scrolls down the page to them.

Examples of external scripts this technique is useful for are generally related to embedded content, such as:

  • Customer reviews/testimonials sections (e.g. carousels of customer reviews)
  • Social media and user generated content (UGC) feeds (e.g. Instagram feed carousel)
  • Google Maps (and alternative solutions)

Again, these types of embeds come at a high cost on page load and there is likely a good chance the user will never even scroll down to them before navigating elsewhere.

In order to achieve this, you can use th Alpine x-intersect plugin.

If you need to use vanilla JavaScript, Intersection Observer API, which is great for determining when elements are being scrolled into view

Alpine Components

To load an external JS library that provides an alpine component, the main obstacle is the calling of the initialization methods for x-data and x-init only when the external library has loaded.

In Alpine v2 this can be accomplished with the help of Alpine.initializeComponent(target).
In Alpine v3, the corresponding code is Alpine.initTree(target)

Example: loading an Alpine component from vanilla JS

Here is an example using the Alpine Product 360 library.

<script>
    function initExternalAlpineComponent() {
        const script = document.createElement('script');
        script.type = 'module';
        script.src = 'https://cdn.jsdelivr.net/gh/moveideas/alpine-product-360@1.1.1/dist/index.min.js';

        script.onload = () => {
            const target = document.getElementById('target');
            const url1 = 'https://picsum.photos/300/350?id=1',
                  url2 = 'https://picsum.photos/300/350?id=2';
            // because the library is a JavaScript module,
            // we need to call alpineCarousel.default() 
            target.setAttribute('x-data', `alpineCarousel.default(['${url1}', '${url2}'], {infinite: true})`);
            target.setAttribute('x-init', 'start()');

            Alpine.initializeComponent
                ? Alpine.initializeComponent(target) // Alpine v2
                : Alpine.initTree(target); // Alpine v3
        };

        document.head.appendChild(script);
    }
</script>
<div>
    <h2>Lazy Initialization of an Alpine Component</h2>

    <button type="button" class="btn"
            onclick="initExternalAlpineComponent()">Load Alpine Component</button>
    <div id="target">
        <img
            :src="carousel.currentPath"
            @mouseup="handleMouseUp"
            @mousedown="handleMouseDown"
            @mousemove="handleMouseMove"
            @mouseleave="handleMouseLeave"
            draggable="false"
        />
    </div>
</div>

Nested Alpine Component

Chances are the new library should be initialized as a nested component.
This can lead to issues because the Alpine.js markup for the nested component is not meant to be evaluated in the scope of the outer component.

The nested component DOM needs to be removed from evaluation, until the new nested component is initialized and ready.

There are many ways to accomplish this, but one way I like using is to hide the nested component HTML from the parent scope until the nested component is loaded, by placing it in a <script type="text/html"> element.
When the external library is loaded, the script tag innerHTML is then copied into the target component about to be initialized.

Example: loading a nested Alpine component

Here is the same example as above, except that this time it is initialized as a nested component.

<script>
    function initialComponent() {
        return {
            initProduct360() {
                const script = document.createElement('script');
                script.type = 'module';

                script.addEventListener('load', () => {
                    const target = document.getElementById('target');
                    const url1 = 'https://picsum.photos/300/350?id=1',
                          url2 = 'https://picsum.photos/300/350?id=2';
                    // because the library is a JavaScript module,
                    // we need to call alpineCarousel.default()
                    target.setAttribute('x-data', `alpineCarousel.default(['${url1}', '${url2}'], {infinite: true})`);
                    target.setAttribute('x-init', 'start()');

                    // inject the nested component into the DOM
                    target.innerHTML = document.getElementById('target-content').innerHTML;

                    Alpine.initializeComponent 
                      ? Alpine.initializeComponent(target) // Alpine v2
                      : Alpine.initTree(target); // Alpine v3
                });
                script.src = 'https://cdn.jsdelivr.net/gh/moveideas/alpine-product-360@1.1.1/dist/index.min.js';
                document.head.appendChild(script);
            }
        }
    }
</script>
<div x-data="initialComponent()">
    <h2>Lazy Initialization of a nested Alpine Component</h2>

    <button type="button" class="btn" @click="initProduct360()">Load Alpine Component</button>
    <div id="target"></div>
    <!-- hide the component from the browser -->
    <script type="text/html" id="target-content">
        <img
            :src="carousel.currentPath"
            @mouseup="handleMouseUp"
            @mousedown="handleMouseDown"
            @mousemove="handleMouseMove"
            @mouseleave="handleMouseLeave"
            draggable="false"
        />
    </script>
</div>