Imports in the Runtime Environment of Webpack 4
Webpack is an important tool in the current evolution of the JavaScript ecosystem. It does a tremendous amount of work to bundle source code modules into resources that the browser can load and parse. Because users will be interacting with web pages running the generated bundle rather than the source, I think it’s especially important that developers understand how Webpack is manipulating and packaging their code.
I’ve created a contrived application to use as a vehicle for understanding how Webpack handles static ES module imports. The referenced bundles were created with the source found in this repository which uses Webpack 4.44.2. Follow the instructions in the README
if you’d like to generate the same bundle.
Now let’s dig in.
The Application
Webpack supports ES modules, CommonJS, and several other module import methods which can be found here, including dynamic import expressions, which are all bundled a little differently from each other. In this case, I’ll be using the ES module syntax, import { fn } from './module;'
, since it’s the most up-to-date and is also supported by modern browsers/Node.js via the .mjs
extension.
Webpack config
The webpack.config.js
file used for this project is fairly minimal.
Webpack 4 provides a default configuration that helps projects get off the ground quickly. In this case, I’ve only added a mode
configuration as well as the html-webpack-plugin
.
Providing a mode
of 'development'
will yield an easily debuggable main.js
file that includes some helpful comments from the Webpack team.
The html-webpack-plugin
generates an index.html
file that’s already referencing the generated Webpack bundle. There are many configuration options available if you want a more robust HTML document, but none were needed for this project.
Source
The entry file contains the bulk of the application logic. It’s the beginnings of a to-do list program, but that’s not important. What’s important are the imports.
L1 (line 1) and L2 contain import statements. The listItemTemplate
and complete
functions will be available once those statements have been parsed. Because ES modules are unavailable in most browsers, Webpack needs to do some work behind the scenes to expose those exports to the code importing them.
The Bundle
npm run webpack
creates a dist
directory that contains all of the code exposed in src/index.js
as well as the HTML generated by html-webpack-plugin
. The dist/main.js
file contains Webpack’s runtime code along with the original source.
Source
Before diving into Webpack’s bootstrap, I think it’s worthwhile to see what happened to the source. Scrolling to the bottom of the main.js
file reveals an object being passed into the call of an IIFE(Immediately Invoked Function Expression)–the object’s keys are paths to the source files, and the values are functions tasked with encapsulating the source code, which has some obvious additions and alterations.
The source code has been stringified, and Webpack’s dependencies have been scattered throughout. When eval
is called, the stringified JavaScript will be executed and those Webpack functions will be called as well–exposing the module code to the encapsulating closure. The details of this exposure will be outlined as we parse Webpack’s require function.
Webpack Bootstrap
At the top of the file is the instantiation of the IIFE referenced above, which has been defined by Webpack in a comment as webpackBootstrap
. It’s basically the declarations of all the functions and variables Webpack needs to load bundled code, as well as the execution of that code.
The modules
argument contains the modified source broken into functions with their corresponding file paths as keys. Next, a number of variables and functions are declared to keep track of and load the various modules and chunks derived from the source.
Caching & module loading
installedModules
is declared and set to an object on the first line of the function body. It’s used as a hash table for caching loaded modules. Any time a module is loaded, its exports are memoized for quick look-ups later by the __webpack_require__
function. This is a performance optimization for situations where a module is imported into multiple files, bypassing a reload, as well as an attempt at parity with the ECMAScript spec. Modules are essentially singletons, meaning only a single instance of the module is referenced once it has been instantiated or in this case loaded.
Let’s say that entry.js
imports foo.js
on L1 and bar.js
on L2. When foo.js
is parsed, all exports from utils.js
will be loaded and cached in installedModules
. Once bar.js
is loaded, Webpack will once again attempt to load utils.js
. Webpack will check the installedModules
cache to see if the module exists, which it will, and then will return the cached exports rather than loading the module again.
Loading static modules
Initial runtime will expose any statically imported modules to the greater execution context. This is done mostly through __webpack_require__
, which is declared early in the webpackBootstrap
but called on the last line of the function body. Here’s a diagram that outlines critical high-level execution steps for static module loading:
Once Webpack’s resources have been loaded and parsed, __webpack_require__
is called with a string representing the path to the main entry point as an argument. This loads the module, loads that module’s static imports, and eventually returns the main entry point.
Webpack’s require function
The __webpack_require__
function is the primary module executor. It is passed into each module as an argument, exposing the evaluated contents to the parent scope. This dependency injection along with stringifying the original source gives Webpack agency to load modules as synchronously or asynchronously as the source demands.
Stepping through, the moduleId
argument is a string representing a path to a module like we saw earlier in the bootstrap’s modules
argument. In the case of this app, the moduleId
will be './src/index.js'
.
The installedModules
cache is checked for the module, and if it exists, execution is stopped and the exports property of that module is returned. If it does not exist, a new module is created (L9) with the moduleId
string set to the i
property, which is essentially an id. The l
, or loaded, property is set to false
, and an exports object is created. Here’s how module
looks at this point in the execution:
{
i: './src/index.js',
l: false,
exports: {}
}
and here’s installedModules
:
{
'./src/index.js': {
i: './src/index.js',
l: false,
exports: {}
}
}
We can see that this particular module still hasn’t been loaded, because the l
property is false
.
The function at modules[moduleId]
is called on L17 with the module.exports
object as context, which is currently the empty object instantiated on L14, and with the arguments module
, a reference to installedModules[moduleId]
aka the module
object written out above, and __webpack_require__
itself. These arguments reflect the parameters seen earlier in the modules
argument object.
Evaluating a module function
Contextually, we’re still in the require function, but we’ve stepped into the function call where a module is being evaluated. Now that we’re fairly well acquainted with the majority of the function body of __webpack_require__
, we should figure out what’s going on in a module when that module is called. I’ll step through this starting on the first line of the eval
call, since I’ve already outlined the arguments in the paragraph above.
__webpack_require__.r
is a function defined in the Webpack runtime that receives an object as an argument, and dresses that object up to look like a CommonJS module as well as an ES Module if the import
statement is used.
Next, renderUtils__WEBPACK_IMPORTED_MODULE_0__
is declared, and set to the return value of another __webpack_require__
call, this time with an argument of './src/renderUtils.js'
. This will add another entry to installedModules
, and run through the require flow with that module as well.
Several lines later, _todo__WEBPACK_IMPORTED_MODULE_1__
is declared, and set to the return value of __webpack_require__('./src/todo.js');
, adding yet another entry to installedModules
.
At this point, installedModules
contains 3 keys–'./src/index.js'
, './src/renderUtils.js'
, and './src/todo.js'
. Let’s see how it looks.
The l
attribute of '.src/index.js'
is still marked as false
, because it’s still loading. Its dependencies–renderUtils
and todo
–have been loaded and their exports are available for use, which is clear from looking at the child properties of exports in the image above.
Once a module has access to its dependencies, the imported functions are able to be accessed and executed.
Object constructor call
Execution may look strange–functions are accessed, passed into Object
calls, and then called rather than simply being called as methods.
This call may seem unnecessary at first since passing a function into the Object
constructor yields that same function back, but Webpack wouldn’t include this in the bundle unless it had a purpose.
var foo =
_renderUtils__WEBPACK_IMPORTED_MODULE_0__['listItemTemplate'];Object(foo)() === foo; // true
According to spec, imported ES modules are in strict mode by default. Functions in strict
mode will block access to the window
object via the this
keyword. Because Webpack does its best to stick to the spec of each of the various import methods, ES modules bundled with Webpack have to behave the same way–this
, when referencing the window
object in strict
mode, should return undefined
.
When Webpack exports a module, it exposes an exports
object rather than what would natively be something akin to a static binding. Functions that were previously unbound will now be bound to the context of the exports object. Passing these strict functions through Object
constructor calls removes that binding.
The rest of the file is evaluated as it was written, resulting in the code being executed.
Stepping back into require
Back in the original require function, L18 sees the module’s l
(loaded) property set to true
, and then the exports
property is returned. At this point, the module
object has been updated to look like this:
{
i: './src/index.js',
l: true,
exports: {
Symbol(Symbol.toStringTag): 'Module',
__esModule: true
}
}
The exports
module has been updated to represent CommonJS and ES modules. If it had exports, like renderUtils
does, it’d include those exports in the exports object as getters.
That’s about all there is to it. Once __webpack_require__
has exposed all modules, execution ceases and the app has been fully loaded and is idling.
Conclusion
Webpack does its best to be a reliable and performant partner when it comes to executing provided source. In the end there’s no real magic happening here, just plain old JavaScript fundamentals. __webpack_require__
is Webpack’s way to use dependency injection at runtime to expose imports to other scopes. Because file contents are stringified, Webpack can pass in arguments that expose exports in any number of ways. Once the content is evaluated, the application will be running as it was implemented.
Please feel free to contact me with any questions, issues, or clarifications.
Resources
Webpack 4 Analysis repository
https://github.com/mskalandunas/webpack-4-analysis/tree/master/static-imports
Webpack docs
https://webpack.js.org/
Webpack repository
https://github.com/webpack/webpack
TC39 2021 ECMAScript specification
https://tc39.es/ecma262/
MDN
https://developer.mozilla.org/en-US/
Stack Overflow
https://stackoverflow.com/questions/64186011/why-does-webpack-pass-functions-imported-as-es-modules-into-an-object-call-in-we