Resources
What & Link | Type |
---|---|
JavaScript.info : Modules Chapter |
Guide |
Impatient JS: Chapter on Modules (24) (from 2ality) - Another Link |
Book Chapter |
Flavio Copes guides to ES Modules | Code examples |
Tyler McGinnis's guide to Modules (very comprehensive) | Comprehensive blog post |
FreeCodeCamp (Preethi Kasireddy) post on Modules, lots of code samples | FreeCodeCamp Post |
CanIUse for ES6 (ECMAScript 2015), which includes modules | Browser Coverage |
ES6 vs CommonJS Examples (2ality) | Code Examples |
Dixin Yan: Understanding (all) JavaScript Module Formats and Tools | Guide |
What is a module?
Simply put, a module is a grouping of code into a distinct unit (the module) that is separated from other code both in physicality (separate file and/or code wrapping) and in functionality (variable scoping, namespaces, etc.)
Modules are isolated, yet reusable,
Native vs Non-Native
Modules have historically not been supported natively in JS, so many environments (nodejs) used a non-native implementation/flavor of modules, such as CommonJS.
The terminology has gotten a little convoluted over time, but currently there is really only one native JS module format, usually referred to as ES6 Modules or just ES Modules.
- These are very common, in both JS and TS
There are many non-native module solutions:
- CommonJS
- AMD (Asynchronous Module Definition)
- UMD (Universal Module Definition)
These non-native formats require loaders, such as requirejs, in most environments, such as the browser.
Hand-coded / Generic modules
Because the term "module" is generic and just refers to the idea of isolated building blocks, there are many ways to build modules that don't rely on a standardized definition/spec like CommonJS. In fact, a basic IIFE (Immediately Invoked Function Expression) could be considered a module format, since it provides closure/scoped vars:
// Module 'customDebug'
(function(){
// These variables are local scoped to IIFE/module
var defaultDebugLevel = 1;
var currentDebugLevel = 1;
var customDebug = {
setDebugLevel: function(level){
currentDebugLevel = level;
},
logMsg: function(msg,dbgLevel){
if (dbgLevel <= currentDebugLevel){
console.log(msg);
}
}
}
// This will expose module to global namespace
App = typeof(App)!=='undefined' ? App : {};
App.customDebug = customDebug;
})()
Syntax differences between module options:
Module Type | Import Syntax | Export Syntax | Notes |
---|---|---|---|
CommonJS | const fs = require('fse'); Using ES6 Object Destructuring: const {foo, bar} = require('myfile.js'); |
Per file, define module.exports (can be function, object, etc.)If you want to emulate ES6 export default , just assign a single thing to module.exports , e.g. module.exports = function(){}; Short syntax ( module.exports.myVar = 'test' ) can often be used |
- Used by NodeJS - Default is one def per file |
AMD | require(['dependencyAlpha','dependencyBeta'],function(depA,depB){ // Do something }); |
define('moduleName',['dependencyAlpha'],function(depA){ // Return what the module should be equal to }); |
- Unlike CJS, can have multiple defs per file |
UMD |
From FreeCodeCamp |
Depends on environment, see code example for import to see how it exposes the factory | - Basically a combination of AMD+CommonJS. - Flexibility allows it to work in multiple environments - Trade-off is less legible generated code |
ES Modules | import ModuleA from 'my-modules'; import {ModuleA,ModuleB} from 'my-modules'; import {ModuleA as alpha, ModuleB as beta} from 'my-modules'; import * as myModules from 'my-modules'; Importing for side effects: import 'my-modules/path Interop with CommonJS single default export in TypeScript (see docs): import MyModules = require('my-modules'); Dynamic importing* (limited support) via: import('./lib/my-module').then((module)=>{}); or: const module = await import('./lib/my-module'); const {default: myDefault, alpha, bravo} = await import('./lib/my-module'); Importing via <script type="module"> tag in the browser is |
Just prefix basically anything with the export or export default keyword.export const getUsernameById = (userId) => {//...}; You can also alias on export: export {ModuleA as alpha}; You can even re-export imports: export {ModuleA as Alpha} from 'my-modules'; export {default as Logger} from './my-logger'; If you need to also use the item while re-exporting it as an import, this works: import {ModuleA, ModuleB} from 'my-modules; export {ModuleA, ModuleB}; |
- You can have multiple named exports per file, one default per file - Named exports are preferred over export default , for tree-shaking |
ES Modules - further notes
The Re-Export Pattern
You might have noticed that in many projects, especially large code-bases and libraries, there is a pattern being followed where nested folders will have an index.{js|ts}
, which simply re-exports a bunch of things from aforementioned folder (or below).
The benefits to doing this are:
- A) By passing things "up" the chain, they can be reached from a single entry point
- This is very important for NPM / Node packages, and is why you often see the pattern being used in libraries / packages
- This is a way to explicitly create a public interface for a library, with easier access to deeply nested modules
- B) Similar to the above point - avoids long and messy deep import paths
- Instead of a developer needing to write:
import {create as createAlpha} from './models/shopping/widgets/v2/alpha
- ...they might be able to just write
import {createAlpha} from './models'
- Instead of a developer needing to write:
You can even alias as you re-export, including aliasing default
(although caution is recommended). You can also re-export everything from another module, which is a common pattern:
// Folder's index.js
// Re-export everything from other files in folder
export * from './alpha';
export * from './beta';
// ...
💡 Tip: To pass things upwards, you don't have to create an index.js
at every level of nesting; you can skip levels, and use deep links / longer import paths in a specific index.js
closer to the top level.
📘 Read more: javascript.info, Digital Ocean: ReactJS Public Interrfaces
Dynamic imports
As noted in the table above, dynamic imports in ES Modules is a newer feature, that is not fully supported everywhere.
- Node:
- Supported V9.7+ (reminder, you can check from CLI with
node -v
)
- Supported V9.7+ (reminder, you can check from CLI with
- Browser:
- A TC39 proposal, which should land fully in ES2020
- CanIUse link - As of 10/24/2019, around 84% support
📘 Read more about dynamic imports on
JavaScript.info
👉 Here is a nice write-up of the feature and some examples on how to use it: v8.dev
Why default export is bad
If you start looking up information about ES Module exports, you will likely start seeing posts and headlines talking about why export default
is bad and should be avoided.
Here is a brief summary of the faults of export default
- Makes it harder, or impossible, for bundling / minifying programs to "tree-shake", e.g. remove dead un-used code.
- If you are concerned about performance and keeping request size down, this is important.
- (Arguable) - Decreases readability and increases ambiguity
- To me, using default exports all over the place can lead to some readiblity issues, because it tends to encourage aliasing and renaming. Example:
- In one file, a dev might choose to use
import * as couch from './furniture/sku-24.js'
- In another file, another dev might choose to import the same default as
import * sofa from './furniture/sku-24.js'
- If I'm just eye-balling these files, and not looking at the top of every file, I might not notice at first that these are pulling from the same file. And searching for specific imports might give me trouble too.
- In one file, a dev might choose to use
- To me, using default exports all over the place can lead to some readiblity issues, because it tends to encourage aliasing and renaming. Example:
- (Arguable) - Although it makes refactoring harder, it also makes it more explicit
Benefits of export default
- Easier to refactor
- Because you can easily alias without knowing the actual name of the export within the file, if that export is changed, you don't need to update the files that consume it
- However, this same benefit could be achieved by using a class as a named export instead of default, and changing the names of methods in the class instead
- Can make writing code a tiny bit faster, since there is slightly less boilerplate
Due to the mix of drawbacks and benefits, some people prefer to use a mix. For example, if your file is mostly centered around a class, let's say Person
, you might export the class as the default, but then export things related to that class as named exports. For example:
export default class Book {
constructor({name, author}) {
this.name = name;
this.author = author;
}
//.. tons of members, methods, etc.
}
// Named export
export const oz = new Book({
name: 'The Wonderful Wizard of Oz',
author: 'L. Frank Baum'
});
If you are looking for a thread full of arguments on the topic, this Airbnb JS style guide issue thread is a pretty lively read.
Warnings, Common Issues, FAQ
Be careful about ES Modules / CommonJS Interop Issues
Interweaving CJS imports with ESM code is notoriously error-prone, especially when using default exports. Often, no errors will show in your IDE, and type intellisense might even work, but a runtime error will be thrown, such as:
UnhandledPromiseRejectionWarning: TypeError: my_module_1.default is not a function
I've written about this issue more in-depth in my TypeScript page, but I'll paste the same possible solutions below:
- Try the default interop syntax (for TS):
import myImport = require('my-lib');
- Try changing
const myImport = require('my-lib');
toconst myImport = require('my-lib').default;
- If you are the library author, try changing
export default ___
toexport = ____
in your type definition file - Try changing
import * as myDefault from 'my-lib'
toimport myDefault from 'my-lib'
If you are trying to mix CJS with ESM, including old packages that require synthetic imports to work, this might be the magic settings you need:
tsconfig.json
:
{
"compilerOptions": {
"module": "CommonJS",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
}
}
Import syntax:
import IgnoreWalk = require('ignore-walk');
Be careful about side effects and code outside defined exports
When you use the "files as modules" pattern to structure your app (where there is a maximum of one module per file), it is easy to forget that files are not modules themselves, and if you "do something" outside of code that is wrapped in an export
, any time you import from that file, that code is potentially triggering other things to happen, importing, etc.
This is also an easy way to trigger the ___.default is not a constructor error
.
Beware of Circular Dependencies!
In a normal situation, you might have a given module, let's say Module A
, imports or requires something from another local module (a dependency), let's call Module B
. This can be represented as:
Module A <-- Module B
A circular dependency is when you import / require Module A from Module B, but Module A also imports / requires something from Module B:
Module A < - > Module B
This can lead to some really nasty bugs that are hard to track down, especially if you are unaware that they are being caused by a circular dependency. I speak from experience ðŸ˜
💡 Getting a lot of
TypeError
(s) that don't correspond to the source code is one good hint that you might be dealing with a circular issue.
The best way to deterministically test for a circular dependency is to use a tool designed to do exactly that. I've had really great success with Madge for this. Using it is as easy as npx madge --circular src/my-file.ts
!
Fixing circular dependencies is a complicated topic. This post by Weststrate covers some great approaches and also explains some of the details in how circular dependencies occur.
How to Import JSON?
Usually, this is pretty easy with standard bundling tools:
For CommonJS, this should work across the board:
const config = require('./config.json');
But with ES Imports, this will only work with certain (common) loaders:
import * as config from './config.json';
And for webpack
(e.g. for CRA), this should work:
import config from './config.json';
For TypeScript, you will need to set
resolveJsonModule
to true, either via CLI flag, or viatsconfig
.
If you don't need type inference or any static information, the most robust way is to just not use the module system at all for loading JSON, and stick to file system methods, either native, or with something like fs-extra
's readJson
command.
Why Use ES6 Modules / ES Modules
There are a bunch of benefits to ES Modules:
- Static in nature
- This has a lot of side-benefits, related to type-checking, transpiling, supporting async loading, and tree-shaking
- Eventually (as it gains adoption), will make writing native cross-environment (aka isomorphic, NodeJS + Browser) code easier
- Standardized syntax (arguable)