ES2015 - The Shape of Javascript to Come
ES2015, formerly known as ES6, is the most extensive update to the JavaScript language since the publication of its first edition in 1997.
The committee decided to use the year of release, instead of version number.
Level 1 - Declarations
The exercises will focus on a forum web app. The first feature will be loading users profile into the sidebar of the site.
Declarations with let
The loadProfiles function takes an array of users and adds their profile to the sidebar.
<!DOCTYPE html>
<!-- ... -->
<script src="./load-profiles.js">
<script>
loadProfiles(["Sam", "Tyler", "Brook", "Jason"]);
</script>
</html>
The loadProfiles Function
function loadProfiles(userNames) {
if (userNames.length > 3) {
var loadingMessage = "This might take a while..."
_displaySpinner(loadingMessage)
} else {
var flashMessage = "Loading Profiles"
_displayFlash(flashMessage)
}
console.log(flashMessage) // returns undefined
// ... fetch names and build sidebar
}
Javascript detects undeclared variables and declares them at the top of the current scope. This is known as hoisting.
One way to avoid confusion is to use let
.
let loadingMessage = "This might take a while..."
Variables declared with let
are not hoisted to the top of the scope. Instead of the hoisting occurring, thus leading
to an undeclared
value for the references, you’ll instead get a ReferenceError informing you that the variable is
not defined.
Declarations with let in for loops
When using var
in for loops, there can be unexpected behavior.
function loadProfiles(userNames) {
for (var i in userNames) {
_fetchProfile("/users/" + userNames[i], function() {
console.log("Fetched for ", userNames[i])
})
}
}
This results in output:
Fetched for Alex
Fetched for Alex
Fetched for Alex
Fetched for Alex
The last element of the array is outputted all 4 times because the _fetchProfile method is delayed in it’s execution
due to an AJAX call, so when it references the variable i
, the iterations have completed and the value of i
is
set to 3
as it’s final value. When the callbacks calls, upon completion of the AJAX request, it references the 3
and ends up outputting ‘Alex’ as the name.
This is because the var i
is hoisted to the top of the function and declared in that scope, and then other references
to i
. If this is replaced with let
, a new i
variable is created on each iteration.
for (let i in userNames) {
}
Variables declared with let
can be reassigned, but cannot be redeclared within the same scope.
// no problem
let flashMessage = "Hello"
flashMessage = "Goodbye"
// problem
let flashMessage = "Hello"
let flashMessage = "Goodbye" // results in a TypeError
// no error because defining in a different scope
let flashMessage = "Hello"
function loadProfiles(userNames) {
let flashMessage = "Loading profiles"
return flashMessage
}
Declarations with const
Magic Number - A literal value without a clear meaning. If you end up using the number multiple times, it will lead to unnecessary duplication, which is bad code. People won’t know if these literal values are related or not.
By using const
we can create a read-only named constant.
const MAX_USERS = 3
if (userNames.length > MAX_USERS) {
// ...
}
You cannot redefine a constant after it has been defined. Constants also require an initial value.
// will result in error
const MAX_USERS;
MAX_USERS = 10;
Block Scoped
Constants are blocked scoped, which mean they are not hoisted to the top of the function. So if you define something
within an if
block, and try to access it from outside, it will return an error.
if (userNames.length > MAX_USERS) {
const MAX_REPLIES = 15
} else {
// ...
}
console.log(MAX_REPLIES) // ReferenceError. MAX_REPLIES is not defined)
Level 2 - Functions
Functions
Default Parameters
Unexpected arguments might cause errors during function execution. This code runs just fine, because it’s passing an array to the function as expected.
loadProfiles(["Sam", "Tyler", "Brook"])
However what if it was passed no arguments, or an undefined
value.
function loadProfiles(userNames) {
// TypeError: Cannot read property ‘length’ of undefined
let namesLength = userNames.length
}
A common practice is to validate the presence of arguments in the beginning of the function.
let names = typeof userNames !== "undefined" ? userNames : []
We can make this code better by defining default values for the parameters.
function loadProfiles(userNames = []) {
let namesLength = userNames.length
console.log(namesLength)
}
Named Parameters
The options object is a widely used pattern that allows user-defined settings to be passed to a function in the form of properties on an object.
function setPageThread(name, options = {}) {
let popular = options.popular
let expires = options.expires
let activeClass = options.activeClass
}
setPageThread("New Version out Soon!", {
popular: true,
expires: 10000,
activeClass: "is-page-thread"
})
This approach doesn’t make it very clear which options the function expects, and it requires the definition of boilerplate code to handle the option assignment.
Using named parameters for optional settings makes it easier to understand how a function should be invoked.
function setPageThread(name, { popular, expires, activeClass }) {
console.log("Name: ", name)
console.log("Popular: ", popular)
console.log("Expires: ", expires)
console.log("Active: ", activeClass)
}
Now we know which arguments are available. The function call remains the same. Each property of the parameter is mapped to the argument appropriately.
It’s NOT okay to omit the options argument altogether when invoking a function with named parameters when no default value is set for them.
// results in TypeError: Cannot read property 'popular' of undefined
setPageThread("New Version out Soon!")
// sets default value, resulting in all becoming undefined
function setPageThread(name, { popular, expires, activeClass } = {}) {
console.log("Name: ", name)
console.log("Popular: ", popular)
console.log("Expires: ", expires)
console.log("Active: ", activeClass)
}
// defaults the value of popular to an empty string
// while still defaulting the entire options hash if not provided
function setPageThread(name, { popular = "", expires, activeClass } = {}) {
console.log("Name: ", name)
console.log("Popular: ", popular)
console.log("Expires: ", expires)
console.log("Active: ", activeClass)
}
Rest Parameter, Spread Operator, and Arrow Functions
Tags are a useful feature in web applications that have lots of read content. It helps filter results down to specific topics. Let’s add these to the forum.
We want our displayTags
function to operate as follows:
// variadic functions can accept any number of arguments
displayTags("songs")
displayTags("songs", "lyrics")
displayTags("songs", "lyrics", "bands")
Arguments Object
In classic Javascript we could have used the arguments
object, which is a build-in Array-like object that corresponds
to the arguments of a function.
This is not ideal because it’s hard to tell which parameters this function expects to be called with. Developers might
not know where the arguments
reference comes from (outside the scope of the function??).
function displayTags() {
for (let i in arguments) {
let tag = arguments[i]
_addToTopic(tag)
}
}
If we change the function signature, it will break our code also.
function displayTags(targetElement) {
let target = _findElement(targetElement)
for (let i in arguments) {
let tag = arguments[i] // becomes broken because the
// first argument is no longer a tag
_addToTopic(tag)
}
}
Rest Parameter
The new rest parameter syntax allows us to represent an indefinite number of arguments as an Array. This way, changes to function signature are less likely to break code.
The three dots make tags
a rest parameter.
function displayTags(...tags) {
// tags in an array object
for (let i in tags) {
let tag = tags[i]
_addToTopic(tag)
}
}
function displayTags(targetElement, ...tags) {
// ...
}
The rest parameter must always go last in the function signature.
Spread Operator
We need a way to convert an Array into individual arguments upon a function call.
getRequest("/topics/17/tags", function(data) {
let tags = data.tags
displayTags(tags) // tags is an Array
})
Our function is expecting to be called with individual arguments, not a single argument that is an Array. How can we convert the Array argument into individual elements on the function call?
getRequest("/topics/17/tags", function(data) {
let tags = data.tags
displayTags(...tags) // tags is an Array
})
Prefixing the tags
array with the spread operator makes it so that the call is the same as calling
displayTags(tag, tag, tag)
.
The syntax for rest parameters and the spread operator look the same, but the former is used in function definitions and the later in function invocations.
JavaScript Objects
JavaScript objects can help us with the encapsulation, organization, and testability of our code.
Functions like getRequest and displayTags should not be exposed to caller code.
getRequest("/topics/17/tags", function(data) {
let tags = data.tags
displayTags(...tags)
})
We want to convert code like above, into code like this:
let tagComponent = new TagComponent(targetDiv, "/topics/17/tags")
tagComponent.render()
The TagComponent object encapsulates the code for fetching tags and adding them to a page.
function TagComponent(target, urlPath) {
this.targetElement = target
this.urlPath = urlPath
}
TagComponent.prototype.render = function() {
getRequest(this.urlPath, function(data) {
// ...
})
}
Properties set on the constructor function can be accessed from other instance methods. This is why the reference to
this.urlPath
works within the render()
method.
Issues with Scope in Callback Functions
Anonymous functions passed as callbacks to other functions create their own scope.
function TagComponent(target, urlPath) {
// this scope within the component object is not the same
// as the anonymous function assigned to 'render' below
this.targetElement = target
this.urlPath = urlPath
}
TagComponent.prototype.render = function() {
getRequest(this.urlPath, function(data) {
let tags = data.tags
// this.targetElement returns undefined
displayTags(this.targetElement, ...tags)
})
}
Arrow Functions
Arrow functions bind to the scope of where they are defined, not where they are used. This is also known as lexical binding.
function TagComponent(target, urlPath) {
this.targetElement = target
this.urlPath = urlPath
}
TagComponent.prototype.render = function() {
// arrow functions bind to the lexical scope
getRequest(this.urlPath, data => {
let tags = data.tags
displayTags(this.targetElement, ...tags)
})
}
Level 3 - Objects, Strings, and Object.assign
Objects and Strings
The buildUser function returns an object with the first, last, and fullName properties.
function buildUser(first, last) {
let fullName = first + " " + last
return {
first: first,
last: last,
fullName: fullName
}
}
let user = buildUser("Sam", "Williams")
As you can see, we end up repeating the same thing as the key and value here in the return statement.
Object Initializer
We can shorten this by using the object initializer shorthand, which removes duplicate variable names.
return {first, last, fullName}; // way cleaner
This only works when the properties and values use the same name. It works anywhere a new object is returned, not just from functions.
let name = "Sam"
let age = 45
let friends = ["Brook", "Tyler"]
let user = { name, age, friends }
Object Destructuring
// generates 3 separate variables based on the object returned
let { first, last, fullName } = buildUser(“Sam”, “Williams”);
console.log(first); // > Sam
console.log(last); // > Williams
console.log(fullName); // > Sam Williams
Not all of the properties have to be destructured all the time. We can explicitly select the ones we want.
let { fullName } = buildUser("Sam", "Williams")
console.log(fullName)
In previous versions of JavaScript, adding a function to an object required specifying the property name and then
the full function definition (including the function
keyword);
function buildUser(first, last, postCount) {
let fullName = first + " " + last
const ACTIVE_POST_COUNT = 10
return {
first,
last,
fullName,
isActive: function() {
return postCount >= ACTIVE_POST_COUNT
}
}
}
A new shorthand notation is available for adding a method to an object where the keyword function
is no longer
necessary.
return {
first,
last,
fullName,
isActive() {
return postCount >= ACTIVE_POST_COUNT;
}
Template Strings
Template strings are string literals allowing embedded expressions. This allows for a much better way to do string interpolation.
function buildUser(first, last, postCount) {
let fullName = first + " " + last
const ACTIVE_POST_COUNT = 10
// ...
}
You can instead use back ticks, with a dollar sign and curly brace syntax for interpolated variables.
function buildUser(first, last, postCount) {
let fullName = `${first} ${last}` // back-ticks, NOT single quotes
const ACTIVE_POST_COUNT = 10
// ...
}
Template strings offer a new - and much better- way to write multi-line strings.
let userName = "Sam"
let admin = { fullName: "Alex Williams" }
let veryLongText = `Hi ${userName},
this is a very
very
long text.
Regards,
${admin.FullName}
`
console.log(veryLongText)
Object.assign
In this example we’ll add a count-down timer to a forum. The countdown timer displays the time left for users to undo their posts after they’ve been created. Once the time is up, they cannot undo it anymore.
We want to make our timer function reusable so that it can be used by other applications and domains.
// simple example
countdownTimer($(".btn-undo"), 60)
// container class specified
countdownTimer($(".btn-undo", 60, { container: ".new-post-options" }))
// container class and time units
countdownTimer(
$(".btn-undo", 60, {
container: ".new-post-options",
timeUnit: "minutes",
timeoutClass: ".time-is-up"
})
)
For functions that need to be used across different applications, it’s okay to accept an options object instead of using named parameters.
// too many options, difficult to interpret calls to this function
function countdownTimer(
target,
timeLeft,
{
container,
timeUnit,
clonedDataAttribute,
timeoutClass,
timeoutSoonClass,
timeoutSoonSeconds
} = {}
) {
// ...
}
// easier to customize to different applications
function countdownTimer(target, timeLeft, options = {}) {
// ...
}
Some options might not be specified by the caller, so we need to have default values.
function countdownTimer(target, timeLeft, options = {}) {
let container = options.container || ".timer-display"
let timeUnit = options.timeUnit || "seconds"
let clonedDataAttribute = options.clonedDataAttribute || "cloned"
let timeoutClass = options.timeoutClass || ".is-timeout"
let timeoutSoonClass = options.timeoutSoonClass || ".is-timeout-soon"
let timeoutSoonTime = options.timeoutSoonSeconds || 10
}
This works, but the default strings and numbers are all over the place, which makes the code hard to understand and difficult to maintain.
Using a local object to group default values for user options is a common practice and can help write more idiomatic JavaScript. We want to merge options and defaults. Upon duplicate properties, those from options must override properties from defaults.
The Object.assign method copies properties from one or more source objects to a target object specified as the first argument.
function countdownTimer(target, timeLeft, options = {}) {
let defaults = {
container: ".timer-display",
timeUnit: "seconds",
clonedDataAttribute: "cloned",
timeoutClass: ".is-timeout",
timeoutSoonClass: ".is-timeout-soon",
timeoutSoonTime: 10
}
// we pass a {} because the target object is modified
// and used as return value
// Source objects remain unchanged
let settings = Object.assign({}, defaults, options)
}
In case of duplicate properties on source objects, the value from the last object on the chain always prevails. Properties in options3 will override options2, and options2 will override options.
function countdownTimer(target, timeLeft, options = {}) {
let defaults = {
// ...
}
let settings = Object.assign({}, defaults, options, options2, options3)
}
Because the target of Object.assign is mutated, we would not be able to go back and access the original default values after the merge if we used it as the target
// bad idea
Object.assign(defaults, options)
// Okay alternative approach
let settings = {}
Object.assign(settings, defaults, options)
We want to preserve the original default values because it gives us the ability to compare them with the options passed, and act accordingly when necessary.
function countdownTimer(target, timeLeft, options = {}) {
let defaults = {
// ...
}
let settings = Object.assign({}, defaults, options)
// this wouldn't be possible without knowing if the argument
// is different than the default
if (settings.timeUnit !== defaults.timeUnit) {
_conversionFunction(timeLeft, settings.timeUnit)
}
}
Let’s run countdownTimer() passing the value for container as argument…
countdownTimer($(".btn-undo"), 60, { container: ".new-post-options" })
function countdownTimer(target, timeLeft, options = {}) {
let defaults = {
container: ".timer-display",
timeUnit: "seconds"
// ...
}
let settings = Object.assign({}, defaults, options)
console.log(settings.container) // .new-post-options
console.log(settings.timeUnit) // seconds
}
Level 4 - Arrays, Maps, and Sets
Arrays
Destructuring
We typically access array elements by their index, but doing so for more than just a couple of elements can quickly turn into a repetitive task.
let users = ["Sam", "Tyler", "Brook"]
// this will keep getting longer as we need to extract more elements
let a = users[0]
let b = users[1]
let c = users[2]
console.log(a, b, c) // Sam Tyler Brook
We can use Array Destructuring to assign multiple values from an array to local variables.
let users = ["Sam", "Tyler", "Brook"]
let [a, b, c] = users // still easy to understand, AND less code
console.log(a, b, c) // Sam Tyler Brook
Values can be discarded if desired.
let [a, , b] = users // discarding "Tyler" value
console.log(a, b) // Sam Brook
We can combine destructuring with rest parameters to group values into other arrays.
let users = ["Sam", "Tyler", "Brook"]
let [first, ...rest] = users // groups remaining argument in an array
console.log(first, rest) // Sam ["Tyler","Brook"]
When returning arrays from functions, we can assign to multiple variables at once.
function activeUsers() {
let users = ["Sam", "Alex", "Brook"]
return users
}
let active = activeUsers()
console.log(active) // ["Sam", "Alex", "Brook"]
let [a, b, c] = activeUsers()
console.log(a, b, c) // Sam Alex Brook
Using for…of
The for…of statement iterates over property values, and it’s a better way to loop over arrays and other iterable objects.
let names = ["Sam", "Tyler", "Brook"]
for (let index in names) {
console.log(names[index])
}
for (let name of names) {
console.log(name)
}
For for..of statement cannot be used to iterate over properties in plain JavaScript objects out-of-the-box.
let post = {
title: "New Features in JS",
replies: 19,
lastReplyFrom: "Sam"
}
// this will not work
// TypeError: post[Symbol.iterator] is not a function
for (let property of post) {
console.log("Value: ", property)
}
In order to work with for…of, objects need a special function assigned to the Symbol.iterator property. The presence of this property allows us to know whether an object is iterable.
let names = ["Sam", "Tyler", "Brook"]
console.log(typeof names[Symbol.iterator]) // function
for (let name of names) {
console.log(name)
}
Since there is a function assigned, then the names
array will work just fine with for..of
.
let post = {
title: "New features in JS",
replies: 19,
lastReplyFrom: "Sam"
}
console.log(typeof post[Symbol.iterator]) // undefined
// Results in TypeError: post[Symbol.iterator] is not a function
for (let property of post) {
console.log(property)
}
Finding an Element in an Array
Array.find
returns the first element in the array that satisfies a provided testing function.
let users = [
{ login: "Sam", admin: false },
{ login: "Brook", admin: true },
{ login: "Tyler", admin: true }
]
How can we find the first admin in the array?
let admin = users.find(user => {
return user.admin
// returns first object for which user.admin is true
})
console.log(admin)
We can alternatively shorten this function by omitting the curly braces and parenthesis in the function definition.
let admin = users.find(user => user.admin)
Maps
Maps are a data structure composed of a collection of key/value pairs. They are very useful to store simple data, such as property values. Each key is associated with a single value.
Objects are first key/value stores that Javascript developers encounter, however when using Objects as maps, it’s keys are always converted to strings.
let user1 = { name: "Sam" }
let user2 = { name: "Tyler" }
let totalReplies = {}
totalReplies[user1] = 5
totalReplies[user2] = 42
console.log(totalReplies[user1]) // 42
console.log(totalReplies[user2]) // 42
console.log(Object.keys(totalReplies)) // [ "[object Object]" ]
This happens because both objects are converted to the string [object Object]
when they are used as keys inside of
totalReplies
.
We should stop using Javascript objects as maps, and instead use the Map object, which is also a simple key/value data structure. Any value may be used as either a key or a value, and objects are not converted to strings.
let user1 = { name: "Sam" }
let user2 = { name: "Tyler" }
let totalReplies = new Map()
totalReplies.set(user1, 5)
totalReplies.set(user2, 42)
console.log(totalReplies.get(user1)) // 5
console.log(totalReplies.get(user2)) // 42
We have to use the get()
and set()
methods to access values in Maps.
Most of the time you will want to use the Map data structure, such as when keys are not known until runtime… such as user input, or IDs generated by a database. You’ll still want to use Objects when the keys are static.
Maps are iterable, so they can be used in a for…of loop. Each run of the loop returns a [key, value] pair for an entry in the Map.
let mapSettings = new Map()
mapSettings.set("user", "Sam")
mapSettings.set("topic", "ES2015")
mapSettings.set("replies", ["Can't wait!", "So cool"])
for (let [key, value] of mapSettings) {
console.log(`${key} = ${value}`)
}
WeakMap
A WeakMap is a type of Map where only objects can be passed as keys. Primitive data types — such as strings, numbers, booleans, etc. — are not allowed.
let user = {}
let comment = {}
let mapSettings = new WeakMap()
mapSettings.set(user, "user")
mapSettings.set(comment, "comment")
console.log(mapSettings.get(user)) // user
console.log(mapSettings.get(comment)) // comment
mapSettings.set("title", "ES2015") // Invalid value used as weak map key
All available methods on a WeakMap require access to an object used as a key.
let user = {}
let mapSettings = new WeakMap()
mapSettings.set(user, "ES2015")
console.log(mapSettings.get(user)) // "ES2015"
console.log(mapSettings.has(user)) // true
console.log(mapSettings.delete(user)) // true
WeakMaps are not iterable, therefore they can’t be used with for…of.
// error:
// mapSettings[Symbol.iterator] is not a function
for (let [key, value] of mapSettings) {
console.log(`${key} = ${value}`)
}
Individual entries in a WeakMap can be garbage collected while the WeakMap itself still exists.
let user = {} // all objects occupy memory space
let userStatus = new WeakMap()
userStatus.set(user, "logged") // Object reference passed as key to the WeakMap
// ...
someOtherFunction(user) // Once this function returns, 'user' can be garbage collected.
WeakMaps don’t prevent the garbage collector from collecting objects currently used as keys, but that are no longer referenced anywhere else in the system. The garbage collector removes the object from the WeakMap as well.
Sets
Limitations with Arrays
Arrays don’t enforce uniqueness of items. Duplicate entries are allowed.
let tags = []
tags.push("Javascript")
tags.push("Programming")
tags.push("Web")
tags.push("Web")
console.log("Total items ", tags.length) // Total items 4
The Set object stores unique values of any type, whether primitive values or object references.
let tags = new Set()
tags.add("Javascript")
tags.add("Programming")
tags.add({ version: "2015" })
tags.add("Web")
tags.add("Web") // duplicate entries are ignored
console.log("Total items ", tags.size) // Total items 4
Set objects are iterable, which means they can be used with for…of and destructuring.
let tags = new Set()
tags.add("Javascript")
tags.add("Programming")
tags.add({ version: "2015" })
tags.add("Web")
for (let tag of tags) {
console.log(tag)
}
let [a, b, c, d] = tags
console.log(a, b, c, d) // Javascript Programming {version: '2015'} Web
WeakSet
The WeakSet is a type of Set where only objects are allowed to be stored.
let weakTags = new WeakSet()
weakTags.add("JavaScript") // TypeError: Invalid value used in weak set
weakTags.add({ name: "JavasScript" })
let iOS = { name: "iOS" }
weakTags.add(iOS)
weakTags.has(iOS) // returns true, because it has that object present
weakTags.delete(iOS) // returns true, it successfully removed from the weakset
Can’t Read From a WeakSet
WeakSets cannot be used with for…of and they offer no methods for reading values from it.
let weakTags = new WeakSet()
weakTags.add({ name: "JavasScript" })
let iOS = { name: "iOS" }
weakTags.add(iOS)
// TypeError weakTags[Symbol.iterator] is not a function
for (let wt of weakTags) {
console.log(wt)
}
Using WeakSets to Show Unread Posts
If we can’t read values from a weakset, when should we use them?
In a visual interface, we want to add a different background color to posts that have not yet been read.
One way to “tag” unread posts is to change a property on each post object once they are read.
let post = { // ... };
// ... when post is clicked on
postList.addEventListener('click', (event) => {
// ...
post.isRead = true; // Mutates post object in order to indicate it's been read
});
// ... rendering list of posts
// checks a property on each post object
for (let post of postArray) {
if(!post.isRead) {
// adds css class on element if new
_addNewPostClass(post.element);
}
}
The issue with this code is that we are changing/mutating each post object unnecessarily. Using immutable objects in Javascript is a common practice that should be favored whenever possible. Doing so makes your code easier to understand, and leaves less room for errors.
We can use WeakSets to create special groups from existing objects without mutating them. Favoring immutable objects allows for much simpler code with no unexpected side effects.
let readPosts = new WeakSet()
// ... when post is clicked on
postList.addEventListener("click", event => {
// ...
readPosts.add(post) // Adds object to a group of read posts
})
// ... rendering posts
for (let post of postArray) {
if (!readPosts.has(post)) {
_addNewPostClass(post.element)
}
}
While we can’t read values from a WeakSet, we can check to see if an object is present in the group.
Level 5 - Classes and Modules
Classes
Adding a Sponsor to the Sidebar
A common approach to encapsulation in JavaScript is using a constructor function.
function SponsorWidget(name, description, url) {
this.name = name
this.description = description
this.url = url
}
SponsorWidget.prototype.render = function() {
// ...
}
Constructor functions are invoked with the new operator. Invoking the SponsorWidget function looks like this:
let sponsorWidget = new SponsorWidget(name, description, url)
sponsorWidget.render()
Using the New Class Syntax
To define a class, we use the class keyword followed by the name of the class. The body of a class is the part between curly braces.
class SponsorWidget {
render() {
// ...
}
}
Instance method definitions in classes look just like the method initializer shorthand in objects’.
Initializing Values in the Constructor Function
class SponsorWidget {
// Runs every time a new instance is created with the new operator
constructor(name, description, url) {
// Assigning to instance variables make them accessible to other instance methods
this.name = name
this.description = description
this.url = url
}
render() {
// ...
}
}
let sponsorWidget = new SponsorWidget(name, description, url)
sponsorWidget.render()
Accessing Class Instance Variables
class SponsorWidget {
constructor(name, description, url) {
this.description = description
this.url = url
}
render() {
// this.url is an instance variable set in constructor
let link = this._buildLink(this.url)
// ...
}
_buildLink(url) {
// ....
}
}
There are no access modifiers like private or protected like there are in other languages.
Prefixing a method with an underscore is a convention for indicating that it should not be invoked from the public API.
The class syntax is not introducing a new object model to JavaScript. It’s just syntactical sugar over the existing prototype-based inheritance (syntactical sugar).
Class Inheritance
We can use class inheritance to reduce code repetition. Child classes inherit and specialize behavior defined in parent classes.
The extends keyword is used to create a class that inherits methods and properties from another class. The super method runs the constructor function from the parent class.
class Widget {
constructor() {
this.baseCSS = "site-widget"
}
parse(value) {
// ...
}
}
class SponsorWidget extends Widget {
constructor(name, description, url) {
super()
// ...
}
render() {
let parsedName = this.parse(this.name)
let css = this._buildCSS(this.baseCSS)
}
}
Overriding Inherited Methods
Child classes can invoke methods from their parent classes via the super object.
class Widget {
constructor() {
this.baseCSS = "site-widget"
}
parse(value) {
// ...
}
}
class SponsorWidget extends Widget {
constructor(name, description, url) {
super()
// ...
}
parse() {
let parsedName = super.parse(this.name)
return `Sponsor: ${parsedName}`
}
render() {
// ...
}
}
Super holds a reference to the parent version of the parse() method.
Modules - Part 1
Polluting the Global Namespace
The common solution for modularizing code relies on using global variables. This increases the chances of unexpected side effects and potential naming conflicts.
These libraries shown below add to the global namespace.
<!DOCTYPE html>
<body>
<script src="./jquery.js"></script>
<script src="./underscore.js"></script>
<script src="./flash-messages.js"></script>
</body>
<script>
// In our Javascript we simply reference these globally defined APIs
let element = $("...").find(...);
let filtered = _.each(...);
flashMessage("Hello");
</script>
Global variables can cause naming conflicts.
Creating Modules
Let’s create a new JavaScript module for displaying flash messages.
/* flash-messages.js */
export default function(message) {
alert(message)
}
/* app.js */
// points to file with .js extension, which must be in the same folder
// pulls in 'default' method from the imported file
import flashMessage from "./flash-message"
// call to flashMessage method
flashMessage("Hello")
Modules still need to be imported via <script>
, but no longer pollute the global namespace.
<!DOCTYPE html>
<body>
<script src="./flash-messages.js"></script>
<script src="./app.js"></script>
</body>
</html>
Can’t Default Export Multiple Functions
Modules give us total control over which methods we expose publicly. The default type export limits the number of functions we can export from a module.
/* flash-messages.js */
export default function(message) {
alert(message)
}
// Not available outside this module
function logMessage(message) {
console.log(message)
}
Using Named Exports
In order to export multiple functions from a single module, we can use the named export.
/* flash-messages.js */
export function alertMessage(message) {
alert(message)
}
export function logMessage(message) {
console.log(message)
}
/* app.js */
import { alertMessage, logMessage } from "./flash-message"
alertMessage("Hello from alert")
logMessage("Hello from log")
Importing a Module as an Object
We can also import the entire module as an object and call each funtion as a property from this object.
/* app.js */
import * as flash from "./flash-message"
flash.alertMessage("Hello from alert")
flash.logMessage("Hello from log")
Removing Repeated Export Statements
Instead of calling export statements every time we want to export (publicly expose) a function, we can instead export them as a list with a single command.
/* flash-messages.js */
function alertMessage(message) {
alert(message)
}
function logMessage(message) {
console.log(message)
}
export { alertMessage, logMessage }
Modules - Part 2
Extracting Hardcoded Constants
Refining constants across our application is unnecessary repetition and can lead to bugs.
/* load-profiles.js */
function loadProfiles(userNames) {
const MAX_USERS = 3
if (userNames.length > MAX_USERS) {
// ...
}
const MAX_REPLIES = 3
if (someElement > MAX_REPLIES) {
// ...
}
}
export { loadProfiles }
/* load-profiles.js */
function listReplies(replies = []) {
const MAX_REPLIES = 3
if (replies.length > MAX_REPLIES) {
// ...
}
}
export { listReplies }
We cannot redefine constants within the same scope, but here we have 3 different functions with their own scope, so this code is correct. It’s still unnecessary duplication. The problem is that if we change one constant, then we have to find all the other ones and update them.
Exporting Constants
Placing constants in their own module allows them to be reused across other modules and hides implementation details (a.k.a., encapsulation).
/* constants.js */
export const MAX_USERS = 3;
export const MAX_REPLIES = 3;
/* alternative constants.js */
const MAX_USERS = 3;
const MAX_REPLIES = 3;
export { MAX_USERS, MAX_REPLIES };
How to Import Constants**
To import constants, we can use the exact same syntax for importing functions.
/* load-profiles.js */
import { MAX_REPLIES, MAX_USERS } from "./constants"
function loadProfiles(userNames) {
if (userNames.length > MAX_USERS) {
// ...
}
if (someElement > MAX_REPLIES) {
// ...
}
}
Exporting Class Modules With Default Export
Classes can also be exported from modules using the same syntax as functions. Instead of 2 individual functions, we now have 2 instance methods that are part of a class.
/* flash-message.js */
export default class FlashMessage {
constructor(message) {
this.message = message
}
renderAlert() {
alert(`${this.message} from alert`)
}
renderLog() {
console.log(`${this.message} from log`)
}
}
The default keyword allows this class to be set to any variable name once it’s imported.
Using Class Modules with Default Export
Imported classes are assigned to a variable using import and can then be used to create new instances.
/* app.js */
import FlashMessage from "./flash-message"
let flash = new FlashMessage("Hello")
flash.renderAlert()
flash.renderLog()
Using Class Modules with Named Export
Another way to export classes is to first define them, and then use the export statement with the class name inside curly braces.
/* flash-message.js */
class FlashMessage {
// ...
}
export { FlashMessage }
When using named export, the script that loads the module needs to assign it to a variable with the same name as the class.
/* app.js */
import { FlashMessage } from "./flash-message"
let flash = new FlashMessage("Hello")
flash.renderAlert()
flash.renderLog()
Level 6 - Promises, Iterators, and Generators
Promises
Fetching Poll Results From the Server
It’s very important to understand how to work with JavaScript’s single-thread model. Otherwise, we might accidentally freeze the entire app, to the detriment of user experience.
Often users will click on a button, a link, or type within an input box, triggering some sort of Javascript action. While these actions occur, they might trigger other actions, such as fetching data from a back-end API.
While we wait for a response, we still must be able to interact with the page. If we mess up and write bad code that blocks the page, we can make elements non-responsive, affecting the user experience.
Avoiding Code That Blocks
Once the browser blocks executing a script, it stops running other scripts, rendering elements and responding to user events like keyboard and mouse interactions.
// Page freezes until a value is returned
// from the getPollResultsFromServer function.
let results = getPollResultsFromServer("Sass vs. LESS")
ui.renderSidebar(results)
In order to avoid blocking the main thread of execution, we write non-blocking code like this:
getPollResultsFromServer("Sass vs LESS", function(results) {
ui.renderSidebar(results)
})
We are passing a callback to the function now, so that it can be responsible for calling the callback function when it receives the response from the API server.
Passing Callbacks to Continue Execution
In continuation-passing style (CPS) async programming, we tell a function how to continue execution by passing callbacks.
One issue is that this can grow to complicated nested code, resulting in error checking on every single callback.
getPollResultsFromServer(pollName, function(error, results) {
if (error) {
//.. handle error
}
// ...
ui.renderSidebar(results, function(error) {
if (error) {
//.. handle error
}
// ...
sendNotificationToServer(pollName, results, function(error, response) {
if (error) {
//.. handle error
}
// ...
doSomethingElseNonBlocking(response, function(error) {
if (error) {
//.. handle error
}
// ...
})
})
})
})
The Best of Both Worlds With Promises
A Promise is a new abstraction that allows us to write async code in an easier way.
getPollResultsFromServer("Sass vs. LESS")
.then(ui.renderSidebar)
.then(sendNotificationsToServer)
.then(doSomethingElseNonBlocking)
.catch(function(error) {
console.log("Error: ", error)
})
This is still non-blocking, but not using nested callbacks anymore.
Creating a New Promise Object
The Promise constructor function takes an anonymous function with 2 callback arguments known as handlers.
function getPollResultsFromServer(pollName) {
return new Promise(function(resolve, reject) {
// called when the non-blocking code is done executing
resolve(someValue)
// called when an error occurs
reject(someValue)
})
}
Handlers are responsible for either resolving, or rejecting the Promise.
The Lifecycle of a Promise Object
Creating a new Promise automatically sets it to the pending state. Then, it can do 1 of 2 things: become fulfilled or rejected.
A Promise represents a future value, such as the eventual result of an asynchronous operation.
let fetchingResults = getPollResultsFromServer("Sass vs. less")
The fetchingResults
variable contains the Promise object in the pending state.
Resolving a Promise
Let’s wrap the XMLHttpRequest object API within a Promise. Calling the resolve() handler moves the Promise to a fulfilled state.
function getPollResultsFromServer(pollName) {
return new Promise(function(resolve, reject) {
let url = `/results/${pollName}`
let request = new XMLHttpRequest()
request.open("GET", url, true)
request.onload = function() {
if (request.status >= 200 && request.status < 400) {
resolve(JSON.parse(request.response))
}
}
// ...
request.send()
})
}
Reading Results From a Promise
We can use the then() method to read results from the Promise once it’s resolved. This method takes a function that will only be invoked once the Promise is resolved.
function getPollResultsFromServer(pollName) {
// ...
resolve(JSON.parse(request.response))
// ...
}
let fetchingResults = getPollResultsFromServer("Sass vs Less")
fetchingResults.then(function(results) {
// renders HTML to the page
ui.renderSidebar(results)
})
The callback passed to then() will receive the argument that was passed to resolve().
Removing Temporary Variables
We are currently using a temporary variable to store our Promise object, but it’s not really necessary. Let’s replace it with chaining function calls.
getPollResultsFromServer("Sass vs Less").then(function(results) {
ui.renderSidebar(results)
})
Chaining Multiple Thens
getPollResultsFromServer("Sass vs Less")
.then(function(results) {
// only returns poll results from Orlando
return results.filter(result => result.city === "Orlando")
})
.then(function(resultsFromOrlando) {
ui.renderSidebar(resultsFromOrlando)
})
Rejecting a Promise
We’ll call the reject() handler for unsuccessful status codes and also when the onerror event is triggered on our request object. Both move the Promise to a rejected state.
function getPollResultsFromServer(pollName) {
return new Promise(function(resolve, reject) {
// ...
request.onload = function() {
if (request.status >= 200 && request.status < 400) {
resolve(JSON.parse(request.response))
} else {
reject(new Error(request.status))
}
}
request.onerror = function() {
reject(new Error("Error Fetching Results"))
}
// ...
request.send()
})
}
Rejecting a Promise moves it to a rejected state.
Catching Rejected Promises
Once an error occurs, execution moves immediately to the catch() function. None of the remaining then() functions are invoked.
getPollResultsFromServer("Sass vs Less")
.then(function(results) {
// only returns poll results from Orlando
return results.filter(result => result.city === "Orlando")
})
.then(function(resultsFromOrlando) {
ui.renderSidebar(resultsFromOrlando)
})
.catch(function(error) {
console.log("Error: ", error)
})
Passing Functions as Arguments
We can make our code more succinct by passing function arguments to then, instead of using anonymous functions.
function filterResults(results) { // ... }
// new method initializer shorthand syntax
let ui = {
renderSidebar(filteredResults){ // ... }
};
getPollResultsFromServer("Sass vs. Less")
.then(filterResults)
.then(ui.renderSidebar)
.catch(function(error){
console.log("Error: ", error);
});
Iterators
What We Know About Iterables So Far
Arrays are iterable objects, which means we can use them with for…of.
let names = ["Sam", "Tyler", "Brook"]
for (let name of names) {
console.log(name)
}
Plain JavaScript objects are not iterable, so they do not work with for…of out-of-the-box.
let post = {
title: "New Features in JS",
replies: 19
}
// TypeError: post[Symbol.iterator] is not a function
for (let p of post) {
console.log(p)
}
Iterables Return Iterators
Iterables return an iterator object. This object knows how to access items from a collection 1 at a time, while keeping track of its current position within the sequence.
let names = ["Sam", "Tyler", "Brook"]
for (let name of names) {
console.log(name)
}
// what's really happening behind the scenes
let iterator = names[Symbol.iterator]()
let firstRun = iterator.next()
// firstRun: {done: false, value: "Sam"}
let name = firstRun.value
let secondRun = iterator.next()
// firstRun: {done: false, value: "Tyler"}
let name = secondRun.value
let thirdRun = iterator.next()
// firstRun: {done: false, value: "Brook"}
let name = thirdRun.value
The next() method is called by the loop. Once ‘done’ is true, the loop is ended.
Understanding the next Method
Each time next() is called, it returns an object with 2 specific properties: done and value.
done(boolean)
- Will be false if the iterator is able to return a value from the collection
- Will be true if the iterator is past the end of the collection
value(any)
- Any value returned by the iterator. When done is true, this returns undefined. { done: true, value: undefined }
The First Step Toward an Iterator Object
An iterator is an object with a next property, returned by the result of calling the Symbol.iterator method.
let post = {
title: "New Features in JS",
replies: 19
}
post[Symbol.iterator] = function() {
let next = () => {
// ...
}
return { next }
}
// Cannot read property 'done' of undefined
for (let p of post) {
console.log(p)
}
Navigating the Sequence
We can use Object.keys to build an array with property names for our object. We’ll also use a counter (count) and a boolean flag (isDone) to help us navigate our collection.
let post = { // ... }
post[Symbol.iterator] = function() {
let properties = Object.keys(this); // returns array with property names
let count = 0; // used to access properties array by index
let isDone = false; // set to true when done with the loop
let next = () => {
if (count >= properties.length) {
isDone = true;
}
// 'this' refers to the post object
return { done: isDone, value: this[properties[count++]] };
}
return { next };
};
Running Our Custom Iterator
We’ve successfully made our plain JavaScript object iterable, and it can now be used with for…of.
let post = {
title: "New Features in JS",
replies: 19
};
post[Symbol.iterator] = function() {
// ...
return {next};
}
// works properly now
for let(p of post) {
console.log(p);
}
// works with spread operator also
let values = [...post];
console.log(values); // ['New Features in JS', 19]
Iterables With Destructuring
Lastly, destructuring assignments will also work with iterables.
let [title, replies] = post
console.log(title) // New Features in JS
console.log(replies) // 19
Generators
Generator Functions
The *function ** declaration defines generator functions**. These are special functions from which we can use the *yield* keyword to return **iterator objects.
function* nameList() {
yield "Sam" // { done: false, value: "Sam" }
yield "Tyler" // { done: false, value: "Tyler" }
}
It doesn’t matter where you place the star character in-between.
function *nameList() { // ... }
function* nameList() { // ... }
function * nameList() { // ... }
Generator Objects and for…of
Generator functions return objects that provide the same next method expected by for…of, the spread operator, and the destructuring assignment.
function* nameList() {
yield "Sam" // { done: false, value: "Sam" }
yield "Tyler" // { done: false, value: "Tyler" }
}
// nameList() returns a generator object
for (let name of nameList()) {
console.log(name)
}
let names = [...nameList()]
console.log(names) // ["Sam", "Tyler"]
let [first, second] = nameList()
console.log(first, second) // Sam Tyler
Replacing Manual Iterator Objects
Knowing how to manually craft an iterator object is important, but there is a shorter syntax.
let post = { title: "New Features in JS", replies: 19 }
post[Symbol.iterator] = function() {
let properties = Object.keys(this)
let count = 0
let isDone = false
let next = () => {
if (count >= properties.length) {
isDone = true
}
return { done: isDone, value: this[properties[count++]] }
}
return { next }
}
Refactoring to Generator Functions
Each time yield is called, our function returns a new iterator object and then pauses until it’s called again.
let post = { title: "New Features in JS", replies: 19 }
// generator functions can be anonymous
post[Symbol.iterator] = function*() {
let properties = Object.keys(this)
for (let p of properties) {
yield this[p]
}
}
// this is the same as
post[Symbol.iterator] = function*() {
yield this.title
yield this.replies
}
for (let p of post) {
console.log(p)
}
// Output:
// New Features in JS
// 19