ES6 (short for ECMAScript 2015), is the sixth version of ECMAScript, which is a general-purpose, cross-platform, and vendor-neutral programming language. ECMAScript is defined in ECMA Standard (ECMA-262) by Ecma International. Most of the time, ECMAScript is more commonly known by the name JavaScript.
Understanding ES6 is the key to writing web applications using modern JavaScript. Owing to the scope of this book, we will only cover the basics of new featuresintroduced in ES6 here as you will see them in the rest of the book.
As mentioned earlier, in ES6, you can uselet
to define variables or useconst
to define constants, and they will have block-level scope. And in the same scope, you can not redefine a variable usinglet
. Also, you cannot access a variable or a constant that is defined withlet
orconst
before its declaration, since there is no variable hoisting withlet
orconst
.
Let's see the followingworkout
example:
1.function workout() { 2.let gym = 'Gym A'; 3. 4.const gymStatuses = {'Gym A': 'open', 'Gym B': 'closed'}; 5.for (let gym in gymStatuses) { 6.console.log(gym + ' is ' + gymStatuses[gym]); 7.} 8. 9.{ 10. const gym = 'Gym B'; 11. console.log('Workout in ' + gym); 12. // The following will throw TypeError 13. // gym = 'Gym C'; 14. } 15. 16. console.log('Workout in ' + gym); 17. 18. { 19. function gym () { 20. console.log('Workout in a separate gym'); 21. } 22. gym(); 23. } 24. 25. if (gymStatuses[gym] == 'open') { 26. let exercises = ['Treadmill', 'Pushup', 'Spinning']; 27. } 28. // exercises are no longer accessible here 29. // console.log(exercises); 30. 31. try { 32. let gym = 'Gym C'; 33. console.log('Workout in ' + gym); 34. throw new Error('Gym is closed'); 35. } catch (err) { 36. console.log(err); 37. let gym = 'Gym D'; 38. console.log('Workout in ' + gym); 39. } 40. } 41. workout();
In line 2
, we declare thegym
variable, and it is visible in theworkout()
function body. In line 5
, we declare thegym
variable within the for
loop block. It shadows thegym
variable declared in line 2
and is only accessible within that for
loop block.
In lines 9
to 14
, we declare a new scope using a block statement. The gym
constant declared in line 10
is only accessible within that scope. And as you can see in line 13
, assigning a value to a constant will causeTypeError
.
In line 16
, thegym
variable is back to the one declared in line 2
. In lines 18
to 23
, we declare thegym
function and it is only accessible within that block.
In line 26
, we define the exercises
variable within theif
block. And as you can see from line 29
, it is no longer accessible outside theif
block.
In lines 31
to 39
, we declare a try
-catch
block. As you can see in lines 32
and 37
, the try
block and catch
block are in different scopes.
To wrap up, usinglet
andconst
, we can archive block-level scope with for
loop blocks, if
blocks, try-catch
blocks, and block statements, as well as switch
blocks.
ES2015 introduces classes, which is primarily a syntactical sugar over prototype-based inheritance. With the class syntax, you can create constructors, extends from a superclass, and create static methods, as well as getters and setters.
Let's see the following example that uses the class syntax to implementUser
, andTeamMember
:
1.class User { 2.constructor(name, interests) { 3.this.name = name; 4.this.interests = interests; 5.} 6.greeting () { 7.console.log('Hi, I\'m ' + this.name + '.'); 8.} 9.get interestsCount () { 10. return this.interests ? this.interests.length : 0; 11. } 12. }
In lines 1
to 12
, we define class User
, which accepts two arguments via its constructor. It has a greeting()
method and aninterestsCount
getter:
13. class TeamMember extends User { 14. constructor(name, interests) { 15. super(name, interests); 16. this._tasks = []; 17. this._welcomeText = 'Welcome to the team!'; 18. } 19. greeting () { 20. console.log('I\' m ' + this.name + '. ' + this._welcomeText); 21. } 22. work () { 23. console.log('I\' m working on ' + this._tasks.length + ' tasks.') 24. } 25. set tasks (tasks) { 26. let acceptedTasks = []; 27. if (tasks.length > TeamMember.maxTasksCapacity()) { 28. acceptedTasks = tasks.slice(0, TeamMember.maxTasksCapacity()); 29. console.log('It\'s over max capacity. Can only take two.'); 30. } else { 31. acceptedTasks = tasks; 32. } 33. this._tasks = this._tasks.concat(acceptedTasks); 34. } 35. static maxTasksCapacity () { 36. return 2; 37. } 38. }
In lines 13
to 38
, we create a TeamMember
class to extend from User
. In its constructor, it calls the constructor of the User
with super
to instantiate the properties of name
and interests
. We also define two additional properties, _tasks
and _welcomeText
. The preceding underscore suggests that they are regarded as private properties and should not be changed directly from outside. However, nothing is private in JavaScript. You can still access these properties, for example, member._tasks
, and member._welcomeText
.
We override the greeting()
method of user
in line 20
and add a newwork()
method in line 22
. In lines 25
to 34
, we define a setter tasks
, inside which we access the maxTasksCapacity()
static method of TeamMember
:
39. let member = new TeamMember('Sunny', ['Traveling']); 40. member.greeting(); // I' m Sunny. Welcome to the team! 41. member.tasks = ['Buy three tickets', 'Book a hotel', 'Rent a car']; // It's over max capacity. Can only take two. 42. member.work(); // I' m working on 2 tasks. 43. console.log(member.interestsCount); // 1 44. member.interestsCount = 2;// This won’t save the change 45. console.log(member.interestsCount); // 1 46. console.log(member.tasks); // undefined
As you can see, in lines 39
to 43
, the member
object has all the features of the User
class and TeamMember
, working as expected. In lines 44
to 45
, we try to make changes tomember.interestsCount
, but it won't work because there is no setter defined. And line 46
shows that accessingmember.tasks
results in undefined
because we didn't define a getter for it.
Note
You cannot use member.constructor
to access the constructor of the TeamMember
defined in line 14
. It is for accessing themember
object’s constructor function, in this case,TeamMember
.
And now let's see how to add a new method, eat()
, to the User
class:
User.prototype.eat = function () { console.log('What will I have for lunch?'); }; member.eat();// What will I have for lunch?
You still need to add it to the prototype object ofUser
. If you add it directly toUser
as follows, you will getTypeError
:
User.sleep = function () { console.log('Go to sleep'); }; member.sleep();// Uncaught TypeError: member.sleep is not a function User.sleep();// Go to sleep
It is because as a result of writing in this way that you addsleep
as a property of theUser
class itself or, more precisely, the User
constructor function itself. And you might have already noticed thatsleep
becomes a static method of theUser
class. When using the class syntax, when you define a method, behind the scene, JavaScript adds it to its prototype object, and when you define a static method, JavaScript adds it to the constructor function:
console.log(User.prototype.hasOwnProperty('eat'));// true console.log(User.hasOwnProperty('sleep'));// true
In ES6, object literals support setting prototypes, shorthand assignments, defining methods, making super calls, and computing properties with expressions.
Let's see the following example, which creates anadvisor
object with a TeamMember
object as its prototype:
1.const advice = 'Stay hungry. Stay foolish.'; 2. 3.let advisor = { 4.__proto__: new TeamMember('Adam', ['Consulting']), 5.advice, 6.greeting () { 7.super.greeting(); 8.console.log(this.advice); 9.}, 10.[advice.split('.')[0]]: 'Always learn more' 11. };
Line 4
, assigning the object of TeamMember
to theadvisor
object's__proto__
property makesadvisor
an instance ofTeamMember
:
console.log(TeamMember.prototype.isPrototypeOf(advisor));// true console.log(advisor instanceof TeamMember);// true
Line 5
is a shorthand assignment ofadvice:advice
. Line 7
is creating thegreeting()
method of TeamMember
, inside which it will invoke the greeting
method of TeamMember
:
advisor.greeting(); // I' m Adam. Welcome to the team! // Stay hungry. Stay foolish.
In line 10
, theStay hungry
property is calculated with bracket notation. And to access this property, in this case, because the property name contains a space, you need to use bracket notation, like this—advisor['Stay hungry']
.
ES6 introduces arrow functions as a function shorthand, using=>
syntax. Arrow functions support statement block bodies as well as expression bodies. When using an expression body, the expression's result is the value that the function returns.
An arrow syntax begins with function arguments, then the arrow=>
, and then the function body. Let's look at the following different variations of arrow functions. The examples are written in both ES5 syntax and ES6 arrow function syntax:
const fruits = [{name: 'apple', price: 100}, {name: 'orange', price: 80}, {name: 'banana', price: 120}]; // Variation 1 // When no arguments, an empty set of parentheses is required var countFruits = () => fruits.length; // equivalent to ES5 var countFruits = function () { return fruits.length; }; // Variation 2 // When there is one argument, parentheses can be omitted. // The expression value is the return value of the function. fruits.filter(fruit => fruit.price > 100); // equivalent to ES5 fruits.filter(function(fruit) { return fruit.price > 100; }); // Variation 3 // The function returns an object literal, it needs to be wrapped // by parentheses. var inventory = fruits.map(fruit => ({name: fruit.name, storage: 1})); // equivalent to ES5 var inventory = fruits.map(function (fruit) { return {name: fruit.name, storage: 1}; }); // Variation 4 // When the function has statements body and it needs to return a // result, the return statement is required var inventory = fruits.map(fruit => { console.log('Checking ' + fruit.name + ' storage'); return {name: fruit.name, storage: 1}; }); // equivalent to ES5 var inventory = fruits.map(function (fruit) { console.log('Checking ' + fruit.name + ' storage'); return {name: fruit.name, storage: 1}; });
There is an additional note regarding variation 3. When an arrow function uses curly brackets, its function body needs to be a statement or statements:
var sum = (a, b) => { return a + b }; sum(1, 2); // 3
The sum
function won't work as expected when it is written like this:
var sum = (a, b) => { a + b }; sum(1, 2);// undefined // Using expression will work var sum = (a, b) => a + b; sum(1, 2);// 3
Arrow functions have a shorter syntax and also many other important differences compared with ES5 function. Let's go through some of these differences one by one.
An arrow function does not have its ownthis
. Unlike an ES5 function, that will create a separate execution context of its own, an arrow function uses surrounding execution context. Let's see the following shopping cart example:
1.var shoppingCart = { 2.items: ['Apple', 'Orange'], 3.inventory: {Apple: 1, Orange: 0}, 4.checkout () { 5.this.items.forEach(item => { 6.if (!this.inventory[item]) { 7.console.log('Item ' + item + ' has sold out.'); 8.} 9.}) 10. } 11. } 12. shoppingCart.checkout(); 13. 14. // equivalent to ES5 15. var shoppingCart = { 16. items: ['Apple', 'Orange'], 17. inventory: {Apple: 1, Orange: 0}, 18. checkout: function () { 19. // Reassign context and use closure to make it 20. // visible to the callback passed to forEach 21. var that = this 22. this.items.forEach(function(item){ 23. if (!that.inventory[item]) { 24. console.log('Item ' + item + ' has sold out.'); 25. } 26. }) 27. } 28. } 29. shoppingCart.checkout();
In line 6
,this
refers to theshoppingCart
object itself, even it is inside the callback of theArray.prototype.forEach()
method. As you can see in line 21
, with the ES5 version, you need to use closure to keep the execution context available to the callback function.
And because an arrow function does not have a separate execution context, when it is invoked with Function.prototype.call()
,Function.prototype.apply()
, orFunction.prototype.bind()
method, the execution context that passed in as the first argument will be ignored. Let's take a look at an example:
1. var name = 'Unknown'; 2. var greeting = () => { 3. console.log('Hi, I\'m ' + this.name); 4. }; 5. greeting.call({name: 'Sunny'});// I'm Unknown 6. greeting.apply({name: 'Tod'}); // I'm Unknown 7. var newGreeting = greeting.bind({name: 'James'}); 8. newGreeting(); // I'm Unknown
As you can see from line 3
, in an arrow function,this
always resolves to its surrounding execution context. Thecall()
,apply()
, orbind()
method has no effect on its execution context.
Note
Unlike ES5 functions, arrow functions do not have their ownarguments
object. Thearguments
object is a reference to the surrounding function'sarguments
object.
Because arrow functions use its surrounding execution context, they are not suitable for defining methods of objects.
Let's see the following shopping cart example, which uses an arrow function for the checkout:
1.var shoppingCart = { 2.items: ['Apple', 'Orange'], 3.inventory: {Apple: 1, Orange: 0}, 4.checkout: () => { 5.this.items.forEach(item => { 6.if (!this.inventory[item]) { 7.console.log('Item ' + item + ' has sold out.'); 8.} 9.}) 10. } 11. } 12. shoppingCart.checkout();
In line 4
, we changecheckout
to an arrow function. And because an arrow function uses its surrounding execution context,this
in line 5
no longer references the shoppingCart
object and it will throwUncaught TypeError: Cannot read property 'forEach' of undefined
.
The preceding shopping cart example is written with object literals. Arrow functions do not work well when defining object methods using a prototype object either. Let's see the following example:
1.class User { 2.constructor(name) { 3.this.name = name; 4.} 5.} 6.User.prototype.swim = () => { 7.console.log(this.name + ' is swimming'); 8.}; 9.var user = new User(); 10. console.log(user.swim()); //is swimming
As you can see from the output, in line 7
, this
does not reference theuser
object. In this example, it references the global context.
Arrow functions do not have prototype objects, hence, they are not constructor functions. And they cannot be invoked with anew
operator. An error will be thrown if you try that. Here's an example:
const WorkoutPlan = () => {}; // Uncaught TypeError: WorkoutPlan is not a constructor let workoutPlan = new WorkoutPlan(); console.log(WorkoutPlan.prototype);// undefined
In ES6, you can define the default values of a function's parameters. This is quite a useful improvement because the equivalent implementation in ES5 is not only tedious but also decreases the readability of the code.
Let's see an example here:
const shoppingCart = []; function addToCart(item, size = 1) { shoppingCart.push({item: item, count: size}); } addToCart('Apple'); // size is 1 addToCart('Orange', 2); // size is 2
In this example, we give the parameter size
a default value, 1
. And let's see how we can archive the same thing in ES5. Here is an equivalent of the addToCart
function in ES5:
function addToCart(item, size) { size = (typeof size !== 'undefined') ? size : 1; shoppingCart.push({item: item, count: size}); }
As you can see, using the ES6 default parameter can improve the readability of the code and make the code easier to maintain.
In ES5, inside a function body, you can use thearguments
object to iterate the parameters of the function. In ES6, you can use rest parameters syntax to define an indefinite number of arguments as an array.
Let's see the following example:
1.// Using arguments in ES5 2.function workout(exercise1) { 3.var todos = Array.prototype.slice.call(arguments, workout.length); 4.console.log('Start from ' + exercise1); 5.console.log(todos.length + ' more to do'); 6.} 7.// equivalent to rest parameters in ES6 8.function workout(exercise1, ...todos) { 9.console.log('Start from ' + exercise1);// Start from //Treadmill 10. console.log(todos.length + ' more to do'); // 2 more to do 11. console.log('Args length: ' + workout.length); // Args length: 1 11. } 12. workout('Treadmill', 'Pushup', 'Spinning');
In line 8
, we define a rest parametertodos
. It is prefixed with three dots and is the last named parameter of theworkout()
function. To archive this in ES5, as you can see in line 3
, we need to slice thearguments
object. And in line 11
, you can see that the rest parametertodos
does not affect the length of the argument in the workout ()
function.
In ES6, when the three dot notation (...
) is used in a function declaration, it defines a rest parameter; when it is used with an array, it spreads the array's elements. You can pass each element of the array to a function in this way. You can also use it in array literals.
Let's see the following example:
1. let urgentTasks = ['Buy three tickets']; 2. let normalTasks = ['Book a hotel', 'Rent a car']; 3. let allTasks = [...urgentTasks, ...normalTasks]; 4. 5. ((first, second) => { 6. console.log('Working on ' + first + ' and ' + second) 7. })(...allTasks);
In line 3
, we use spread syntax to expand theurgentTasks
array and thenormalTasks
array. And in line 7
, we use spread syntax to expand theallTasks
array and pass each element of it as arguments of the function. And the first
argument has the valueBuy three tickets
, while thesecond
argument has the valueBook a hotel
.
In ES6, you can use the destructuring assignment to unpack elements in an array, characters in a string, or properties in an object and assign them to distinct variables using syntax similar to array literals and object literals. You can do this when declaring variables, assigning variables, or assigning function parameters.
First of all, let's see an example of object destructuring:
1. let user = {name:'Sunny', interests:['Traveling', 'Swimming']}; 2. let {name, interests, tasks} = user; 3. console.log(name); // Sunny 4. console.log(interests);// ["Traveling", "Swimming"] 5. console.log(tasks);// undefined
As you can see, thename
andinterests
variables defined in line 2
pick up the values of the properties with the same name in theuser
object. And thetasks
variable doesn't have a matching property in theuser
object. Its value remains as undefined
. You can avoid this by giving it a default value, like this:
let {name, interests, tasks=[]} = user; console.log(tasks)// []
Another thing you can do with object destructuring is that you can choose a different variable name. In the following example, we pick the value of the name
property of theuser
object and assign it to thefirstName
variable:
let {name: firstName} = user; console.log(firstName)// Sunny
Array destructuring is similar to object destructuring. Instead of using curly brackets, array destructuring uses brackets to do the destructuring. Here is an example of array destructuring:
let [first, second] = ['Traveling', 'Swimming', 'Shopping']; console.log(first); // Traveling console.log(second);// Swimming
You can also skip variables and only pick the one that you need, like the following:
let [,,third, fourth] = ['Traveling', 'Swimming', 'Shopping']; console.log(third); // Shopping console.log(fourth);// undefined
As you can see, we skip the first two variables and only require the third and the fourth. However, in our case, thefourth
variable doesn't match any elements in the array and its value remains asundefined
. Also, you can give it a default value, like this:
let [,,third, fourth = ''] = ['Traveling', 'Swimming', 'Shopping']; console.log(fourth);// an empty string
Similar to using object literals and array literals to create complex nested data structures with a terse syntax, you can use a destructuring assignment to pick up variables in a deeply nested data structure.
Let's see the following example, in which we only need the user's second interest:
1. let user = {name:'Sunny', interests:['Traveling', 'Swimming']}; 2. let {interests:[,second]} = user; 3. console.log(second);// Swimming 4. console.log(interests); // ReferenceError
In line 2
, even though we putinterests
in the destructuring assignment, JavaScript doesn't really declare it. As you can see in line 4
, accessing it will raiseReferenceError
. What happens here is that JavaScript uses the part on left side of the colon (:
), in this case,interests
, to extract the value of the property of the same name, and uses the part on the right side to do further destructuring assignments. If you want to extract the interests
property, as demonstrated previously, you need to write it in like this: let {interests} = user;
.
Here is another example in which the name
property of the second element in an array is destructured:
const fruits = [{name:'Apple', price:100},{name:'Orange', price:80}]; let [,{name:secondFruitName}] = fruits; console.log(secondFruitName); // Orange
You can use the same syntax of the rest parameters in the destructuring assignment to put the remainder of the elements of an array into another array. Here is an example:
let [first, ...others] = ['Traveling', 'Swimming', 'Shopping']; console.log(others); // ["Swimming", "Shopping"]
As you can see, the second and third items of the array have been copied into the others
variable. We can use this syntax to copy an array. However, this is only a shallow clone. When the elements of the array are objects, changes to an object's property of the copied array will be seen in the original array because essentially, the elements of both arrays reference the same object. Here is an example:
1. const fruits = [{name:'Apple', price:100},{name:'Orange', price:80}]; 2. let [...myFruits] = fruits; 3. console.log(myFruits[0].name);// Apple 4. myFruits.push({name:'Banana', price:90}); 5. console.log(myFruits.length); // 3 6. console.log(fruits.length); // 2 7. myFruits[0].price = 110; 8. console.log(fruits[0].price); // 110
As you can see in line 2
, we use the destructuring assignment syntax to copy the fruits
array into the myFruits
array. And adding a new item to the copied array doesn't affect the original array, as you can see in lines 4
to 6
. However, changing the value of the price
property from the copied array will be also seen in the original array.
You can apply a destructuring assignment to function parameters as well. Let's see the following example:
1. function workout({gym}, times) { 2. console.log('Workout in ' + gym + ' for ' + times + ' times'); 3. } 4. let thisWeek = {gym: 'Gym A'}; 5. workout(thisWeek, 2); // Workout in Gym A for 2 times
As you can see, in line 1
, we use object destructuring syntax to extract the gym
variable from the first argument of the workout()
function. In this way, the argument passed to the workout()
function cannot be null
or undefined
. Otherwise, TypeError
will be thrown. You can pass a number, a string, an array, or a function to the workout()
function and JavaScript won't complain about it, although you will get undefined
as a value for the gym
variable.
Let's look at another example, in which we will perform a further destructuring of a destructured variable:
1. function workout({gym, todos}) { 2. let [first] = todos; 3. console.log('Start ' + first + ' in ' + gym); 4. } 5. let today = {gym: 'Gym A', todos: ['Treadmill']}; 6. workout(today); // Start Treadmill in Gym A 7. workout({gym: 'Gym B'}) // throw TypeError
In line 1
, we do a parameter destructuring of the first argument, and in line 2
we do a further destructuring of the todos
variable. In this way, the argument passed to the workout()
function must have a todos
property and its value is an array. Otherwise, TypeError
will be thrown, as you can see in line 7
. This is because, in line 2
, JavaScript cannot do destructuring on undefined
ornull
. We can improve this by giving todos
a default value, as follows:
1. function workout({gym, todos=['Treadmill']}) { 2. let [first] = todos; 3. console.log('Start ' + first + ' in ' + gym); 4. } 5. workout({gym: 'Gym A'});// Start Treadmill in Gym A 6. workout(); // throw TypeError
As you can see, in line 1
, we only give todos
a default value and we have to call the workout()
function with a parameter. Calling it without any parameters, as in line 6
, will still throw an error. It is because JavaScript still cannot do destructuring on undefined
to get a value for the gym
variable. And if you try to assign a default value to gym
itself, such as workout({gym='', ...)
, it won't work. You need to assign the entire parameter destructuring a default value, like this:
function workout({gym='', todos=['Treadmill']} = {}) {
...
}
Template literals provide the ability to embed expressions in string literals and support multiple lines. The syntax is to use the back-tick (`
) character to enclose the string instead of single quotes or double quotes. Here is an example:
let user = { name: 'Ted', greeting () { console.log(`Hello, I'm ${this.name}.`); } }; user.greeting();// Hello, I'm Ted.
As you can see, inside the template literals, you can access the execution context via this
by using the syntax ${...}
. Here is another example with multiple lines:
let greeting = `Hello, I'm ${user.name}. Welcome to the team!`; console.log(greeting);// Hello, I'm Ted. // Welcome to the team!
One caveat is that all the whitespaces inside the back-tick characters are part of the output. So, if you indent the second line as follows, the output wouldn't look good:
let greeting = `Hello, I'm ${user.name}. Welcome to the team!`; console.log(greeting); // Hello, I'm Ted. //Welcome to the team!
In ES6, JavaScript provides language-level support for modules. It usesexport
andimport
to organize modules and create a static module structure. That means you can determine imports and exports at compile time. Another important feature of ES6's module is that imports and exports must be at the top level. You cannot nest them inside blocks such as if
andtry/catch
.
Note
Besides static declarations of imports and exports, there is a proposal to use theimport()
operator to programmatically load modules. The proposal is, at the time of writing, at stage 3 of the TC39 process. You can checkout the details at https://github.com/tc39/proposal-dynamic-import.
To create a module, all you need to do is to put your JavaScript code into a .js
file. You can choose to use tools such as Babel (http://babeljs.io) to compile ES6 code into ES5, together with tools such as webpack (https://webpack.js.org) to bundle the code together. Or, another way to use the module files is to use <script type="module">
to load them into browsers.
Inside a module, you can choose to not export anything. Or, you can export primitive values, functions, classes, and objects. There are two types of exports—named exports and default exports. You can have multiple named exports in the same module but only a single default export in that module.
In the following examples, we will create auser.js
module that exports theUser
class, atasks.js
module that tracks the count of total completed tasks, and aroles.js
module that exports role constants.
Let's have a look atuser.js
file:
1. export default class User { 2. constructor (name, role) { 3. this.name = name; 4. this.role = role; 5. } 6. };
In this module, we export theUser
class inline as the default export by placing the keywordsexport
anddefault
in front of it. Instead of declaring an export
inline, you can declare theUser
class first and then export it at the bottom, or anywhere that is at the top level in the module, even before theUser
class.
Let's have a look atroles.js
file:
1. const DEFAULT_ROLE = 'User'; 2. const ADMIN = 'Admin'; 3. export {DEFAULT_ROLE as USER, ADMIN};
In this module, we create two constants and then export
them using named exports in a list by wrapping them in curly brackets. Yes, in curly brackets. Don't think of them as exporting an object. And as you can see in line 3
, we can rename things during export
. We can also do the rename withimport
too. We will cover that shortly.
Let's have a look at tasks.js
file:
1. console.log('Inside tasks module'); 2. export default function completeTask(user) { 2. console.log(`${user.name} completed a task`); 3. completedCount++; 4. } 5. // Keep track of the count of completed task 6. export let completedCount = 0;
In this module, in line 2
, we have a default export of thecompleteTask
function and a named export of acompletedCount
variable in line 6
.
Now, let's create a moduleapp.js
to import the modules we just created.
Let's have a look at app.js
file:
1.import User from './user.js'; 2.import * as Roles from './roles.js'; 3.import completeTask from './tasks.js'; 4.import {completedCount} from './tasks.js'; 5. 6.let user = new User('Ted', Roles.USER); 7.completeTask(user); 8.console.log(`Total completed ${completedCount}`); 9.// completedCount++; 10. // Only to show that you can change imported object. 11. // NOT a good practice to do it though. 12. User.prototype.walk = function () { 13. console.log(`${this.name} walks`); 14. }; 15. user.walk();
In line 1
, we use default import
to import theUser
class from theuser.js
module. You can use a different name other than User
here, for example,import AppUser from './user.js'
. default import
doesn't have to match the name used in the default export.
In line 2
, we use namespace import
to import theroles.js
module and named itRoles
. And as you can see from line 6
, we access the named exports of the roles.js
module using the dot notation.
In line 3
, we use default import
to import thecompleteTask
function from thetasks.js
module. And in line 4
, we use named import
to importcompletedCount
from the same module again. Because ES6 modules are singletons, even if we import it twice here, the code of thetasks.js
module is only evaluated once. You will see only oneInside tasks module
in the output when we run it. You can put default import
and named import
together. The following is equivalent to the preceding lines 3
and 4
:
import completeTask, {completedCount} from './tasks.js';
You can rename a named import
in case it collides with other local names in your module. For example, you can renamecompletedCount
tototalCompletedTasks
like this:
import {completedCount as totalCompletedTasks} from './tasks.js';
Just like function declarations, imports are hoisted. So, if we put line 1
after line 6
like this, it still works. However, it is not a recommended way to organize your imports. It is better to put all your imports at the top of the module so that you can see the dependencies at a glance:
let user = new User('Ted', Roles.USER); import User from './user.js';
Continue with the app.js
module. In line 7
, we invoke thecompleteTask()
function and it increases thecompletedCount
inside thetasks.js
module. Since it is exported, you can see the updated value ofcompletedCount
in another module, as shown in line 8
.
Line 9
is commented out. We were trying to change thecompletedCount
directly, which didn't work. And if you uncomment it, when we run the example later, you will seeTypeError
, saying that you cannot modify a constant. Wait. completedCount
is defined withlet
inside thetasks.js
module; it is not a constant. So what happened here?
Import declarations have two purposes. One, which is obvious, is to tell the JavaScript engine what modules need to be imported. The second is to tell JavaScript what names those exports of other modules should be. And JavaScript will create constants with those names, meaning you cannot reassign them.
However, it doesn't mean that you cannot change things that are imported. As you can see from lines 12
to 15
, we add thewalk()
method to theUser
class prototype. And you can see from the output, which will be shown later, that theuser
object created in line 6
has thewalk()
method right away.
Now, let's load theapp.js
module in an HTML page and run it inside Chrome.
Here is theindex.html
file:
1. <!DOCTYPE html> 2. <html> 3. <body> 4. <script type="module" src="./app.js"></script> 5. <script>console.log('A embedded script');</script> 6. </body> 7. </html>
In line 4
, we loadapp.js
as a module into the browser with<script type="module">
, which is specified in HTML and has thedefer
attribute by default, meaning the browser will execute the module after it finishes parsing the DOM. You will see in the output that line 5
, which is script code, will be executed before the code insideapp.js
.
Here are all the files in this example:
/app.js /index.html /roles.js /tasks.js /user.js
You need to run it from an HTTP server such as NGINX. Openingindex.html
directly as a file in Chrome won't work because of the CORS (short for Cross-Origin Resource Sharing) policy, which we will talk about in another chapter.
Note
If you need to spin up a simple HTTP server real quick, you can use http-server
, which requires zero configuration and can be started with a single command. First of all, you need to have Node.js installed and then run npm install http-server -g
. Once the installation completes, switch to the folder that contains the example code, run http-server -p 3000
, and then open http://localhost:3000
in Chrome.
You will need to go to Chrome's Developer Tools to see the output, which will be similar to the following:
A embedded script Inside tasks module Ted completed a task Total completed 1 Ted walks
As you can see from the output, the browser defers the execution of the module's code, while the script code is executed immediately, and thetasks.js
module is only evaluated once.
Starting from ES6, there are two types in JavaScript—scripts and modules. Unlike scripts code, where you need to put'use strict';
at the top of a file to render the code in strict mode, modules code is automatically in strict mode. And top-level variables of a module are local to that module unless you useexport
to make them available to the outside. And, at the top level of a module,this
refers toundefined
. In browsers, you can still access a window
object inside a module.
Promises are another option in addition to callbacks, events for asynchronous programming in JavaScript. Before ES6, libraries such as bluebird
(http://bluebirdjs.com) provided promises compatible with the Promises/A+ spec.
A promise represents the eventual result of an asynchronous operation, as described in the Promises/A+ spec. The result would be a successful completion or a failure. And it provides methods such as .then()
, and.catch()
for chaining multiple asynchronous operations together that would make the code similar to synchronous code that is easy to follow.
Note
The features of ES6 promises are a subset of those provided by libraries such as bluebird
. In this book, the promises we use are those defined in the ES6 language spec unless otherwise specified.
Let's look at an example in which we will get a list of projects from the server and then get tasks of those projects from the server in a separate API call. And then we will render it. The implementation here is a simplified version for demonstrating the differences between using callbacks and promises. We usesetTimeout
to stimulate an asynchronous operation.
First of all, let's see the version that uses callbacks:
1.function getProjects(callback) { 2.// Use setTimeout to stimulate calling server API 3.setTimeout(() => { 4.callback([{id:1, name:'Project A'},{id:2, name:'Project B'}]); 5.}, 100); 6.} 7.function getTasks(projects, callback) { 8.// Use setTimeout to stimulate calling server API 9.setTimeout(() => { 10. // Return tasks of specified projects 11. callback([{id: 1, projectId: 1, title: 'Task A'}, 12. {id: 2, projectId: 2, title: 'Task B'}]); 13. }, 100); 14. } 15. function render({projects, tasks}) { 16. console.log(`Render ${projects.length} projects and ${tasks.length} tasks`); 17. } 18. getProjects((projects) => { 19. getTasks(projects, (tasks) => { 20. render({projects, tasks}); 21. }); 22. });
As you can see in lines 18
to 22
, we use callbacks to organize asynchronous calls. And even though the code here is greatly simplified, you can still see that the getProjects()
,getTasks()
, andrender()
methods are nested, creating a pyramid of doom or callback hell.
Now, let's see the version that uses promises:
1.function getProjects() { 2.return new Promise((resolve, reject) => { 3.setTimeout(() => { 4.resolve([{id:1, name:'Project A'},{id:2, name:'Project B'}]); 5.}, 100); 6.}); 7.} 8.function getTasks(projects) { 9.return new Promise((resolve, reject) => { 10. setTimeout(() => { 11. resolve({projects, 12.tasks:['Buy three tickets', 'Book a hotel']}); 13. }, 100); 14. }); 15. } 16. function render({projects, tasks}) { 17. console.log(`Render ${projects.length} projects and ${tasks.length} tasks`); 18. } 19. getProjects() 20. .then(getTasks) 21. .then(render) 22. .catch((error) => { 23. // handle error 24. });
In lines 1
to 15
, in thegetProjects()
andgetTasks()
method, we wrap up asynchronous operations inside aPromise
object that is returned immediately. ThePromise
constructor function takes a function as its parameter. This function is called an executor function, which is executed immediately with two arguments, aresolve
function and areject
function. These two functions are provided by thePromise
implementation. When the asynchronous operation completes, you call theresolve
function with the result of the operation or no result at all. And when the operation fails, you can use the reject
function to reject the promise. Inside the executor function, if any error is thrown, the promise will be rejected too.
A promise is in one of these three states:
You cannot get the state of a promise programmatically. Instead, you can use the.then()
method of the promise to take action when the state changes to fulfilled, and use the .catch()
method to react when the state changed to rejected or an error are thrown during the operation.
The .then()
method of a promise
object takes two functions as its parameters. The first function in the argument is called when the promise is fulfilled. So it is usually referenced asonFulfilled
, and the second one is called when the promise is rejected, and it is usually referenced asonRejected
. The.then()
method will also return a promise
object. As you can see in lines 19
to 21
, we can use.then()
to chain all the operations. The.catch()
method in line 22
is actually a syntax sugar of.then(undefined, onRejected)
. Here, we put it as the last one in the chain to catch all the rejects and errors. You can also add.then()
after.catch()
to perform further operations.
The ES6 Promise
also provides the .all(iterable)
method to aggregate the results of multiple promises and the .race(iterable)
method to return a promise that fulfills or rejects as soon as one of the promises in the iterable fulfills or rejects.
Another two methods that ES6 Promise
provides are the .resolve(value)
method and the .reject(reason)
method. The .resolve(value)
method returns aPromise
object. When thevalue
is a promise, the returned promise will adopt its eventual state. That is when you call the.then()
method of the returned promise; theonFulfilled
handler will get the result of thevaluepromise. When thevalueis not a promise, the returned promise is in a fulfilled state and its result is a value. The.reject(reason)
method returns a promise that is in a rejected state with thereason
passed in to indicate why it is rejected.
As you might have noticed, promises do not help you write less code, but they do help you to improve your code's readability by providing a better way of organizing code flow.