JavaScript Modules
Since the release of JavaScript version ES6 in 2015 and the switch to a yearly release schedule, the JavaScript language has been reborn as a powerful, full-featured language that is both fun and easy to use. The need for backward compatibility means that there are still a few clunky areas, but overall the language is in a good place now. We have been referring to these new features modern JavaScript, and we’ll continue to do that here.
Perhaps the most important new feature added to JavaScript recently is the ability to split our code up into many small modules. Using old-school JavaScript, we either had to write everything in one huge file, sometimes thousands of lines long, use a non-standard solution such as
browserify or
require.js, or include lots of separate <script>
elements in our HTML files.
The new “official JavaScript modules” are called ES6 Modules, and using them, we can break our app down into discrete components, and put each of these components into a separate file. Doing so leads to a huge improvement in code style and re-usability.
As with our previous chapter on JavaScript, we’re won’t attempt a complete description of ES6 modules here. We’ll only cover the bits you need to know to get through this book.
When writing modular JavaScript, each file is a module. So, we may refer to a module by its file name, for example, main.js, or simply as the main module.
Modules in Other Environments
Modules are an official feature of JavaScript so they will be supported everywhere… eventually. All modern browsers now support ES6 modules, however, Node.js has been slow to catch up. Fortunately, as of Node v14, ES6 modules are fully supported. However, when using older node versions, or very old browsers, you may need to do additional work to get modules working.
Modular Software Design
Modular software design opens up a new world of possibilities for structuring an application. Each module we create should have a single, well-defined responsibility. Additionally, each module should be self-contained, so far as possible, and rely on little or no knowledge of other modules.
These are tried and tested design patterns, known as the single responsibility principle, loose coupling, and high cohesion. A well-designed module has a single responsibility and is both loosely coupled and highly cohesive.
In other words, each module should do one thing only, and do that well, without relying on outside help. High cohesion means that the functions inside a module logically belong together. When writing code in this way, each module deals with a tiny fraction of the overall complexity, and even though our applications may grow and become complex over time, at any moment we should be dealing with just a few simple modules.
ES6 Module Syntax: import
and export
ES6 modules introduced two new keywords to JavaScript:
import
and
export
. These allow us to write code in one file, export
it, and then import
it for use in a different file.
We’ll illustrate this here by exporting a variable called x
from a file named export.js.
Later, we can import this variable into main.js, and then log the value to the console or use it in a calculation.
If you open up the inline IDE, you’ll see that we have set up these two files for you. You can use the IDE to test out the rest of the examples on this page.
Import and export Statements can be Placed Anywhere in Module Scope
We don’t have to wait until the end of the file to perform an export. Instead, we can do it immediately when we declare the variable:
We can place import
and export
statements anywhere
in module scope.
However, we can’t import or export while in function or block scope.
In this book, for clarity, we’ll always place import
statements at the top of a module and export
statements at the bottom.
Relative Import URLs
So far, we’ve been using relative URLs to import and export between the main.js and export.js files, which both reside in the src/ folder. We also use a relative URL in the <script>
tag in index.html. You can tell when an import is relative because it will start with ./
or ../
.
We’ve placed export.js in the same directory as main.js, so we’re using ./
. If we had placed it in a subfolder called exported
, for example, the import statement would look like this:
Importing from Other Websites
You can also import from other websites like a CDN by specifying the full web address of the module.
We used this style in the intro when we showed you how to import three.js from a CDN (content delivery network).
For the rest of this chapter, to keep things simple, we’ll stick with relative paths. For more info on how URLs work on the web, refer to a quick primer on URLs and paths on MDN.
Importing from Node Modules (NPM or YARN)
If you’re using a package manager like NPM or Yarn, you can install packages into the node_modules folder:
Once you do this, you’ll find three.module.js in the node_modules/three/build folder. If you like, you can import it directly from there:
However, it’s more common to use a bundling tool such as Rollup.js, Parcel, or Webpack in conjunction with a package manager. These bundlers follow a convention of allowing you use the package name as a shortcut when importing (in this case, the package name is three
). If you are using a bundler, these are equivalent:
For now, remember that if you see an import that’s not a relative import or a website import, but instead start with a package name like three
, it means the code is designed to be used with a bundler and you will not be able to run it directly.
There’s a lot more to using a bundler than this, which we won’t get into here. However, to keep our code clean, in most of the code examples in this book, including in the editor, we will use import { ... } from 'three'
.
Named Exports
The presence of {}
around x
means that this is a named export. To import x
we must refer to it by name, although once imported we can rename it if we need to.
You can have any number of named exports in a file. For example, here’s a file that exports twenty-six names, one for every letter of the alphabet (although we’ve skipped f-y for brevity):
We can import all of these named exports at the same time. Here, we import all twenty-six names (again, skipping the lines f-y):
Renaming Named Exports with the as
Keyword
We can rename named exports using the as
keyword, either when they are exported:
Or, when they are imported:
Using Namespaces with Named Imports
Importing a lot of things from a single module like this can get a bit messy. In these cases, it can be useful to import everything at once from a given module and save it to a namespace. We can do this using import * as <namespace>
:
With a single line, we can import all twenty-six names from the previous file. We can then access them with dot notation:
Note that we can’t rename the individual exports when doing this. It’s a common convention to use all capitals for namespaces, but it’s not required.
The THREE
Namespace
You will often see the THREE
namespace used when working with three.js. The
three.js core contains hundreds of exports. It’s highly unlikely you’ll need to use all of them in a single file, but for quick tests, you can import them all at once and store them in a namespace.
Until the switch to modules, to use three.js you would include the core build/three.js file in your HTML using a <script>
tag, and the THREE
namespace would become globally available.
Now that we’ve switched to modules, we try to avoid using global namespaces. But the THREE
namespace has been associated with three.js for years, and as the examples around the web are gradually converted to modules, it’s faster to continue using the namespace.
In this book, we’ll avoid using namespaces like THREE
, preferring to import components as we need them. This will train us to keep modules focused. Rather than having a huge number of unused components available, we’ll only have the ones we need in any given module.
Default Exports
Unlike named exports, default exports allow us to export a value without naming the export. To create a default export, omit the {}
braces and add the default
keyword:
Default exports don’t have names. Instead, we can name them whatever we like on import. Here, we import the variable x
into the file main.js and call it hello
.
The variable was originally called x
, but that doesn’t matter on import for a default export.
You can only have one default export per file, otherwise, there’s no way for JavaScript to know what export we’re referring to. You can mix default and named exports in a single file, but we’ll avoid doing so. In fact, throughout this book, we’ll avoid using default exports completely.
Referencing JavaScript Modules from HTML
As we mentioned above, when using JavaScript modules, every file is a module. However, modern JavaScript, including ES6 modules, is built on top of old-school JavaScript, and all the old syntax and ways of doing things still work. In old-school JavaScript, files were not modules.
This means, when we pass the main.js module over to the browser, it can be interpreted in one of two ways:
- It’s an old-school “normal” JavaScript file.
- It’s a fancy new JavaScript module.
There’s no way to tell from a glance at the file name which interpretation is correct so we need to tell the browser.
To reference an old-school JavaScript file from HTML we use
the <script>
element. For example, here we include an old-school, non-modular JavaScript file called app.js in index.html:
To tell the browser that the file is a module, we need to add the type="module"
attribute. Here, we include our fancy new main.js module.
We can also write JavaScript directly in an HTML <script>
element:
However, that’s strictly old-school JavaScript. No import
or export
allowed. But, if we add the type="module"
attribute, we can then write import
statements directly in HTML. For example, we can bypass main.js and import the variable x
directly from export.js into index.html.
Dynamic Imports
We’ll finish up this chapter with a brief look at dynamic imports. So far in this chapter, we’ve used static imports, meaning they are evaluated at load time. By contrast, dynamic imports are evaluated at run time.
Static imports use the import
statement, while dynamic imports use the import()
function.
With dynamic imports you can optionally load a module during the execution of your code. This might be useful, for example, if you want to create an app that can load any of the
thirty or so 3D asset formats that three.js supports (there are more than thirty loaders there, but some are for textures and other things). Altogether, these loaders comprise around one megabyte of JavaScript, which is a lot to force upon a poor user if they only need a fraction of it. Instead, you can wait until the user sends you a model file, examine the file and say, “ayup, that there’s an FBX file, better be fetchin' tha FBXLoader
":
Again, take to note that we’re using the dynamic import()
function, not a static import
statement which would look like this:
As we’ll see in the next chapter, .then
means that import()
returns a
Promise. Even better, we can use
the await
keyword, which we’ll also cover in the next chapter:
That’s it for JavaScript modules. Next up, we’ll examine another important aspect of JavaScript: asynchronous programming, otherwise known as how to prevent your app grinding to a halt while you wait for something to load.