Search code examples
javascriptpluginsslickgridesbuildesbuild-plugin

esbuild hybrid plugin to bundle multiple files iife and single esm bundle


There is a project SlickGrid that have multiple files that are all written as iife (it was originally built with jQuery namespace as iife). Most files are optional and the user can choose which feature they are interested (ie slick.contextmenu.js, slick.headermenu.js, ...) by loading the associated JavaScript feature file(s) and by doing so it will simply extend the Slick object that exist on the window object (basically the core file has an esbuild globalName: 'Slick' defined and the other files are simply extending on it whenever they are loaded, it's not tree shakeable but it's a nice way to keep a build size small by only loading what feature they want).

I'd like to keep these iife file separate for the users who still want to use standalone <script> loading but also want to provide ESM (a single file bundle) in a separate build folder for ESM. I think that I can achieve this with esbuild by writing an esbuild plugin by using onResolve. I manage to make it work but it's not the most elegant, I'd like help to find a better solution

import {
  Event as SlickEvent_,
  EventData as SlickEventData_,
  EditorLock as SlickEditorLock_,
  Utils as SlickUtils_,
} from './slick.core.js';
import { Draggable as SlickDraggable_, MouseWheel as SlickMouseWheel_, Resizable as SlickResizable_ } from './slick.interactions.js';

// TODO: I would like to avoid having to write all of the following lines which are only useful for iife 
// for iife, pull from window.Slick object but for ESM use named import
const SlickEvent = window.Slick ? Slick.Event : SlickEvent_;
const EventData = window.Slick ? Slick.EventData : SlickEventData_;
const EditorLock = window.Slick ? Slick.EditorLock : SlickEditorLock_;
const Utils = window.Slick ? Slick.Utils : SlickUtils_;
const Draggable = window.Slick ? Slick.Draggable : SlickDraggable_;
const MouseWheel = window.Slick ? Slick.MouseWheel : SlickMouseWheel_;
const Resizable = window.Slick ? Slick.Resizable : SlickResizable_;

// ...

// then use it normally in the code...
const options = Utils.extend(true, {}, defaults, options);

So the custom plugin that I wrote seems to work, but it's a bit hacky, and it will use either window.Slick for iife (when found) OR use the named import for ESM usage. Running a build for ESM will be roughly the same but without using any plugin since we want to bundle everything into a single bundled file and keep named imports like a regular build.

However please note the intentation is to still produce multiple files for the iife build, that is even if we use bundle :true because the plugin will simple replace any of the imports with an empty string.

in other words, the plugin is simply loading the code from the associated window.Slick.featureXYZ and replaces the import with an empty string because the code exist in the window.Slick object already so we don't need to use the imported code again (hence why we replace that part with an empty string)

import { build } from 'esbuild';

const myPlugin = {
    name: 'my-plugin',
    setup(build) {
      build.onResolve({ filter: /.*/ }, args => {
        if (args.kind !== 'entry-point') {
          return { path: args.path + '.js', namespace: 'import-ns' }
        }
      })

      build.onLoad({ filter: /.*/, namespace: 'import-ns' }, (args) => {
        return {
          contents: `// empty string, do nothing`,
          loader: 'js',
        };
      })
    }
};

build({
    entryPoints: ['slick.grid.js'],
    color: true,
    bundle: true,
    minify: false,
    target: 'es2015',
    sourcemap: false,
    logLevel: 'error',

    format: 'iife',
    // globalName: 'Slick', // only for the core file
    outfile: 'dist/iife/slick.grid.js',
    plugins: [myPlugin],
});

So this approach seems to work but is not very elegant, ideally it would be great if I could get the named imports and replace them directly in the code and avoid having to write all these extra lines after the imports in my codebase.

Does anyone have a better solution? Is there a way to get named imports in esbuild onResolve and onLoad?

So far what I found is that esbuild only provides the kind property as import-statement but it doesn't provide the named import that goes with it. If by any chance I could find how to get them, then I could maybe write my own code in the onLoad to override it with something like var Utils = window.Slick.${namedImport} for iife without having to write all these extra lines by myself in the codebase (ie: const SlickEvent = window.Slick ? Slick.Event : SlickEvents;), this would also cleanup these unused lines in my ESM build (it's only useful for the iife build).

EDIT

I found this esbuild request issue Request: Expose list of imports in onLoad/onResolve argument to allow custom tree-shaking which is asking for the same thing I was looking for. The feature request was rejected because it might not be possible within esbuild itself but a suggestion was posted to find the named imports, so I'll give that a try


Solution

  • To answer my own question after some trials and errors, I came up with the code below.

    I first kept the same custom plugin that I had mentioned in the original question which removes all import when building as iife. So the plugin is the same code and is shown in the question above.

    What is new in my answer, which was the missing piece, is to use esbuild#define which was provided as a suggestion from an issue that I've opened on the esbuild project and for which I have received this comment, with define we are able to do the following (as described in their docs)

    It can be a way to change the behavior some code between builds without changing the code itself

    With that new knowledge, we can add something like define: { IIFE_ONLY: 'true' } (notice the boolean is written as a string) for iife and the inverse ('false') for non iife builds (i.e. ESM) and that's it. With that new code in place, I'm able to build all the files as iife (to keep our legacy approach) and also build as ESM for the modern approach. So in summary, with define I'm able to remove unnecessary code from my builds without changing the code too much, and all dead code (related to format type) are now removed accordingly

    import { build } from 'esbuild';
    
    const removeImportsPlugin= {
        name: 'remove-imports-plugin',
        setup(build) {
          build.onResolve({ filter: /.*/ }, (args) => {
            if (args.kind !== 'entry-point') {
              return { path: args.path + '.js', namespace: 'import-ns' }
            }
          });
          build.onLoad({ filter: /.*/, namespace: 'import-ns' }, () => ({
            contents: `// empty string, do nothing`,
            loader: 'js',
          }));
        }
    };
    
    /** build as iife, every file will be bundled separately */
    export async function buildIifeFile(file) {
      build({
        entryPoints: [file],
        format: 'iife',
        // add Slick to global only when filename `slick.core.js` is detected
        globalName: /slick.core.js/.test(file) ? 'Slick' : undefined,
        define: { IIFE_ONLY: 'true' },
        outfile: `dist/browser/${file.replace(/.[j|t]s/, '')}.js`,
        plugins: [removeImportsPlugin],
      });
    }
    
    // bundle in ESM format into single file index.js
    export function buildEsm() {
      build({
        entryPoints: ['index.js'],
        format: 'esm',
        target: 'es2020',
        treeShaking: true,
        define: { IIFE_ONLY: 'false' },
        outdir: `dist/esm`,
      });
    }
    

    The code that was provided in the original question has to be updated a little bit and is shown below, basically changing this window.Slick ? ... to this IIFE_ONLY ? ... (which is the new define flag)

    // imports will be auto-dropped in iife by custom plugin
    import { SlickEvent as SlickEvent_, Utils as Utils_ } from '../slick.core';
    
    // for (iife) load `Slick` methods from global window object, or use imports for (cjs/esm)
    const SlickEvent = IIFE_ONLY ? Slick.Event : SlickEvent_;
    const Utils = IIFE_ONLY ? Slick.Utils : Utils_;
    
    // ...
    
    // then use it normally in the code...
    const options = Utils.extend(true, {}, defaults, options);
    

    The code that will be produced for an iife build will require to use both the custom plugin (to get rid of all import) and also use define: { IIFE_ONLY: 'true' } which mean that the 1st value of the ternary operator will be used for iife and for esm build then the 2nd value will be used.

    So after all of that, the iife output looks like below (imports are removed, and we use the code from the global window Slick object)

    "use strict";
    (() => {
      // plugins/slick.cellcopymanager.js
      var SlickEvent = Slick.Event, Utils = Slick.Utils;
      function CellCopyManager() {
    // ...
    

    while the output for ESM is a single bundled file with the code shown below (it uses and keep all the import instead and gets rid of the global Slick object)

    // plugins/slick.cellcopymanager.js
    var SlickEvent5 = SlickEvent, Utils10 = Utils;
    function CellCopyManager() {
    // ...
    

    in summary, both iife and esm builds are now only including code that belong to their build format type and nothing more. This allow my builds to be clean and a lot smaller in comparison to what I used at the beginning and that is thanks to this new approach, there's no more unreachable dead code anymore (as opposed to originally we had many lines that were simply unreachable and not removed).

    and voilà!

    I can now use esbuild for all my build format types and make all my users happy regardless of the format they use with roughly the same build size as before.