JavaScript has been around for many decades now and has undergone multiple revisions. This book explores design patterns in the modern JavaScript context and uses modern ES2015+ syntax for all the examples discussed. This chapter discusses ES2015+ JavaScript features and syntax essential to further our discussion of design patterns in the current JavaScript context.
Some fundamental changes were introduced to JavaScript syntax with ES2015 that are especially relevant to our discussion on patterns. These are covered well in the BabelJS ES2015 guide.
This book relies on modern JavaScript syntax. You may also be curious about TypeScript. TypeScript, a statically typed superset of JavaScript, offers several language features that JavaScript does not. These features include strong typing, interfaces, enums, and advanced type inference and can also influence design patterns. To learn more about TypeScript and its benefits, consider checking out some O’Reilly books such as Programming TypeScript by Boris Cherny.
Modular JavaScript allows you to logically split your application into smaller pieces called modules. A module can be imported by other modules that, in turn, can be imported by more modules. Thus, the application can be composed of many nested modules.
In the world of scalable JavaScript, when we say an application is modular, we often mean it’s composed of a set of highly decoupled, distinct pieces of functionality stored in modules. Loose coupling facilitates easier maintainability of apps by removing dependencies where possible. If implemented efficiently, it allows you to see how changes to one part of a system may affect another.
Unlike some more traditional programming languages, the older iterations of JavaScript until ES5 (Standard ECMA-262 5.1 Edition) did not provide developers with the means to organize and import code modules cleanly. It was one of the concerns with the specifications that had not required great thought until more recent years when the need for more organized JavaScript applications became apparent. AMD (Asynchronous Module Definition) and CommonJS modules were the most popular patterns to decouple applications in the initial versions of JavaScript.
Native solutions to these problems arrived with ES6 or ES2015. TC39, the standards body charged with defining the syntax and semantics of ECMAScript and its future iterations, had been keeping a close eye on the evolution of JavaScript usage for large-scale development and was acutely aware of the need for better language features for writing more modular JS.
The syntax to create modules in JavaScript was developed and standardized with the release of ECMAScript modules in ES2015. Today, all major browsers support JavaScript modules. They have become the de facto method of implementing modern-day modular programming in JavaScript. In this section, we’ll explore code samples using the syntax for modules in ES2015+.
Modules allow us to separate our application code into independent units, each containing code for one aspect of the functionality. Modules also encourage code reusability and expose features that can be integrated into different applications.
A language should have features that allow you to import
module dependencies and export
the module interface (the public API/variables we allow other modules to consume) to support modular programming. The support for JavaScript modules (also referred to as ES modules) was introduced to JavaScript in ES2015, allowing you to specify module dependencies using an import
keyword. Similarly, you can use the export
keyword to export just about anything from within the module:
import
declarations bind a module’s exports as local variables and may be renamed to avoid name collisions/conflicts.
export
declarations declare that a local binding of a module is externally visible such that other modules may read the exports but can’t modify them. Interestingly, modules may export child modules but can’t export modules that have been defined elsewhere. We can also rename exports so that their external name differs from their local names.
.mjs is an extension used for JavaScript modules that helps us distinguish between module files and classic scripts (.js). The .mjs extension ensures that corresponding files are parsed as a module by runtimes and build tools (for example, Node.js, Babel).
The following example shows three modules for bakery staff, the functions they perform while baking, and the bakery itself. We see how functionality that is exported by one module is imported and used by the other:
// Filename: staff.mjs
// =========================================
// specify (public) exports that can be consumed by other modules
export
const
baker
=
{
bake
(
item
)
{
console
.
log
(
`Woo! I just baked
${
item
}
`
);
}
};
// Filename: cakeFactory.mjs
// =========================================
// specify dependencies
import
baker
from
"/modules/staff.mjs"
;
export
const
oven
=
{
makeCupcake
(
toppings
)
{
baker
.
bake
(
"cupcake"
,
toppings
);
},
makeMuffin
(
mSize
)
{
baker
.
bake
(
"muffin"
,
size
);
}
}
// Filename: bakery.mjs
// =========================================
import
{
cakeFactory
}
from
"/modules/cakeFactory.mjs"
;
cakeFactory
.
oven
.
makeCupcake
(
"sprinkles"
);
cakeFactory
.
oven
.
makeMuffin
(
"large"
);
Typically, a module file contains several related functions, constants, and variables. You can collectively export these at the end of the file using a single export statement followed by a comma-separated list of the module resources you want to export:
// Filename: staff.mjs
// =========================================
const
baker
=
{
//baker functions
};
const
pastryChef
=
{
//pastry chef functions
};
const
assistant
=
{
//assistant functions
};
export
{
baker
,
pastryChef
,
assistant
};
Similarly, you can import only the functions you need:
import
{
baker
,
assistant
}
from
"/modules/staff.mjs"
;
You can tell browsers to accept <script>
tags that contain JavaScript modules by specifying the type
attribute with a value of module
:
<
script
type
=
"module"
src
=
"main.mjs"
></
script
>
<
script
nomodule
src
=
"fallback.js"
></
script
>
The nomodule
attribute tells modern browsers not to load a classic script as a module. This is useful for fallback scripts that don’t use the module syntax. It allows you to use the module syntax in your HTML and have it work in browsers that don’t support it. This is useful for several reasons, including performance. Modern browsers don’t require polyfilling for modern features, allowing you to serve the larger
transpiled code to legacy browsers alone.
A cleaner approach to importing and using module resources is to import the module as an object. This makes all the exports available as members of the object:
// Filename: cakeFactory.mjs
import
*
as
Staff
from
"/modules/staff.mjs"
;
export
const
oven
=
{
makeCupcake
(
toppings
)
{
Staff
.
baker
.
bake
(
"cupcake"
,
toppings
);
},
makePastry
(
mSize
)
{
Staff
.
pastryChef
.
make
(
"pastry"
,
type
);
}
}
ES2015+ also supports remote modules (e.g., third-party libraries), making it simplistic to load modules from external locations. Here’s an example of pulling in the module we defined previously and utilizing it:
import
{
cakeFactory
}
from
"https://example.com/modules/cakeFactory.mjs"
;
// eagerly loaded static import
cakeFactory
.
oven
.
makeCupcake
(
"sprinkles"
);
cakeFactory
.
oven
.
makeMuffin
(
"large"
);
The type of import just discussed is called static import. The module graph needs to be downloaded and executed with static import before the main code can run. This can sometimes lead to the eager loading of a lot of code up front on the initial page load, which can be expensive and delay key features being available earlier:
import
{
cakeFactory
}
from
"/modules/cakeFactory.mjs"
;
// eagerly loaded static import
cakeFactory
.
oven
.
makeCupcake
(
"sprinkles"
);
cakeFactory
.
oven
.
makeMuffin
(
"large"
);
Sometimes, you don’t want to load a module up-front but on demand when needed. Lazy-loading modules allows you to load what you need when needed—for example, when the user clicks a link or a button. This improves the initial load-time performance. Dynamic import was introduced to make this possible.
Dynamic import introduces a new function-like form of import. import(url)
returns a promise for the module namespace object of the requested module, which is created after fetching, instantiating, and evaluating all of the module’s dependencies, as well as the module itself. Here is an example that shows dynamic imports for the
cakeFactory
module:
form
.
addEventListener
(
"submit"
,
e
=>
{
e
.
preventDefault
();
import
(
"/modules/cakeFactory.js"
)
.
then
((
module
)
=>
{
// Do something with the module.
module
.
oven
.
makeCupcake
(
"sprinkles"
);
module
.
oven
.
makeMuffin
(
"large"
);
});
});
Dynamic import can also be supported using the await
keyword:
let
module
=
await
import
(
"/modules/cakeFactory.js"
);
With dynamic import, the module graph is downloaded and evaluated only when the module is used.
Popular patterns like Import on Interaction and Import on Visibility can be easily implemented in vanilla JavaScript using the dynamic import feature.
Some libraries may be required only when a user starts interacting with a particular feature on the web page. Typical examples are chat widgets, complex dialog boxes, or video embeds. Libraries for these features need not be imported on page load but can be loaded when the user interacts with them (for example, by clicking on the component facade or placeholder). The action can trigger the dynamic import of respective libraries followed by the function call to activate the desired functionality.
For example, you can implement an onscreen sort function using the external lodash.sortby
module, which is loaded dynamically:
const
btn
=
document
.
querySelector
(
'button'
);
btn
.
addEventListener
(
'click'
,
e
=>
{
e
.
preventDefault
();
import
(
'lodash.sortby'
)
.
then
(
module
=>
module
.
default
)
.
then
(
sortInput
())
// use the imported dependency
.
catch
(
err
=>
{
console
.
log
(
err
)
});
});
Many components are not visible on the initial page load but become visible as the user scrolls down. Since users may not always scroll down, modules corresponding to these components can be lazy-loaded when they become visible. The IntersectionObserver API can detect when a component placeholder is about to become visible, and a dynamic import can load the corresponding modules.
Node 15.3.0 onward supports JavaScript modules. They function without an experimental flag and are compatible with the rest of the npm package ecosystem. Node treats files ending in .mjs and .js with a top-level type field value of module as JavaScript modules:
{
"name"
:
"js-modules"
,
"version"
:
"1.0.0"
,
"description"
:
"A package using JS Modules"
,
"main"
:
"index.js"
,
"type"
:
"module"
,
"author"
:
""
,
"license"
:
"MIT"
}
Modular programming and the use of modules offer several unique advantages. Some of these are as follows:
The browser evaluates module scripts only once, while classic scripts get evaluated as often as they are added to the DOM. This means that with JS modules, if you have an extended hierarchy of dependent modules, the module that depends on the innermost module will be evaluated first. This is a good thing because it means that the innermost module will be evaluated first and will have access to the exports of the modules that depend on it.
Unlike other script files, where you have to include the defer
attribute if you don’t want to load them immediately, browsers automatically defer the loading of modules.
Modules promote decoupling pieces of code that can be maintained independently without significant changes to other modules. They also allow you to reuse the same code in multiple different functions.
Modules create a private space for related variables and constants so that they can be referenced via the module without polluting the global namespace.
Before the introduction of modules, unused code files had to be manually removed from projects. With module imports, bundlers such as webpack and Rollup can automatically identify unused modules and eliminate them. Dead code may be removed before adding it to the bundle. This is known as tree-shaking.
All modern browsers support module import and export, and you can use them without any fallback.
In addition to modules, ES2015+ also allows defining classes with constructors and some sense of privacy. JavaScript classes are defined with the class
keyword. In the following example, we define a class Cake
with a constructor and two getters and
setters:
class
Cake
{
// We can define the body of a class constructor
// function by using the keyword constructor
// with a list of class variables.
constructor
(
name
,
toppings
,
price
,
cakeSize
){
this
.
name
=
name
;
this
.
cakeSize
=
cakeSize
;
this
.
toppings
=
toppings
;
this
.
price
=
price
;
}
// As a part of ES2015+ efforts to decrease the unnecessary
// use of function for everything, you will notice that it is
// dropped for cases such as the following. Here an identifier
// followed by an argument list and a body defines a new method.
addTopping
(
topping
){
this
.
toppings
.
push
(
topping
);
}
// Getters can be defined by declaring get before
// an identifier/method name and a curly body.
get
allToppings
(){
return
this
.
toppings
;
}
get
qualifiesForDiscount
(){
return
this
.
price
>
5
;
}
// Similar to getters, setters can be defined by using
// the set keyword before an identifier
set
size
(
size
){
if
(
size
<
0
){
throw
new
Error
(
"Cake must be a valid size: "
+
"either small, medium or large"
);
}
this
.
cakeSize
=
size
;
}
}
// Usage
let
cake
=
new
Cake
(
"chocolate"
,
[
"chocolate chips"
],
5
,
"large"
);
JavaScript classes are built on prototypes and are a special category of JavaScript functions that need to be defined before they can be referenced.
You can also use the extends
keyword to indicate that a class inherits from another class:
class
BirthdayCake
extends
Cake
{
surprise
()
{
console
.
log
(
`Happy Birthday!`
);
}
}
let
birthdayCake
=
new
BirthdayCake
(
"chocolate"
,
[
"chocolate chips"
],
5
,
"large"
);
birthdayCake
.
surprise
();
All modern browsers and Node support ES2015 classes. They are also compatible with the new-style class syntax introduced in ES6.
The difference between JavaScript modules and classes is that modules are imported and exported, and classes are defined with the class
keyword.
Reading through, one may also notice the lack of the word “function” in the previous examples. This isn’t a typo: TC39 has made a conscious effort to decrease our abuse of the function
keyword for everything, hoping it will help simplify how we write code.
JavaScript classes also support the super
keyword, which allows you to call a parent class’ constructor. This is useful for implementing the self-inheritance pattern. You can use super
to call the methods of the superclass:
class
Cookie
{
constructor
(
flavor
)
{
this
.
flavor
=
flavor
;
}
showTitle
()
{
console
.
log
(
`The flavor of this cookie is
${
this
.
flavor
}
.`
);
}
}
class
FavoriteCookie
extends
Cookie
{
showTitle
()
{
super
.
showTitle
();
console
.
log
(
`
${
this
.
flavor
}
is amazing.`
);
}
}
let
myCookie
=
new
FavoriteCookie
(
'chocolate'
);
myCookie
.
showTitle
();
// The flavor of this cookie is chocolate.
// chocolate is amazing.
Modern JavaScript supports public and private class members. Public class members are accessible to other classes. Private class members are accessible only to the class in which they are defined. Class fields are, by default, public. Private class fields can be created by using the #
(hash) prefix:
class
CookieWithPrivateField
{
#
privateField
;
}
class
CookieWithPrivateMethod
{
#
privateMethod
()
{
return
'delicious cookies'
;
}
}
JavaScript classes support static methods and properties using the static
keyword. Static members can be referenced without instantiating the class. You can use static methods to create utility functions and static properties for holding configuration or cached data:
class
Cookie
{
constructor
(
flavor
)
{
this
.
flavor
=
flavor
;
}
static
brandName
=
"Best Bakes"
;
static
discountPercent
=
5
;
}
console
.
log
(
Cookie
.
brandName
);
//output = "Best Bakes"
Over the last few years, some modern JavaScript libraries and frameworks—notably React—have introduced alternatives to classes. React Hooks make it possible to use React state and lifecycle methods without an ES2015 class component. Before Hooks, React developers had to refactor functional components as class components so that they handle state and lifecycle methods. This was often tricky and required an understanding of how ES2015 classes work. React Hooks are functions that allow you to manage a component’s state and lifecycle methods without relying on classes.
Note that several other approaches to building for the web, such as the Web Components community, continue to use classes as a base for component development.
This chapter introduced JavaScript language syntax for modules and classes. These features allow us to write code while adhering to object-oriented design and modular programming principles. We will also use these concepts to categorize and describe different design patterns. The next chapter talks about the different categories of design patterns.