- Notifications
You must be signed in to change notification settings - Fork20.6k
jQuery 4 exports explainer
NOTE: this doc explains a future 4.0 design as implemented in PRhttps://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 withexports
in itspackage.json
.exports
allow to expose multiple entry points, hide implementation details, serve different files toimport
vs.require
and many more.
This doc explains all the choices behind the jQuery 4.0exports
definition.
Theexports
syntax is pretty complex and we won't cover it all here. To learn more aboutexports
, readhttps://nodejs.org/api/packages.html#package-entry-points. For jQuery purposes, the most important rules are:
- The top level contains entry points. The following definition:in a library named
"exports": {".":"main.js","./foo":"bar.js"}
lib
would mean importinglib
returns the contents ofmain.js
, while importinglib/foo
- the context ofbar.js
. - The value for each entry point is an object in which keys are possibleconditions and leaves are all the possible resolved path values. For example, in the following case:if the environment reports conditions
"exports": {".": {"a": {"b": {"c":"c.js" } },"d":"d.js","default":"default.js" }}
a
,b
&c
,c.js
will be returned. Otherwise, if it reports thed
condition, we'll getd.js
. Otherwise, we'll receivedefault.js
.All conditions on the path to a specific leaf need to be reported to get that leaf. - Conditions are evaluated from top to bottom. If they are multiple reported condition paths, the most top one will be the chosen one.
- 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 ESMimport
andrequire
if it's fetched via a CommonJSrequire
. - 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.
- 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:means importing"exports": {"a/*.js":"b/*.js"}
lib/a/foo/bar.js
will provideb/foo/bar.js
from thelib
package.
- 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:will makeimport{jQueryFactory}from"jquery/factory";const$=jQueryFactory(window);
$
point to jQuery, wherewindow
is a browser-compatiblewindow
implementation. - For compatibility reasons, both:in ESM files and:
import$from"jquery";
in CommonJS files need to continue working, withconst$=require("jquery");
$
pointing to jQuery. - 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);
- Regardless of whether Node.js or a popular bundler is used to run/build jQuery, if a single project fetches jQuery both via
import
andrequire
, they need to point to the same copy of jQuery. require
should work in an environment supporting only CommonJS;import
needs to work in an environment supporting only ESM.- The
src
directory needs to be fully exposed for more advanced usage; only ESM is supported there.
As we have four entry points plus all the files insrc
, we are starting with:
"exports": {".": {},"./slim": {},"./factory": {},"./factory-slim": {},"./src/*.js":"./src/*.js" },"main":"dist/jquery.js",
We are keepingmain
for backwards compatibility with tools not supportingexports
. It is not that rare; for example, even the newest versions of TypeScript ignoreexports
ifmoduleResolution
is set tonode10
or its legacynode
alias.
The line"./src/*.js": "./src/*.js"
exposes all JS files insrc
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 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 thedefault
condition, we need to add one pointing to CommonJS - but then we don't need therequire
condition as thedefault
one can be reused:
".": {"import":"./dist-module/jquery.module.js","default":"./dist/jquery.js"}
However, this means projects fetching jQuery via both the ESMimport
and the CommonJSrequire
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 onescannot synchronously require from ESM ones. This is because ES modules are inherently async. This means we can create a small ESM wrapper file calledjquery.node-module-wrapper.js
with the following contents:
importjQueryfrom"../dist/jquery.js";export{jQuery,jQueryas$};exportdefaultjQuery;
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 thenode
condition.
Our updatedexports
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 thenode
condition. However, they usually report themodule
one, regardless of whether the ESMimport
or the CommonJSrequire
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 viaimport
but also allow CommonJS files to synchronously fetch ESM files viarequire
. 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 thenode
section, changing the key fromnode
tomodule
. 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 thejquery.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-levelimport
&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"}
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:
- We don't use the default export in ESM. We use one named one:
jQueryFactory
. - 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 whetherimport
orrequire
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"}
The final full version ofexports
&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",
Some tools, likeWebpack orParcel support thedevelopment
&production
conditions. They can be especially useful if a library ships different development code, e.g. adding some debugging features. Some other tools, likeRollup, 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 hugeexports
definition - we'd have to split almost every current path to itsdevelopment
&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.
The only entry points Node.js supports arenode
,node-addons
,import
,require
, anddefault
. 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 globalwindow
, one can simulate it via jsdom. We've already discussed thedevelopment
&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.