packages/transformers/js/hoist.md
This document describes how the SWC-based scope hoisting implementation works, in comparison to the previous implementation.
Scope hoisting is the process of combining multiple JavaScript modules together into a single scope. This enables dead code elimination (aka tree shaking) to be more effective, and improves runtime performance by making cross-module references static rather than dynamic property lookups.
Parcel has historically implemented scope hoisting in JavaScript on top of Babel ASTs. It operated in 3 phases:
$parcel$require calls inserted by the hoist phase. This also handled wrapping modules that were required from a non-top-level statement to preserve side effect ordering. Operates on module ASTs.The new scope hoisting implementation operates in just two phases.
The hoist phase should do as much work as possible as it operates on ASTs and and in parallel on individual files. It's also implemented in Rust using SWC for performance.
This is implemented in two passes. The first pass analyzes and collects data about the module, which is used in the second pass, which actually transforms the module.
In the first pass, we collect:
exports object is referenced non-statically. This means we will need to always use the namespace rather than resolving exports statically.eval, top-level return, non-static module access, exports re-assignment, etc.In the second pass, we transform:
import "module_id:dep_specifier"; is hoisted to the top of the module. This indicates where the dependency code should be inserted by the packager later.require calls, an import like above is inserted before the current top-level statement. This is new in this version, and prevents us needing to search for $parcel$require calls in statements to find where to insert the code in the packager. If the require is anywhere except a top-level variable declaration, it is marked as wrapped to preserve side effect ordering (also more conservative than before). The require is replaced by an identifier referencing the * symbol in the dependency, or the relevant symbol if in a statically resolvable member expression.eval.The output from Rust is an object which is used by the JSTransformer Parcel plugin to add symbols to dependencies and the asset itself, along with some metadata that is used by the packager.
The packager operates purely on strings now rather than ASTs. This makes it much faster since it doesn't need to deserialize a bunch of ASTs from disk and perform codegen.
The packager visits dependencies recursively, starting from the bundle entries, and following the import statements left by the hoist transform. It resolves the import specifiers to dependencies, follows the dependency to a resolved asset, and then recursively processes that asset. The import statement is replaced by the processed code of the dependency.
For each asset, we look at the symbols used by each dependency and resolve them to their final location, following re-exports. This is done by Parcel core in the bundle graph. We perform a string replacement for each temporary import name to the final resolved symbol. If the resolved asset is wrapped, we use parcelRequire to load it, and if it has non-static exports, we use a member expression on the namespace object.
The packager also synthesizes the exports object when needed. If the namespace is used, the asset has non-static exports, or it is wrapped, a namespace is declared, and each used symbol is added to the namespace using the $parcel$export helper. This is different from the previous implemetation, which added the exports object in the link phase, and then removed it if unnecessary in the link phase. Now we do the opposite, which makes it possible to operate on strings rather than ASTs.
If the asset is wrapped, we use the parcelRequire.register function to register it in a module map. This is used both when the asset is required in a non top-level context, and also when the asset has non-statically analyzable code (e.g. eval). Previously we wrapped in hoist for the latter. In this case, a module and exports object are passed in, and we use those instead of the locally declared exports object, which makes circular dependencies work.
import "module_id:specifier" statements rather than inline $parcel$require calls. This indicates where to insert the dependent code, and no longer requires the packager to search for requires inside statements.require or a member expression with a require are handled without wrapping. This should solve cases like #5606. In addition, if any non-static access of an import occurs, we always use the namespace object rather than using static references for some imports and dynamic references for others. Same goes for exports.parcelRequire.register for wrapping rather than $init calls. This should allow us to share a module map between bundles and solve some side effect ordering/circular dependency issues.The following examples demonstrate how the scope hoisting implementation works.
// a.js
import {b} from './b';
b();
// b.js
let b = 2;
export {b};
// a.js
import 'id:./b';
$id$import$b$b();
// b.js
let $id$export$b = 2;
imported symbols:
| Local | Specifier | Imported |
|---|---|---|
| $id$import$b$b | ./b | b |
exported symbols:
| Exported | Local |
|---|---|
| b | $id$export$b |
let $id$export$b = 2;
$id$export$b$b();
// a.js
import {b} from './b';
b();
// b.js
exports.b = 2;
// a.js
import 'id:./b';
$id$import$b$b();
// b.js
let $id$export$b = 2;
imported symbols:
| Local | Specifier | Imported |
|---|---|---|
| $id$import$b$b | ./b | b |
exported symbols:
| Exported | Local |
|---|---|
| b | $id$export$b |
let $id$export$b = 2;
$id$export$b();
// a.js
const {b} = require('./b');
b();
// b.js
exports.b = 2;
// a.js
import 'id:./b';
$id$import$b$b();
// b.js
let $id$export$b = 2;
imported symbols:
| Local | Specifier | Imported |
|---|---|---|
| $id$import$b$b | ./b | b |
exported symbols:
| Exported | Local |
|---|---|
| b | $id$export$b |
let $id$export$b = 2;
$id$export$b();
// a.js
const {b} = require('./b');
b();
// b.js
let b = 2;
export {b};
// a.js
import 'id:./b';
$id$import$b$b();
// b.js
let $id$export$b = 2;
imported symbols:
| Local | Specifier | Imported |
|---|---|---|
| $id$import$b$b | ./b | b |
exported symbols:
| Exported | Local |
|---|---|
| b | $id$export$b |
let $id$export$b = 2;
$id$export$b();
// a.js
const b = require('./b');
b[something]();
// b.js
exports.foo = 2;
// a.js
import 'id:./b';
$id$import$b[something]();
// b.js
let $id$export$foo = 2;
imported symbols:
| Local | Specifier | Imported |
|---|---|---|
| $id$import$b | ./b | * |
exported symbols:
| Exported | Local |
|---|---|
| foo | $id$export$foo |
let $id$export$foo = 2;
let $id$exports = {};
$parcel$export($id$exports, 'foo', () => $id$export$foo);
$id$exports[something]();
// a.js
const b = require('./b');
b.foo();
// b.js
exports[something] = 2;
// a.js
import 'id:./b';
$id$import$b$foo();
// b.js
$id$exports[something] = 2;
imported symbols:
| Local | Specifier | Imported |
|---|---|---|
| $id$import$b$foo | ./b | foo |
exported symbols:
| Exported | Local |
|---|---|
| * | $id$exports |
let $id$exports = {};
$id$exports[something] = 2;
$id$exports.foo();
// a.js
const b = require('./b');
b[foo]();
// b.js
exports[something] = 2;
// a.js
import 'id:./b';
$id$import$b[foo]();
// b.js
$id$exports[something] = 2;
imported symbols:
| Local | Specifier | Imported |
|---|---|---|
| $id$import$b | ./b | * |
exported symbols:
| Exported | Local |
|---|---|
| * | $id$exports |
output:
let $id$exports = {};
$id$exports[something] = 2;
$id$exports[foo]();
// a.js
function test() {
return require('./b').foo;
}
// b.js
exports.foo = 2;
// a.js
import 'id:./b';
function test() {
return $id$import$b$foo;
}
// b.js
let $id$export$foo = 2;
imported symbols:
| Local | Specifier | Imported |
|---|---|---|
| $id$import$b$foo | ./b | foo |
exported symbols:
| Exported | Local |
|---|---|
| foo | $id$export$foo |
wrapped dependencies:
./bparcelRequire.register('b', module => {
let $id$export$foo = 2;
$parcel$export(module.exports, 'foo', () => $id$export$foo);
});
function test() {
return parcelRequire('b').foo;
}
These are the patterns that we are able to fully statically analyze in CommonJS modules. This means that requires can be replaced with static variable references to the imported module's exports. The exports are static, so are replaced with individual variables for each exported symbol.
require('y');
require('y').foo;
require('y').foo();
const y = require('y');
const x = require('y').x;
const {x} = require('y');
const {x: y} = require('y');
const {x = 2} = require('y');
// Safe but needs to be split into separate declarations.
const a = sideEffect(),
b = require('y');
exports.foo = 2;
module.exports.foo = 2;
this.foo = 2;
exports['foo'] = 2;
function test() {
exports.foo = 2;
}
These patterns require a namespace object to be used instead of static references.
require('x')[something];
const x = require('x')[something];
const x = require('x');
x[something];
const {x, ...y} = require('x');
x = require('y');
({x} = require('y'));
exports[foo] = 2;
module.exports[foo] = 2;
this[foo] = 2;
sideEffect(exports);
sideEffect(module.exports);
These patterns require the imported module to be wrapped to preserve side effect ordering.
function x() {
const x = require('y');
// etc.
}
const x = sideEffect() + require('b');
const x = sideEffect(), require('b');
const x = sideEffect() || require('b');
const x = condition ? require('a') : require('b');
if (condition) require('a');
for (let x = require('y'); x < 5; x++) {}
// etc.
// Exports re-assigned
exports.foo = 2;
exports = {};
exports.bar = 3;
({exports} = something);
// Module accessed non-statically
sideEffect(module);
// Eval
eval('exports.foo = 2');
// Top-level return
return;