Moving from AMD to Browser modules in Liferay DXP

This article explains how to leverage the new ECMAScript modules feature (from now on will be referred to as ESM), Custom Element and Import Map Entry client extensions in Liferay DXP to achieve the same goals possible in the AMD architecture (and more).

The motivation for this article is that, with the new ESM/client extensions architecture, the things we were used to doing with the help from the liferay-npm-bundler have to be done in a slightly different way.

However things are much simpler and powerful than before because we base everything on standards  🎉.

 

Expected audience

This article assumes that you are familiar with client extensions in DXP and have deployed them by hand and/or using the Liferay Workspace Gradle plugin (which is the preferred way).

For current users of liferay-npm-bundler, it also explains how to move from the old AMD to the new ES/client extensions paradigm, which is way more powerful. Don’t worry if you have never used liferay-npm-bundler, as you can skip the parts associated with it and still learn some cool things about how to deal with JavaScript code in the ES/client extensions architecture.

 

Things we could do with liferay-npm-bundler (AMD)

 

Pure JavaScript widgets

The @liferay/cli tool from JS Toolkit lets us create pure JavaScript widgets based on React, Angular and Vue.js frameworks.

These widgets work as regular Java widgets and can be added to pages too, but are completely written in JavaScript. Because of that peculiarity, they have no access to the full Portlet Specification, as in Java, so they can only leverage a limited feature set:

These widgets need to be built with liferay-npm-bundler and require a bit of transformation at build time to convert all ES code into AMD code. This step is similar to what Webpack and similar bundlers do but focused on DXP’s AMD platform.

 

Adapted widgets

Another option for deploying widgets is to adapt a native React, Angular or Vue.js project to make it deployable to Liferay DXP. In this case there are even more limitations because the project needs to be built by the native framework first so that the JS Toolkit carries on and transforms the output to make it an AMD module that complies with the standard entry point interface.

Nevertheless, the adaptation process is a helper when all the user needs is to bundle a project and make it work inside a DXP widget (as opposed to a full blown single page application).

 

Shared bundles (a.k.a. “bundler imports”)

DXP’s AMD architecture allows a special type of bundle to be deployed (after being built with liferay-npm-bundler) to make shared code available to multiple widgets. For example, if your widgets are all based in Angular, you can build a shared bundle that contains Angular, then reuse that copy of Angular from all your portlets.

That has the benefits:

  1. Less memory footprint because each widget does not need to bring its own copy of Angular and load in in the JavaScript engine.

  2. Less network consumption because you download the bulk of the code (Angular) just once, instead of one per widget (which would be the case if you adapted them, for example).

  3. Faster build times because Angular takes a lot to build for liferay-npm-bundler and, deploying it as a shared bundle, you just build it once and, since it is immutable, you don’t need to build it after every change to your projects.

In the case of React, projects can even reuse the copy of React that DXP uses under the hood, making your project’s footprint and build times even smaller.

Even though the shared feature bundle was never thoroughly documented because it was a complex advanced feature, it has been used by some with great success, not only for consuming React from DXP (which was an OOTB transparent feature) but also to share code between widgets.

 

Recipes to move from AMD to ESM

All the things we could do in AMD can be done in ESM with different techniques. However, both models are similar enough to make it possible to migrate AMD stuff to ESM with just a few changes. Let’s see how we can achieve the same old AMD features in the ESM paradigm.

 

Pure JavaScript widgets

The best way to implement widgets in the ESM architecture is to use Custom Element inside the Client Extensions menu option:

 

 

 

The standard custom element specification is leveraged to implement a pure JavaScript widget that is rendered by placing the custom element tag inside the widget.

Note that, as opposed to what was needed in AMD, there’s no need to use DXP’s standard entry point interface any more since custom elements are based on a web standard, rather than a custom DXP one.

Also note that the JavaScript and CSS code for the custom element does not need to be deployed to DXP (although that would be perfectly valid too) and can be hosted even outside of DXP since all the browser needs is to load the correct URL.

Additionally, take into account that declaring a configuration for the portlet (as in the AMD paradigm) is no longer possible, though you can use the Properties field inside the Additional Resources section to declare key=value pairs that are rendered as the custom element tag attributes.

So, for example, this configuration:

 

Would render an HTML like:

 <my-custom-element title=”Something” color=”red”>

 

This way the custom element’s code can access the title and color values to do its duties.

Note that you can also override the values of some or all of the properties per portlet instance using their configuration dialogs.

Finally, as opposed to the AMD paradigm, it’s not possible to localize custom elements using the Liferay.Language.get() API. This is because the code for the custom element can be hosted outside of the DXP installation which makes it impossible to process the JavaScript file to translate the keys.

In any case, we recommend using the standard I18N mechanisms that come with your framework or choice, or even specialized libraries, since the Liferay.Language.get() API is very limited and doesn’t allow for any flexibility when it comes to deploying the keys.

You can see an example of a custom element project at the Liferay Sample Workspace. You can even build and deploy it instead of placing the sample JavaScript and CSS files in a hosting provider and configuring the custom element by hand.

 

Adapted widgets

As in the Pure JavaScript Widget case, you can deploy your old adapted portlets using the Custom Element client extension.

The way to go would be converting your project to a custom element. Usually, frameworks like React, Angular, Vue.js, etc. provide a way to do this since custom elements are a web standard. For example:

You can build your project with the standard framework tools and deploy it as you like, then simply configure it as a client extension so that DXP knows what .js and .css files to include in the HTML.

 

Shared bundles (a.k.a. “bundler imports”)

In the old AMD paradigm the way to share common code between multiple portlets was the bundler import. This technique consisted in creating a Shared bundle project to hold the common code, like this:

Then configuring the bundler imports in the consumer projects using the .npmbundlerrc file like in this target platform definition file which is used whenever you choose Liferay DXP 7.4 as your project’s target platform.

In the new ESM paradigm, sharing code between multiple ES modules is much easier since it can be achieved directly by using an import declaration.

For example, imagine you write a helper function that returns “Hello” in several languages, like this:

function getHello(lang) {
    switch (lang) {
        case 'en':
​​​​​​            return 'Hello';
        case 'es':
            return 'Hola';
        case 'fr':
            return 'Bonjour';
        case 'it':
            return 'Ciao';
        case 'pt':
            return 'Olá';
        default:
            return '???';
    }
}

 

And you want to use it from a custom element implementation, like this:

class CustomElement extends HTMLElement {
    constructor() {
​​​​​​​        super();

        const root = document.createElement('pre');

        root.innerHTML = `

Greetings in:

 

 · English:     ${getHello('en')}

 · French:      ${getHello('fr')}

 · Italian:     ${getHello('it')}

 · Portuguese:  ${getHello('pt')}

 · Spanish:     ${getHello('es')}

`;

        this.attachShadow({mode: 'open'}).appendChild(root);
    }
​​​​​​​}

 

Since you are using the ESM standard, you can simply:

  1. Put your custom element code inside an index.js file.

  2. Put the getHello function implementation inside a my-utils.js file.

  3. Deploy both files to be served from the same base URL (let’s say http://my-hosting.com/js/index.js and http://my-hosting.com/js/my-utils.js).

  4. Change the function declaration to: export default function getHello(lang)

  5. Import the function at the top of the custom element file like this: import getHello from ‘./my-utils.js’;

Et voila, the browser will take care of wiring the two files together and, what’s more, if another custom element declares the same import, the browser will not fetch and parse the file again, but use the module it has already loaded in memory.

Generally, you may structure your application JavaScript files as you wish and use relative imports between them and, as long as the relative development and runtime locations of the files are the same, your IDE and your browser will see the same code structure which makes development far easier than before and, especially, live reloading far more efficient.

In fact, you may even free yourself from the need to have a build step and simply deploy your ES sources to your browser and let them execute, as we used to do in the beginning of the century, when there were no tools like Bower, Npm or Webpack.

Back to the past! 🚀

 

New things we can do in ESM

Thanks to the ESM paradigm, we can do the same things we did before with AMD and more in an easier way, because we don’t need to use complex customized builds. Among the new things we can do are the ones that we describe in the following sections.

 

Shared code imported using a bare specifier

The getHello function example in the previous chapter demonstrates a way to share code between different ES modules but has the limitation that, because the import declarations are using relative URLs to refer to the other modules, the relative location of all JavaScript files must be known in advance at build time. Sometimes that may not be possible, so what can we do?

Import maps to the rescue! 🚀

The import maps standard specification provides a way to map bare specifiers to URLs. A bare specifier is an ES module name that is not an absolute or relative URL, so something like:

import moment from ‘moment’;

 

As you can imagine, the browser doesn’t know how to fetch the JavaScript file associated with “moment”, because it’s not a URL. However, the import map lets us tell the browser where to fetch “moment” from. For example, like this:

{
  "imports": {
    "moment": "https://my-fancy-hosting.com/moment/index.js"
  }
}

 

We have created a client extension to allow for this, it’s called Import Maps Entry, and you can see an example in the Liferay Sample Workspace. The example maps the bare specifier my-utils to the example’s file my-utils/index.js so that the custom element in the example can refer to it using the bare identifier instead of the relative URL.

Of course, you don’t need to restrict yourself to files contained in the examples, you can reference remote URLs outside of the DXP server too if you wish and need it for your system architecture.

 

Pure reusable custom elements

We have seen how to implement a widget with a custom element in the chapter before. However in the Custom Element client extension, the custom element specification is only used as a way to invoke the code that renders the widget.

But what if we want to go further? What if we want to use a custom element in some HTML snippet that is not a full blown widget? There’s an easy solution to this problem that can be generally used for any resource you may need to use on demand.

Let’s say you want to use our Vanilla Counter custom element example, found at https://liferay.github.io/liferay-frontend-projects/vanilla-counter/index.js in a piece of HTML code. What would you do?

Inspecting the Vanilla Counter .js file you can see that it doesn’t have any import or export declarations which may make us think that it cannot be used in the ESM architecture. But that’s not true because, by definition, any JavaScript file can be an ES module. It’s only that, in this case, it doesn’t pull any dependency, nor does it export anything. But if we write this to the HTML page:

<script type=”module”>
import ‘https://liferay.github.io/liferay-frontend-projects/vanilla-counter/index.js’;
</script>

<vanilla-counter/>

 

The script node will make the browser load the file and, because the file registers the custom element with the browser in the last lines, it will be available so that the next <vanilla-counter> node can render it.

This is exactly what the Custom Element client extension does under the hood, but it’s a general technique that you can use in lots of places. The good thing about it is that because the browser will treat https://liferay.github.io/.../vanilla-counter/index.js as an ES module it will only fetch and load the file once no matter how many times you import it from <script> nodes or other ES modules which is a perfect way to make sure that on demand resources are loaded only when needed but no more than once 🎉.

 

Global JavaScript objects on demand (not recommended but possible)

An old technique some people use in DXP to put global objects in the window object is to inject JavaScript code through themes. Although it works it’s not the best way to do it because themes are intended to tweak aspects, not to inject code. So what’s a better way to do it?

Imagine you want to use jQuery in your JavaScript code. One way to accomplish this is to deploy an Import Maps Entry client extension with the bare specifier jquery pointing to, for instance, the jQuery CDN, like this:

{
  "imports": {
    "jquery": "https://code.jquery.com/jquery-3.7.0.js"
  }
}

 

Then add an import declaration like this to every ES module that wants to use jQuery:

import ‘jquery’;

 

Of course using global objects, when you can directly obtain a handle to jQuery from the import declaration itself, is a bit dirty but in cases where your dependencies are not real ES modules it’s a nice workaround.
 

💡 Bonus track: writing the import to every module that accesses jQuery can be cumbersome so you can even make this more magical and inject a <script type=”module”> node at the top of the HTML that does the import once and for all.

It is debatable whether injecting a global import is better than simply emitting the jQuery code, but having alternatives is always nice, depending on what you are planning to do.

 

CSS styles on demand (one possible implementation)

I’m sure you’ve already grabbed the concept of on demand loading, but we are going to cover one last example to see how powerful the technique can be.

Imagine you have a CSS file for a GUI control that you want to load only when the control is used. An easy way to make sure that the CSS is loaded only when the control is used and just once would be writing two JavaScript files. Those would be the control and a JavaScript stub to load the CSS file, with the following contents:

control.js

import ‘./control-css-loader.js’;

// Code that renders the control follows

.
.
.

 

control–css-loader.js

const link = document.createElement("link");
link.rel = 'stylesheet';
​​​​​​​link.href = 'https://my-nice-hosting/control.css’;

​​​​​​​document.head.appendChild(link);

 

It’s easy to realize that, when the control.js file is loaded, it will ask the browser to download control-css-loader.js before doing anything, which will append the link to the CSS file to the HTML document and, because the browser knows that it has already loaded control-css-loader.js, there will never be more than one link to the CSS file in the page.

Note that this does not guarantee that the CSS has been really loaded before the control renders because by appending the CSS to the HTML we are not blocking the flow of execution and the control-css-loader.js module will be considered loaded by the browser right after the append succeeds (which happens before the CSS is really fetched from the network).

However, you could leverage the top level await construct to make the loader export an awaited Promise that is fulfilled only when the CSS has been really loaded (detecting that by attaching to the onload event of the <link> node).

 

Conclusion

I hope this article has enlightened you to change the way you think about JavaScript in DXP. It’s a bit difficult to start thinking in ESM when you have spent a lot of time thinking in AMD, but once your mind clicks you realize how easy things can be.

And, for those of you reading this who were born the past millennium, one thing that may help is to unlearn all the stuff we had to learn to bundle npm packages as AMD, and think again as we did in the beginning, when all we had were <script> nodes and the window object.

At least to me, the ESM model is much more similar to that than the AMD model is. Obviously, the ability to be able to import and export things and the sandboxing of each module makes the ESM architecture much more powerful than what we had at the beginning of the century, but the fact that once again you can deploy the very same sources you write directly to the browser makes things way easier than when you need to apply complex steps between building and deploying.

 

Related topics

If this article was not enough, let me finish suggesting some more things to learn that may be useful for your developments.

Dynamic import

Similar to the static import, but it lets you import dependencies dynamically in the middle of an execution. Even with the possibility to compose the imported URL dynamically based on the contents of variables (something you cannot do in the static import).

HTTP/2

Even though you can still use Webpack to bundle whole graphs of dependencies into a single JavaScript file when using ESM, you can also decide not to do it and deploy your JavaScript files alone (as long as they are proper ES modules, of course).

However doing that may lead to a big amount of JavaScript files to be downloaded. We know that browsers have a limit of around 6 maximum connections per domain so this can hurt performance. Can we get around this?

It turns out that HTTP/2 is already supported by the most popular browsers and servers so all you need to do is to enable it in your server of choice and enjoy virtually unlimited parallel connections to the same domain.