Sharing Javascript Code and Libraries

As you build out multiple Javascript-based custom elements, you'll find yourself using the same code and libraries over and over. Wouldn't it be great if you could share those? You can, and this blog will help you do it...

JS Import Map Client Extensions

Introduction

As evidenced by my recent blogs, I'm still on a React Custom Element Client Extension tear... I think this is the best way to develop custom solutions on Liferay because they are free from the Liferay upgrade cycle, so effectively I'm writing these solutions once and I'm done.

As you start building out multiple custom elements though, you'll quickly find yourself reusing code and libraries across the projects.

For example, in my React apps I'm always including React Router. And since I'm consuming Headless APIs and the generated client (see my last blog), I'm including the client as well as its SuperAgent dependency. And lately I've been using React Hook Form for my forms..

Not knowing any better, you'd include these in each one of your custom elements and it would work fine, but eventually you'll face an issue like having to regenerate the client code because you've added an Object or relationship and you want to have it modeled, and then you'll be left figuring out how to replicate the change to all of the modules that have it embedded. Or you need to update the version of SuperAgent you're using and this will be a similar issue.

But I'm here to tell you, there's a better way...

JS Import Maps

I'm going to introduce JS Import Maps in this section; if you already know what they are, feel free to skip ahead to the next section. If you haven't heard of JS Import Maps before or wanted to understand them a little more, this section should prove interesting...

As a modern JavaScript developer, you’re likely familiar with module bundlers like Webpack, Rollup, or Parcel. These tools have been essential for managing dependencies and packaging code for production. However, with the advent of ES Modules (ESM) in browsers, a new way to handle module loading has emerged: JavaScript Import Maps.

What Are Import Maps?

Import Maps are a browser feature that allow you to control how module specifiers (the strings used in import statements) are resolved to actual JavaScript module files. They enable the use of “bare” import specifiers—like 'react' or 'lodash'—in the browser environment without bundling.

In essence, an Import Map is a simple JSON-like object that tells the browser where to find the modules you want to import.

The Problem Import Maps Solve

Traditionally, when you write:

import React from 'react';

In a browser environment, this would fail because the browser doesn’t know where to fetch the 'react' module from. Bundlers solve this by resolving module paths at build time. However, this approach requires a build step and tooling setup.

With Import Maps, you can eliminate the bundling step for certain use cases, allowing the browser to handle module resolution natively.

Benefits of Using Import Maps

We really want to use Import Maps, especially in the portal environment, because we get the following benefits:

Simplified Development

  • No Bundler Required: For small projects or simple applications, you can skip the bundling step entirely.
  • Faster Iteration: Changes can be tested immediately in the browser without waiting for a build process.

Fine-Grained Control

  • Version Management: Easily specify exact versions of libraries.
  • Module Aliasing: Map multiple specifiers to the same module or create shortcuts.

Improved Performance

  • HTTP/2 Efficiency: Leverage HTTP/2 multiplexing to load multiple modules concurrently.
  • Cache Optimization: Browsers can cache individual modules, potentially reducing load times on subsequent visits.

Liferay-Specific Benefits

  • Shared Resources: Multiple custom elements using the same shared resources reduces duplicates and possible conflicts.
  • Simplified Maintenance: Updating a single JS Import Map module version ensures all dependent custom elements will use the newer version.

Considerations

There are some things to keep in mind with respect to the use of JS Import Maps:

Performance Implications

  • Initial Load: May result in more HTTP requests, but this can be mitigated with HTTP/2 and server-side optimizations.
  • Caching: Individual modules can be cached by the browser, improving subsequent load times.

Limitations

  • Static Mapping: Import Maps are static and cannot be altered at runtime.
  • Complex Dependencies: For applications with complex build requirements (like JSX transpilation), bundlers may still be necessary.

When to Use Import Maps

Import Maps are ideal for:

  • Prototyping: Quickly testing ideas without setting up a build process.
  • Micro Frontends: Managing dependencies in a micro-frontend architecture.
  • Legacy Projects: Incrementally modernizing older codebases.
  • Sharing Resources: Using the same dependency in multiple custom elements.

By leveraging Import Maps, you can simplify your development workflow and take advantage of native ES Module support in browsers. While they don’t replace bundlers entirely, they offer a powerful tool for certain scenarios where simplicity and control are paramount.

When Not to Use Import Maps

There are reasons not to throw all of your dependencies into an import map. Remember that the declared imports are going to be used on every portal page. If you're declaring yup as a dependency in only one custom element, it doesn't make sense to push that to an import map.

And the Import Map is going to share and expose a single version... Depending upon how you look at it, this can be a good thing because it forces consistency, but if different custom elements need different versions, having the dependency in an Import Map works against your needs.

Now that we know more about JS Import Maps, let's dig into them with a Liferay-specific bent...

Liferay JS Import Maps

Liferay has a client extension for defining JS Import Maps that will make modules available for your custom elements.

Using a JS Import Map, you can load and share both libraries as well as custom code. Using a JS Import Map CX, we can deal with the issues mentioned in the introduction by updating one CX and deploying it knowing that all others will be using the updated code almost by magic.

So, how do we set up and use a JS Import Map CX? Well let's figure that out together as we bundle the generated Headless client and dependency from my last blog as a JS Import Map, then we'll see how to use it in a React Custom Element Client Extension.

Creating the Shared Module CX

So the first thing we'll do is create the shared module CX. This is actually pretty easy...

In the client-extensions folder in our workspace, we create a folder to hold the CX. Use a good name so you can recognize the project and its purpose. For my headless Vacation client, I'm going to name my folder vacation-client-js-import-map-entry.

The js-import-map-entry suffix is a Liferay standard, so I use it too to be consistent.

We also need to have a client-extension.yaml file, and the initial file is just going to be:

vacation-react-router-import-maps-entry:
  bareSpecifier: react-router-dom
  name: Liferay CoE Frontend React Router DOM Import Maps Entry
  type: jsImportMapsEntry
  url: https://esm.sh/react-router-dom@6.17.0?external=react
vacation-react-hook-form-import-maps-entry:
  bareSpecifier: react-hook-form
  name: Vacation React Hook Form Import Maps Entry
  type: jsImportMapsEntry
  url: https://esm.sh/react-hook-form@7.52.2
vacation-superagent-import-maps-entry:
  bareSpecifier: superagent
  name: Vacation SuperAgent Import Maps Entry
  type: jsImportMapsEntry
  url: https://esm.sh/superagent@9.0.2

And for the build we need a simple package.json file:

{
  "name": "@liferay/vacation-js-import-maps-entry",
  "private": true
}

Now this CX only contains the shared modules that our other custom elements could then leverage.

Specifically we're declaring that we have these three modules that will be loaded by the browser and will be available in our custom React elements as simple imports.

This is all we need for a shared module CX. When we build and deploy this CX, the shared modules will be available for our custom elements to use.

Using the Shared Modules

Just because we've built and deployed our shared module JS Import Map doesn't mean our custom elements are going to automagically start using them. No, we have to actually declare that we are depending upon them, but we'll do this the same way we declare any dependency - in the package.json file.

In the example file below, a custom element is declaring a dependency on react-hook-form:

{
  "name": "@liferay/vacation-request",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    ...
  },
  "dependencies": {
    "react-hook-form": "^7.52.2",
    "react": "16.12.0",
    "react-dom": "16.12.0"
  },
  "devDependencies": {
    ...
  }
}

If we do nothing else, when we build this custom element it will include its own copy of react-hook-form.

To avoid embedding its own copy and leveraging the shared module, we need to exclude it from packaging.

Using Vite, we'd just add this as another external resource like we do when we're using Liferay's version of React. Here's the vite.config.js file we'd be using:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  base: '/o/vacation-request',
  plugins: [react({
    jsxRuntime: 'classic',
  })],
  build: {
    outDir: 'build/vite',
    rollupOptions: {
      external: [
        'react',
        'react-dom',
        'react-hook-form',
        /^(?!@clayui\/css)@clayui.*$/,
      ],
    }
  }
})

When Vite is packaging up the custom element, it will not include react-hook-form, and when deployed it will instead use the version which is provided by the JS Import Map CX that we created and deployed.

Creating a Simple Shared Code CX

So now we know how to share an existing module in Liferay using a JS Import Map CX.

But that's not all that we can do. We can share custom code and elements too.

Before we get to sharing that generated client, let's start by sharing something a little simpler...

We'll create a CX from scratch to expose data, a function, and a React component. These examples should cover your basic cases that you'll face, but of course they can be built out to include a library of functions and/or components once we know how to handle the simple cases...

We're going to start in a workspace that you already have, at the command line and inside of the client-extensions folder. If you have used blade init to create a new workspace, you may need to create the client-extensions folder yourself.

So, here's the steps we'll take to create our shiny new shared code CX:

Step 1 - Create the Project

We need to create the project and, rather than copying from the Liferay samples workspace, we're going to create from scratch.

Here's the actual commands/output:

$ mkdir vacation-shared-code-js-import-map-entry
$ cd vacation-shared-code-js-import-map-entry
$ yarn init -y
yarn init v1.22.22
warning The yes flag has been set. This will automatically answer yes to all 
  questions, which may have security implications.
success Saved package.json
✨  Done in 0.01s.
$ yarn add vite --dev
yarn add v1.22.22
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
warning Pattern ["vite@^5.4.6","vite@^5.2.0","vite@^5.2.0"] is trying to unpack 
  in the same destination "/Users/dnebinger/Library/Caches/Yarn/v6/
  npm-vite-5.4.6-85a93a1228a7fb5a723ca1743e337a2588ed008f-integrity/node_modules/
  vite" as pattern ["vite@^5.4.6"]. This could result in non-deterministic behavior, 
  skipping.
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Saved 1 new dependency.
info Direct dependencies
info All dependencies
└─ vite@5.4.6
✨  Done in 1.87s.
$ mkdir src
$ touch src/index.js

This is going to create a new yarn-based module that leverages Vite and we have the initial index.js that we'll be modifying in a bit.

As part of the project setup, let's edit the package.json file so it is ready:

{
  "dependencies": {
    "react": "16.12.0",
    "react-dom": "16.12.0"
  },
  "devDependencies": {
    "@types/react": "^16.14.1",
    "@types/react-dom": "^16.9.7",
    "@vitejs/plugin-react": "^4.2.1",
    "vite": "^5.2.0"
  },
  "exports": {
    ".": {
      "import": "./build/vite/index.js"
    }
  },
  "name": "@liferay/vacation-shared-code",
  "private": true,
  "scripts": {
    "build": "vite build",
    "dev": "vite",
    "preview": "vite preview"
  },
  "type": "module",
  "version": "1.0.0"
}

This adds some stuff to the generated package.json the tools give us. It flushes out the scripts section, but more importantly it declares that exports and it gives the module the name that will be used for future import statements.

In addition, let's also update the vite.config.js file so it too is ready:

import { resolve } from 'path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.js'),
      formats: ["es"],
    },
    outDir: 'build/vite',
    rollupOptions: {
      external: [
        'react',
        'react-dom',
      ],
      output: {
        entryFileNames: "[name].js",
      },
    }
  },
  plugins: [
    react({
      jsxRuntime: 'classic',
    })
  ]
})

Like the changes to package.json, the changes here are setting up the exports.

Although we haven't introduced React yet (but we will), I include it in the initial files so I don't have to come back and edit in in later on.

Finally we also need a client-extension.yaml file so build and deployment will work:

assemble:
  - from: build/vite
    into: static
vacation-shared-code-js-import-maps-entry:
  bareSpecifier: "@liferay/vacation-shared-code"
  name: Vacation Shared Code JS Import Maps Entry
  type: jsImportMapsEntry
  url: /index.js

These changes are necessary to declare and expose the JS Import Map once built and deployed into Liferay.

Step 2 - Create the Continents Constant

Well, really this is more about defining shared data than simply a constant, but it is a convenient way to demonstrate how it is done.

Let's make a folder to hold the continents and then populate it:

$ mkdir src/data
$ cat > src/data/continents.js
export default {
  AF: 'Africa',
  AN: 'Antarctica',
  AS: 'Asia',
  EU: 'Europe',
  NA: 'North America',
  OC: 'Oceania',
  SA: 'South America',
};
$ cat >> src/index.js
export {default as continents} from './data/continents';

Our continents file contains a simple mapping of keys to continent names. Simple data, certainly, but if you can imagine your own shared data needs exposing them in this similar way, that's the idea.

We also need to update the primary index.js file to export the data and the name to export it by. This will be important to reference the data from the JS Import Map.

Step 3 - Deploy and Test

So let's see this in action!

In the folder, I used the following commands:

$ yarn install
yarn install v1.22.22
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
✨  Done in 0.50s.
$ blade gw deploy

> Task :yarnInstall
yarn install v1.13.0
[1/4] Resolving packages...
success Already up-to-date.
Done in 0.09s.

> Task :client-extensions:vacation-shared-code-js-import-map-entry:packageRunBuild
yarn run v1.13.0
$ vite build
vite v5.4.6 building for production...
transforming...
✓ 2 modules transformed.
rendering chunks...
computing gzip size...
build/vite/index.js  0.17 kB │ gzip: 0.14 kB
✓ built in 23ms
Done in 0.36s.

Deprecated Gradle features were used in this build, making it incompatible with 
Gradle 9.0.

You can use '--warning-mode all' to show the individual deprecation warnings and 
determine if they come from your own scripts or plugins.

For more on this, please refer to https://docs.gradle.org/8.5/userguide/
  command_line_interface.html#sec:command_line_warnings in the Gradle documentation.

BUILD SUCCESSFUL in 1s
11 actionable tasks: 2 executed, 9 up-to-date

After deployment is complete and the module is started, you can log into Liferay as an admin, go to the Site menu, to the Design section and choose Fragments, then create a new fragment.

Before putting in any code, take a moment to pull up the inspection panel and go to the console so you can see the result.

Enter the following text into the Javascript panel:

import { continents } from '@liferay/vacation-shared-code';

console.log(continents);

After this is done, you should see the following in the Javascript console:

See in the console window there where it has logged the continents? This proves that the module is available for importing the continents data element

Because my client-extension.yaml file's bareSpecifier includes the @liferay prefix, I need to also include that in the import declaration in the Javascript panel. If I exclude the prefix in the bareSpecifier, I would also exclude it in the import declaration.

At the end of the blog we'll find a real React custom element that will take advantage of all of these imports, but for now we know the JS Import Map is working and, at this point, this should be enough to move onto our next example, sharing functions/code.

Step 4 - Create the sayHello() Function

Again, another over simplistic contrived example, but one to demonstrate how you can share functions from the JS Import Maps client extension.

We'll create a util folder to hold the JS file, then we'll create a simple function in there.

$ mkdir src/util
$ cat > src/util/sayHello.js
export default function sayHello(name) {
  return "Hello " + name + ", we're glad you're here!";
}
$ cat >> src/index.js
export { default as sayHello } from './util/sayHello';

The last step adds the export to the index.js so the function will be exposed.

With these changes saved, we can then use blade gw deploy to deploy the updated module.

Once deployed, we can go to our handy fragment and change it to test:

This demonstrates how we could have any number of functions/objects defined within our JS Import Map implementation. As long as they're properly exported by the import map, they can be imported and referenced in your custom fragments and/or custom elements.

Along with custom functions, we can also support custom React elements...

Step 5 - Create the SimpleInput Component

Yes, in this step we're going to create a custom React element that we are going to export and make available a custom React component. The package.json and vite.config.js are already set up to support React, so we don't have to do anything now. If we didn't set that up earlier, we'd have to update the files to include the React support.

We'll start by creating a components folder to hold the new element, then we'll create the SimpleInput component:

$ mkdir src/components
$ cat > src/components/SimpleInput.jsx
import React from 'react';

const SimpleInput = (props) => {
  const {
    id,
    label,
    name,
    value,
    ...restProps
  } = props;
  
  const inputId = id || name;
  
  return (
    <div>
      {label && <label htmlFor={inputId}>{label}</label>}
      
      <input
        {...restProps}
        id={inputId}
        name={name}
        value={value}
      />
    </div>
  );
}

export default SimpleInput;
$ cat >> src/index.js
export {default as SimpleInput} from './components/SimpleInput';

Nothing special here as far as React components go. Just defines a simple component that presents a label with an input and props to handle the settings.

We update the index.js file to export SimpleInput too.

To test this, and others, we'll need to create an actual React Custom Element CX...

Step 6 - Create the Test React Custom Element

Since we have a shared React component, we'll need a React Custom Element CX so we can leverage and use that component.

Now, you might be thinking to create this guy I'm going to point you to my blog, From React App to React Client Extension in 5 Easy Steps, but you'd be wrong. Instead, I'm going to point you at a Bash script I've been using on my Mac called create-react-cx. I'm sure it can be adapted to other platforms, but effectively the script just automates everything we did manually in the 5 steps blog.

Usage is pretty simple:

$ create-react-cx [-s scope] [-e element-name] project-name

Scope is the @liferay portion of the name. If not provided, @liferay is used, but you could use for example -s dnebing to get an @dnebing scope.

Element name is an optional name to use for the element if you want one that is different than the project name (the default). For my test element, I just used create-react-cx vacation-test and it created the project named vacation-test and the element was also vacation-test. Using the -e with a different name just gives you the flexibility to change it from the project name if you want to.

Okay, back to this blog and the use of the custom JS Import Map CX...

First we need a dependency addition in package.json. I made the following change to my package.json file:

{
  "name": "@liferay/vacation-test",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@liferay/vacation-shared-code": "*",
    "react": "16.12.0",
    "react-dom": "16.12.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.9.0",
    "@types/react": "^18.3.3",
    "@types/react-dom": "^18.3.0",
    "@vitejs/plugin-react": "^4.3.1",
    "eslint": "^9.9.0",
    "eslint-plugin-react": "^7.35.0",
    "eslint-plugin-react-hooks": "^5.1.0-rc.0",
    "eslint-plugin-react-refresh": "^0.4.9",
    "globals": "^15.9.0",
    "vite": "^5.4.1"
  }
}

As you can see, there was only one line added. The dependency matches exactly the name I used in the package.json file for the vacation-shared-code-js-import-map-entry project.

Along with this, I also needed to update the vite.config.js file:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  base: '/o/vacation-test',
  build: {
    outDir: './vite-build',
    rollupOptions: {
      external: [
        '@liferay/vacation-shared-code',
        'react',
        'react-dom',
        /^(?!@clayui\/css)@clayui.*$/,
      ],
    }
  },
  plugins: [
    react({
      jsxRuntime: 'classic',
    }),
  ]
})

Again I added a single line to the external block indicating that the @liferay/vacation-shared-code dependency would be provided elsewhere and not to package it into the module. Like the package.json change, the exclusion name matches exactly the name from the vacation-shared-code-js-import-map-entry project.

The only thing left is to actually use these new elements. A quick adjustment to the App.jsx file below will do the trick:

import React, { useState, useEffect } from 'react'
import './App.css'
import { sayHello, SimpleInput } from '@liferay/vacation-shared-code'

function App() {
  const [name, setName] = useState('');
  const [greeting, setGreeting] = useState('');

  useEffect(() => {
    setGreeting(sayHello(name));
  }, [name]);


  return (
    <>
      <SimpleInput label="Enter name to be greeted:" name="UserName"
        value={name} onChange={(e) => setName(e.target.value)} />
      <p>{greeting}</p>
    </>
  )
}

export default App;

Note how the import of the artifacts matches exactly the name from the vacation-shared-code-js-import-map-entry project.

Conclusion

So that's really all to show regarding the JS Import Maps.

Admittedly here I only did a very simple case, but the same kind of thing can be applied to any scale that you need.

When you go back to the generated client code that we did in the last blog to access the headless services, you'll see in its own index.js file that it is exporting everything, so it is basically ready to be a module that can be used in Liferay under the same model, you just need to add the necessary files for Liferay to treat it like the JS Import Map entry it wants to be.

Hope you find this as useful as I have!

Blogs

Hey Dave, really nice stuff!

jsImportMapsEntry is, without a doubt, my favorite Client Extension at all! 😄

I just want to add one thing: you mentioned workspace in a Liferay perspective and, in fact, the Liferay Workspace will enable `@liferay/vacation-shared-code` as a dependency of your `customElement` (as a installed package) because of the way Node.js will resolve modules in a NPM/Yarn Workspace . So, I imagine your Liferay's root `package.json` looks like this:

{     "private": true,     "workspaces": {         "packages": [             "client-extensions/vacation-shared-code-js-import-map-entry",             "client-extensions/vacation-test"         ]     } }

Based on this configuration (Liferay Workspace also defining a NPM/Yarn Workspace), Node.js will be able to resolve the package name (`@liferay/vacation-shared-code`), as you said, because it was told to it where else it should look for packages.

We can even create a NPM/Yarn Workspace outside a Liferay Workspace, and still make the modules/packages shareble among each other, but it is really nice to see how Liferay team cares about the ecosystem and we can use it built in! 👏

A few extra resources to go deeper:

https://yarnpkg.com/features/workspaceshttps://docs.npmjs.com/cli/v7/using-npm/workspaces

Absolutely right Gabriel!

The Liferay Workspace is not required to build this kind of JS Import Map support.

I tend to recommend it though because the Liferay Workspace does know how to bundle up the various Client Extensions into the deployable zip artifacts in an easy and convenient way. And in this case, it also allows the custom element to use and reference the vacation-shared-code as a dependency without having to create or maintain the root package.json needed for the NPM/Yarn workspace.

So while the Liferay Workspace isn't the only way to create and manage these guys, I do feel it makes it easier in some respects.