The next time the same module is loaded again without changes, if the browser is not updated, it will typically just reuse the bytecode read from disk. Like parsing compilation in browsers typically happens off-thread. Also most JavaScript engines built for browsers don't parse in their functions in the modules at first, like here. The top-level constant assignment will be fully parsed and compiled when the module is loaded. But the function add won't be fully parsed or compiled until it's invoked for the first time. Node also has started to support on-disk compilation cache since 22, although it's currently updated via the no-compile-cache variable or the module enable-compile-cache API, or the use-code-cache option if you're building a no-sync-code application. The bytecode is also only reused when the module is not changed and run in the same Node version. And like parsing compilation in Node happens in the main thread. Currently Node also only pre-parses in their functions and does not generate code for them until they are invoked, although this can also be made configurable in the future because Node usually provides more low-level extensions than browsers.
So after compilation, we've got the bytecode of one module, what's next? We need to move on and handle its dependencies, if any. For example, here we have a module, main.js, that imports a package. That package in turn loads the Node path built-in and another utils module. We need to iterate through these module requests, we need to resolve their specifiers to URLs, load the source code from the resolved URLs, parse and compile their code, and repeat until the dependency graph is transitively loaded. At this stage, if any of the transitive dependencies can be resolved, loaded, or parsed, Node will throw an error because any module code in the graph, before any module code in the graph is executed. Like here, for instance, if the package utils.js doesn't exist, it fails to resolve, and Node will error out and you won't execute any code, even if you place it before the import statements, like the first console log here won't be executed because according to language specification, import statements are hoisted and executable code will run much later before the dependencies are loaded, regardless of source locations.
And after we compile the entire graph transitively, the next stage is instantiation. This is when V8 tries to match the import and exports and look for mismatches. In the language specification, this stage has been renamed to linking. By Node and V8 terminology, this is still called instantiation, so we also call it instantiation for now. Here, for example, main.js imports a name, add, from package and the package does not have an export called add. So these references will be bound together, and package in turn imports n underscore add from another package, and it also imports the default export from node path built-in. And these references will also be bound together by V8 during instantiation. And at this stage, if a module imports a name from another module that doesn't export it, V8 will throw an error. And this is why we call it instantiation, because if it's a module that doesn't export it, V8 will throw an error. And again, this happens before any code is executed. Like here, if main.js importing the name that does not get exported from a package, the console.log statement before the imports statement would not run. So earlier we talked about using module.register.hooks in coding, but how about customizing the stages from parsing to instantiation? These stages are more special in ESM and cannot share primitives with CommonJS via the same module.register.hooks API. We have the VM modules API. It's been used by a few testing frameworks for lightweight isolation. This was the reason why ESM support in Jest is still experimental, because they rely on this API that has been experimental since 2017.
Comments