Skip to content

jQuery 4 exports explainer

Michał Gołębiowski-Owczarek edited this page Mar 11, 2024 · 2 revisions

Summary

NOTE: this doc explains a future 4.0 design as implemented in PR https://github.com/jquery/jquery/pull/5429. This is work in progress, further changes may happen before the final release; this doc will be updated in such a case.

jQuery 4.0 will ship with exports in its package.json. exports allow to expose multiple entry points, hide implementation details, serve different files to import vs. require and many more.

This doc explains all the choices behind the jQuery 4.0 exports definition.

The exports API

The exports syntax is pretty complex and we won't cover it all here. To learn more about exports, read https://nodejs.org/api/packages.html#package-entry-points. For jQuery purposes, the most important rules are:

  1. The top level contains entry points. The following definition:
    "exports": {
      ".": "main.js",
      "./foo": "bar.js"
    }
    in a library named lib would mean importing lib returns the contents of main.js, while importing lib/foo - the context of bar.js.
  2. The value for each entry point is an object in which keys are possible conditions and leaves are all the possible resolved path values. For example, in the following case:
    "exports": {
      ".": {
        "a": {
          "b": {
            "c": "c.js"
          }
        },
        "d": "d.js",
        "default": "default.js"
      }
    }
    if the environment reports conditions a, b & c, c.js will be returned. Otherwise, if it reports the d condition, we'll get d.js. Otherwise, we'll receive default.js. All conditions on the path to a specific leaf need to be reported to get that leaf.
  3. Conditions are evaluated from top to bottom. If they are multiple reported condition paths, the most top one will be the chosen one.
  4. There are three most important conditions reported by Node.js: default is always reported, import is reported when the entry point is fetched via the ESM import and require if it's fetched via a CommonJS require.
  5. If the value for an entry point is a string instead of an object, the provided path will always be the chosen one regardless of reported conditions.
  6. Wildcards are reported in entry point definitions as well as in paths. * in an entry point matches a substring directly mapped to * on the right side. For example:
    "exports": {
      "a/*.js": "b/*.js"
    }
    means importing lib/a/foo/bar.js will provide b/foo/bar.js from the lib package.

Requirements

  1. There need to be four entry points: jquery, jquery/slim, jquery/factory & jquery/factory-slim. The first two need to point to the full/slim version of jQuery respectively, the last two to their factory versions - for example, the following:
    import { jQueryFactory } from "jquery/factory";
    const $ = jQueryFactory( window );
    will make $ point to jQuery, where window is a browser-compatible window implementation.
  2. For compatibility reasons, both:
    import $ from "jquery";
    in ESM files and:
    const $ = require( "jquery" );
    in CommonJS files need to continue working, with $ pointing to jQuery.
  3. Because of interop issues between ESM & CommonJS when default ESM exports are used, named $ & jQuery exports need to be exposed via ESM in addition to the default one. They all need to point to the same jQuery:
    import $default, { jQuery, $ } from "jquery";
    console.assert( $default === jQuery );
    console.assert( $ === jQuery );
  4. Regardless of whether Node.js or a popular bundler is used to run/build jQuery, if a single project fetches jQuery both via import and require, they need to point to the same copy of jQuery.
  5. require should work in an environment supporting only CommonJS; import needs to work in an environment supporting only ESM.
  6. The src directory needs to be fully exposed for more advanced usage; only ESM is supported there.

Implementation

As we have four entry points plus all the files in src, we are starting with:

  "exports": {
    ".": {},
    "./slim": {},
    "./factory": {},
    "./factory-slim": {},
    "./src/*.js": "./src/*.js"
  },
  "main": "dist/jquery.js",

We are keeping main for backwards compatibility with tools not supporting exports. It is not that rare; for example, even the newest versions of TypeScript ignore exports if moduleResolution is set to node10 or its legacy node alias.

The line "./src/*.js": "./src/*.js" exposes all JS files in src as-is. We don't guarantee any stability here.

The first four exports form two groups: "." will look almost identical to "./slim" and "./factory" to "./factory-slim". We will only discuss non-slim versions.

The main entry point: .

The most obvious setup would look like the following:

".": {
  "import": "./dist-module/jquery.module.js",
  "require": "./dist/jquery.js"
}

Since some older environments may only recognize the default condition, we need to add one pointing to CommonJS - but then we don't need the require condition as the default one can be reused:

".": {
  "import": "./dist-module/jquery.module.js",
  "default": "./dist/jquery.js"
}

However, this means projects fetching jQuery via both the ESM import and the CommonJS require would get two different jQuery copies, each with their own data storage.

Node.js packages docs have a section dedicated to this issue: Dual CommonJS/ES module packages. In Node.js, ESM files can synchronously import from CommonJS ones - but CommonJS ones cannot synchronously require from ESM ones. This is because ES modules are inherently async. This means we can create a small ESM wrapper file called jquery.node-module-wrapper.js with the following contents:

import jQuery from "../dist/jquery.js";
export { jQuery, jQuery as $ };
export default jQuery;

However, we cannot depend that all tools supporting ESM recognize such imports from CommonJS. We need to constrain this workaround to Node.js only. Thankfully, Node.js reports the node condition.

Our updated exports definition:

".": {
  "node": {
    "import": "./dist-module/jquery.node-module-wrapper.js",
    "default": "./dist/jquery.js"
  },
  "import": "./dist-module/jquery.module.js",
  "default": "./dist/jquery.js"
}

We handled Node.js. Bundlers don't usually report the node condition. However, they usually report the module one, regardless of whether the ESM import or the CommonJS require is used to fetch the library.

Note: this is because bundlers have more relaxed rules as opposed to Node.js and allow not only ESM files fetching CommonJS ones via import but also allow CommonJS files to synchronously fetch ESM files via require. It's possible as bundlers... well, bundle: they can merge multiple input files into one bundle with synchronous access between parts.

It seems we could just duplicate the node section, changing the key from node to module. However, some bundlers have pure ESM modes where CommonJS is not recognized at all; one such example is Rollup. We need to serve a pure ESM version to them.

To solve this issue, we use a similar solution as for Node.js but reversed - i.e., we ship a pure ESM version but the CommonJS one, contained in the jquery.bundler-require-wrapper.js file, is just re-exporting the ESM one:

const { jQuery } = require( "../dist-module/jquery.module.js" );
module.exports = jQuery;

For tools supporting only ESM or only CommonJS, we leave the top-level import & default entries.

The final section for entry point . looks like the following:

".": {
  "node": {
    "import": "./dist-module/jquery.node-module-wrapper.js",
    "default": "./dist/jquery.js"
  },
  "module": {
    "import": "./dist-module/jquery.module.js",
    "default": "./dist/jquery.bundler-require-wrapper.js"
  },
  "import": "./dist-module/jquery.module.js",
  "default": "./dist/jquery.js"
}

The factory entry point: ./factory

For the factory entry point, we could follow the same strategy as for the main . one. That would require two extra wrapper files (four if you count the slim versions). However, we can simplify it a bit, leveraging the fact that factory entry points are new, they are not meant to be usable from browser script tags and we have more freedom when it comes to their APIs.

To avoid wrapper files:

  1. We don't use the default export in ESM. We use one named one: jQueryFactory.
  2. In CommonJS, we use a similar API: module.exports = { jQueryFactory }.

With these assumptions, we can just serve the CommonJS version to Node and the ESM one to bundlers without differentiating on whether import or require was used to fetch the library. The final version:

"./factory": {
  "node": "./dist/jquery.factory.js",
  "module": "./dist-module/jquery.factory.module.js",
  "import": "./dist-module/jquery.factory.module.js",
  "default": "./dist/jquery.factory.js"
}

Final version

The final full version of exports & main:

"exports": {
  ".": {
    "node": {
      "import": "./dist-module/jquery.node-module-wrapper.js",
      "default": "./dist/jquery.js"
    },
    "module": {
      "import": "./dist-module/jquery.module.js",
      "default": "./dist/jquery.bundler-require-wrapper.js"
    },
    "import": "./dist-module/jquery.module.js",
    "default": "./dist/jquery.js"
  },
  "./slim": {
    "node": {
      "import": "./dist-module/jquery.node-module-wrapper.slim.js",
      "default": "./dist/jquery.slim.js"
    },
    "module": {
      "import": "./dist-module/jquery.slim.module.js",
      "default": "./dist/jquery.bundler-require-wrapper.slim.js"
    },
    "import": "./dist-module/jquery.slim.module.js",
    "default": "./dist/jquery.slim.js"
  },
  "./factory": {
    "node": "./dist/jquery.factory.js",
    "module": "./dist-module/jquery.factory.module.js",
    "import": "./dist-module/jquery.factory.module.js",
    "default": "./dist/jquery.factory.js"
  },
  "./factory-slim": {
    "node": "./dist/jquery.factory.slim.js",
    "module": "./dist-module/jquery.factory.slim.module.js",
    "import": "./dist-module/jquery.factory.slim.module.js",
    "default": "./dist/jquery.factory.slim.js"
  },
  "./src/*.js": "./src/*.js"
},
"main": "dist/jquery.js",

FAQ

Why are you not handling the development & production conditions?

Some tools, like Webpack or Parcel support the development & production conditions. They can be especially useful if a library ships different development code, e.g. adding some debugging features. Some other tools, like Rollup, does not support these conditions.

In jQuery, the only difference between the development & production builds are that the latter is minified. Most workflows caring about the file size already include the minification step that also minifies vendors, though, so pointing to the minified jQuery version wouldn't help much. On the other hand, handling these conditions would greatly enlarge an already huge exports definition - we'd have to split almost every current path to its development & production versions. All the wrapper files would need to be doubled as well. For example, the section for just the . entry point would look like the following:

".": {
  "node": {
    "import": {
        "production": "./dist-module/jquery.node-module-wrapper.min.js",
        "default": "./dist-module/jquery.node-module-wrapper.js"
    }
    "production": "./dist/jquery.min.js",
    "default": "./dist/jquery.js"
  },
  "module": {
    "import": {
        "production": "./dist-module/jquery.module.min.js",
        "default": "./dist-module/jquery.module.js"
    },
    "production": "./dist/jquery.bundler-require-wrapper.min.js",
    "default": "./dist/jquery.bundler-require-wrapper.js"
  },
  "import": {
    "production": "./dist-module/jquery.module.min.js",
    "default": "./dist-module/jquery.module.js"
  },
  "production": "./dist/jquery.min.js",
  "default": "./dist/jquery.js"
}

For now, we decided to avoid this complexity.

Why are you not handling the X condition?

The only entry points Node.js supports are node, node-addons, import, require, and default. However, Node docs themselves mention a few other conditions: types, browser, development & production. We don't provide types and we ship the same API to Node.js & the browser - while Node doesn't have a browser-compatible global window, one can simulate it via jsdom. We've already discussed the development & production conditions.

In general, adding support for a new condition can be done in a minor release; it's not a breaking change. For this first release, we're trying to do only as much as it's needed to fulfill our requirements.