π³ Scope Hoisting
What scope hoisting is and how it enables smaller builds and ESM output
ΒΆ Tips for smaller/faster builds
ΒΆ Wrapped Assets
There are a few cases where an asset needs to be wrapped, that is moved inside a function. This negates the advantages of scope-hoisting because moving the exports into the top-level was our original objective.
-
If a top-level
return
statement oreval
are being used or amodule
variable is used freely (module.exports
is fine), we cannot add it into the top-level scope (becausereturn
would stop the execution of the whole bundle andeval
might use variables that have been renamed). -
If an asset is imported conditionally (or generally in a try/catch, a function an if statement) using CommonJS
require
, this isn't possible with the ESM syntax), we cannot add it into the top-level scope because its content should only be execute when it is actually required.
ΒΆ sideEffects: false
When sideEffects: false
is specified in the package.json
, Parcel can skip processing some assets entirely (e.g. not transpiling the lodash
function that weren't imported) or not include them in the output bundle at all (e.g. because that asset merely does reexporting).
ΒΆ Motivation and Advantages of Scope Hoisting
For a long time, many bundlers (like Webpack and Browserify, but not Rollup) achieved the actual bundling by wrapping all assets in a function, creating a map of all included assets and providing a CommonJS runtime. A (very) simplified example of that:
(function (modulesMap, entry) {
// internal runtime
})(
{
"index.js": function (require, module, exports) {
var { Foo } = require("./thing.js");
var obj = new Foo();
obj.run();
},
"thing.js": function (require, module, exports) {
module.exports.Foo = class Foo {
run() {
console.log("Hello!");
}
};
module.exports.Bar = class Bar {
run() {
console.log("Unused!");
}
};
},
},
"index.js"
);
This mechanism has both advantages and disadvantages:
- + The bundle can be generate very quickly, the asset's sources is simply copied into a string.
-
β It is hard to optimize because the
require
function makes it hard to statically analyze which exports are used (think oflodash
) and whether a asset that only does reexports could be removed entirely. -
β To generate a bundle that does ESM exports, the
export
declarations cannot be inside of functions.
ΒΆ Solution
Instead we take the individual assets and concatenate them directly in the top-level scope:
// thing.js
var $thing$export$Foo = class {
run() {
console.log("Hello!");
}
};
var $thing$export$Bar = class {
run() {
console.log("Unused!");
}
};
// index.js
var $index$export$var$obj = new $thing$export$Foo();
$index$export$var$obj.run();
As you can see, the top-level variables from the assets need to be renamed to have a globally unique name.
Now, removing unused exports has become trivial: the variable $thing$export$Bar
is not used at all, so we can safely remove it (and a minifier like Terser would do this automatically), this step is sometimes referred to as tree shaking.
The only real downside is that builds take quite a bit longer and also use more memory than the wrapper-based approach (because every single statement needs to be modified and the bundle as a whole needs to remain in memory during the packaging).