The Modern JavaScript Tutorial - Part 1 Core JavaScript Note
Booknote of The Modern JavaScript Tutorial. This is a comprehensive guide to JavaScript programming in a modern style. The part 1 cover the core element of modern JavaScript.
An introduction
1.1 An introduction to JavaScript
Today, JavaScript can execute on any device that has the JavaScript
engine. The browser has an embedded engine sometimes called a
"JavaScript virtual machine". Different engines have different
"codenames". For example: V8
in Chrome and Opera or
SpiderMonkey
in Firefox
JavaScript's capabilities greatly depend on the environment it's running in. For example JavaScript in the browser has following restrictions:
- JavaScript on a webpage may not read/write arbitrary files on the hard disk, copy them or execute programs
- Different tabs/windows generally do not know about each other
- JavaScript can easily communicate over the net to the server where the current page came from. But its ability to receive data from other sites/domains is crippled
1.2 Manuals and specifications
The ECMA-252 specification contains the most in-depth, detailed and formalized information about JavaScript. It defines the language
MDN (Mozilla) JavaScript Reference is the main manual with examples and other information. It's great to get in-depth information about individual language functions, methods etc. One can find it at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference
1.3 Code editors
For IDEs, you can choose Visual Studio Code or WebStorm. For editors, any text editor should work
1.4 Developer console
In the browser, users don't see errors by default. To see errors and get a lot of other useful information about scripts, "developer tools" have been embedded in browsers
JavaScript Fundamentals
2.1 Hello, world!
The "script" tag
JavaScript programs can be inserted almost anywhere into an HTML
document using the <script>
tag
1 |
|
Modern markup
The <script>
tag has a few attributes that are
rarely used nowadays but can still be found in old code:
The
type
attribute:<script type=...>
HTML4 required a script to have a type. Usually it was
type="text/javascript"
. It's not required anymore. Also, the modern HTML standard totally changed the meaning of this attributeThe
language
attribute:<script language=...>
This attribute no longer makes sense because JavaScript is the default language
Comments before and after scripts
1
2
3<script type="text/javascript">
</script>These comments hide JavaScript code from old browsers that didn't know how to process the
<script>
tag
External scripts
Script files are attached to HTML with the src
attribute:
1 | <script src="/path/to/script.js"></script> |
As a rule, only the simplest script are put into HTML. More complex
ones reside in separate files. The benefit of a separate file is that
the browser will download it and store it in its cache. That reduces
traffic and makes pages faster. If src
is set, the script
content is ignored. A single <script>
tag can't have
both the src
attribute and code inside
2.2 Code structure
Semicolons
A semicolon may be omitted in most cases when a line break exists. In most cases, JavaScript interprets the line break as an "implicit" semicolon. This is called automatic semicolon insertion. But there are situations where JavaScript "fails" to assume a semicolon where it is really needed. Thus we recommend putting semicolons between statements even if they are separated by newlines
Comments
One line comments starts with //
Multiline comments start with /*
and ends with
*/
2.3 The modern mode, "use strict"
"use strict"
Until ECMAScript 5 in 2009, JavaScript had not modified any existed
features. This is change in ES5. It added new features to the language
and modified some of the existing ones. To keep the old code working,
most such modifications are off by default. You need to explicitly
enable them with a special directive: "use strict"
. When it
is located at the top of a script, the whole script works the "modern"
way
"use strict"
can be put at the beginning of a function.
Doing that enables strict mode in that function only. If it is not at
the top of your scripts or functions, it is ignored
2.4 Variables
A variable
1 | let message = "Hello"; |
There is also keyword var
which is almost the same as
let
. The difference is discussed later
Variable naming
- The name must contain only letters, digits, or the symbols
$
and_
- The first character must not be a digit
- Cannot be reserved names
Constants
1 | const birthday = "18.04.1982"; |
There is a practice to use constants as aliases for difficult-to-remember values that are prior to execution. "Hard-coded" variables.
2.5 Data types
There are eight basic data types in JavaScript. We can put any type in a variable. For example, a variable can at one moment be a string and then store a number
1 | // no error |
Number
The number type represents both integer and floating point numbers.
Besides regular numbers, there are so-called "special numeric values"
which also belong to this data type: Infinity
,
-Infinity
, and NaN
. NaN
represents a computational error. It is a result of an incorrect or
undefined mathematical operation. NaN
is sticky. Any
further operation on NaN
returns NaN
BitInt
In JavaScript, the "number" type cannot represent integer values
larger than
BigInt
type was recently added to the language to
represent integers of arbitrary length. A BigInt
vaule is
created by appending n
to the end of an integer:
1 | const bigInt = 12345678901234567890n; |
String
A string in JavaScript must be surrounded by quotes
1 | let str = "Hello"; |
Double and single quotes are "simple" quotes. There's practically no
difference between them in JS. Backticks are "extended functionality"
quotes. They allow use to embed variables and expressions into a string
by wrapping them in ${...}
. For example:
1 | let name = "John"; |
There is no character type
Boolean
The boolean type has only two values: true
and
false
The "null" value
The special null
value does not belong to any of the
types described above. In JavaScript, null
is not a
"reference to a non-existing object" or a "null pointer" like in some
other languages. It's just a special value which represents "nothing",
"empty" or "value unknown"
The "undefined" value
The meaning of undefined
is "value is not assigned"
Objects and symbols
The symbol
type is used to create unique identifiers for
objects
The typeof operator
The typeof
operator returns the type of the argument. It
supports two forms of syntax:
typeof x
typeof(x)
2.6 Interaction: alert, prompt, confirm
alert
It shows a message and waits for the user to press "OK"
prompt
The function prompt
accepts two arguments
1 | result = prompt(title, [default]); |
It shows a modal window with a text message, an input field for the visitor, and the buttons OK/Cancel
title
: the text to show the visitordefault
: an optional second parameter, the initial value for the input field. Always supply adefault
in IE
confirm
1 | result = confirm(question); |
The function confirm
shows a modal window with a
question and two buttons: OK and Cancel. The result is true
is OK is pressed and false
otherwise
2.7 Type conversions
String conversion
For example, alert(value)
does it to show the value. We
can also call the String(value)
function to convert a value
to a string
Numeric conversion
Numeric conversion happens in mathematical functions and expressions automatically. For example:
1 | alert("6" / "2"); // 3 |
Explicit conversion is usually required when we read a value from a
string-based source like a text form but expect a number to be entered.
If the string is not a valid number, the result of such a conversion is
NaN
. For instance:
1 | let age = Number("Please insert your age: "); |
Numeric conversion rules:
value | becomes |
---|---|
undefined |
NaN |
null |
0 |
true and false |
1 and 0 |
string |
Whitespaces from the start and end are removed. If the remaining
string is empty, the result is 0. Otherwise, the number is "read" from
the string. An error gives NaN |
Boolean conversion
It happens in logical operations but can slo be performed explicitly
with a call to Boolean(value)
. The conversion rule: *
Values that are intuitively "empty", like 0, an empty string,
null
, undefined
, and NaN
, become
false * Other values become true
2.8 Basic operators, maths
Terms: unary, binary, operand
- An operand is what operators are applied to
- An operator is unary if it has a single operand. For example, the
unary negation
-42
- An operator is binary if it has two operands. For example, the minus
4-2
Math operations
- Addition
+
- Subtraction
-
- Multiplication
*
- Division
/
- Remainder
%
- Exponentiation
**
String concatenation with binary +
1 | let s = "my" + "string"; // mystring |
Note that if any of the operands is a string, then the other one is converted to a string too
Numeric conversion, unary +
The unary plus applied to a single value, doesn't do anything to numbers. But if the operand is not a number, the unary plus converts it into a number
1 | let apples = "2"; |
Assignment
The assignment =
returns a value which is the assigned
value
Modify-in-place
For example:
1 | let n = 2; |
Increment / decrement
- Increment
++
increases a variable by 1 - Decrement
--
decreases a variable by 1
The operators can be placed either before or after a variable
- When the operator goes after the variable, it is in "postfix form"
- Otherwise it is in "prefix form"
- The prefix form returns the new value while the postfix form returns the old value
Comma
The comma operator allows us to evaluate several expressions,
dividing them with a comma ,
. Each of them is evaluated but
only the result of the last one is returned
2.9 Comparisons
JavaScript uses the so-called "dictionary" or "lexicographical" order to compare strings
Strict equality
A strict equality operator ===
checks the equality
without type conversion
Comparison with null and undefined
For a strict equality:
1
alert (null === undefined); // false
For a non-strict check:
1
alert (null == undefined); // true
Using strict equality to avoid strange bugs
2.10 Conditional branching
1 | let year = 2015; |
A shorter way to write short conditionals is to write it with question mark operator:
1 | let result = condition ? value1 : value2; |
2.11 Logical operators
There are four logical operators in JavaScript:
||
: or- OR finds the first truthy value
- Short-circuit evaluation
&&
: and- AND finds the first falsy value
- Precedence of AND is higher than OR
!
: not??
: nullish coalescingWe will say that an expression is "defined" when it's neither
null
norundefined
The result of
a ?? b
is:- If
a
is defined, thena
- If
a
is not defined, thenb
1
let area = (height ?? 100) * (width ?? 50);
- If
Due to safety reasons, JavaScript forbids using
??
together with&&
and||
operators, unless the precedence is explicitly specified with parentheses1
let x = (1 && 2) ?? 3; // 2
2.12 Loops
The "while" loop
1 | while (condition) { |
The "do ... while" loop
The loop will first execute the body, then check the condition
1 | do { |
The "for" loop
1 | for (begin; condition; step) { |
Labels for break/continue
1 | outer: |
2.13 The "switch" statement
1 | switch(x) { |
2.14 Functions
1 | function name(parameter1, parameter2, ... parameterN = defaultValue) { |
A function with an empty return
or without it returns
undefined
Alternative default parameters
1 | function showMessage(text1, text2) { |
2.15 Function expressions
In JavaScript, a function is not a "magical language structure", but a special kind of value. The syntax that we used before is called a function declaration. There is another syntax for creating a function that is called a function expression. For example:
1 | let sayHi = function () { |
Why is there a semicolon at the end? * There is no need for
;
at the end of code blocks and syntax structures that use
them like if {...}
, for {...}
,
function f {...}
etc. * A function expression is used
inside the statement: let sayHi = ...;
, as a value. It is
not a code block, but rather an assignment. The semicolon ;
is recommended at the end of statements, no matter what the value is
Here, the function is created and assigned to the variable
explicitly, like any other value. The meaning of this code sample means:
"create a function and put it into the variable sayHi
". You
can even print out that value using alert
which shows the
function code:
1 | alert( sayHi ); |
The last line does not run the function, because there are no
parentheses after sayHi
. In JavaScript, a function is a
value, wo we can deal with it as a value.
For example:
1 | function ask(question, yes, no) { |
A function is a value representing an "action". A function declaration can be called earlier than it is defined. A function expression is created when the execution reaches it and is usable only from that moment. In strict mode, when a function declaration is within a code block, it's visible everywhere inside that block. But not outside of it
2.16 Arrow function, the basics
There is another simple and concise syntax for creating functions: the arrow functions
1 | let func = (arg1, arg2, ..., argN) => expression |
For example:
1 | let sayHi = () => alert("Hello!"); |
- Without curly braces, the right side is an expression: the function evaluates it and returns the result
- With curly braces, the right side contains multiple statement inside
the function, the we need an explicit
return
to return something
Code quality
3.1 Debugging in the browser
Debugger command
We can pause the code by using the debugger
command in
the code like:
1 | function hello(name) { |
Tracing the execution
- Resume: continue the execution, hotkey
F8
- Step: run the next command, hotkey
F9
- Step over: run the next command, but don't go into a function, hokey
F10
- Step into:step into an asynchronous function calls, hotkey
F11
- Step out: continue the execution till the end of the current
function, hotkey
Shift+F11
3.2 Coding style
Suggested syntax rules
- A space between parameters in function
- No space between the function name and parentheses and between the parentheses and parameter
- Curly brace
{
on the same line, after a space - Indentation: 2 or 4 space
- Space around operators
- A space after
for
,if
,while
... - A semicolon is mandatory
- A space betweem arguments
- An empty line between logical blocks
- Lines should not be very long
} else {
without a line break- Spaces around a nested call
There are lots of style guide from different companies and organizations
3.3 Comments
Comments this
- Overall architecture, high-level view
- Function usage
- Important solutions, especially when not immediately obvious
Avoid comments:
- That tell "how code" works and "what it does"
- Put them in only if it's impossible to make the code so simple and self-descriptive that it doesn't require them
3.4 Ninja code
DO NOT:
- Make the code as short as possible
- Use one-letter variables
- Use abbreviations
- Be too abstract
- Use similar variable names
- Use many different synonyms for similar task
- Reuse names
- Use lots of underscores
- Use meaningless adjectives in variable names
- Overlap outer variables
- Have side-effects everywhere
- Provide too much functionalities to a single function
3.5 Automated testing with Mocha
When testing a code by manual re-runs, it's easy to miss something. Automated testing means that tests are written separately, in addition to the code. They run our functions in various ways and compare results with the expected
Behavior Driven Development (BDD)
BDD is three things in one: tests AND documentation AND examples. For
example, to write a function pow
.
Development of "pow": the spec
Before creating the code of pow
, we can imagine what the
function should do and describe it. Such description is called a
specification or, in short, a spec, and contains descriptions of use
cases together with tests for them, like this:
1 | describe("pow", function() { |
A spec has three main building blocks that you can see:
describe("title", function() { ... })
: What functionality we're describing. Used to group "workers" - theit
blocksit("use case description", function() { ... })
_ In the title ofit
we in a human-readable way describe the particular use case, and the second argument is a function that tests itassert.equal(value1, value2)
: The code insideit
block, if the implementation is correct, should execute without errors
The development flow
- An initial spec is written, with tests for the most basic functionality
- An initial implementation is created
- To check whether it works, we run the testing framework Mocha that runs the spec. While the functionality is not complete, errors are displayed. We make corrections until everything works
- Now we have a working initial implementation with tests
- We add more use cases to the spec, probably not yet supported by the implementations. Tests start to fail
- Go to 3, update the implementation till tests give no errors
- Repeat steps 3 - 6 till the functionality is ready
The spec in action
In this tutorial we will be using the following JS libraries for test:
Mocha
: the core framework. It provides common testing functions includingdescribe
andit
and the main function that runs testsChai
: the library with many assertions. It allows to use a lot of different assertions, for now we need onlyassert.equal
Sinon
: a library to spy over functions, emulate built-in functions are more, we'll need it much later
These libraries are suitable for both in-browser and server-side testing. Here we will consider the browser variant
The full HTML page with these frameworks and pow
spec:
1 |
|
The page can be divided into 5 parts:
- The
<head>
- add third--party libraries and styles for tests - The
<script>
with the function to test, in out case - with the code forpow
- The tests - in our case an external script
test.js
that hasdescribe("pow", ...)
from above - The HTML element
<div id="mocha">
will be used by Mocha to output results - The tests are started by the command
mocha.run()
Preprocess and postprocessing
We can setup before/after
functions that execute
before/after running tests, and also beforeEach/afterEach
functions that execute before/after every it
. For
instance:
1 | describe("test", function() { |
Other assertions
assert.equal(value1, value2)
: checks the equalityvalue1 == value2
assert.strictEqual(value1, value2)
: checks the strict equalityvalue1 === value2
assert.notEqual
,assert.notStrictEqual
: inverse checks to the ones aboveassert.isTrue(value)
: checks thatvalue === true
assert.isFalse(value)
: checks thatvalue === false
assert.isNaN
: checks if it isNaN
3.6 Polyfills and transpilers
How to make our modern code work on older engines that don't understand recent features yet? There are two tools for that:
- Transpilers
- Polyfills
Transpilers
A transpiler is a special piece of software that translates source code to another source code. It can parse modern code and rewrite it using older syntax constructs, so that it will also work in outdated engines. Usually, a developer runs the transpiler on their own computer, and then deploys the transpiled code to the server
Babel
is one of the most prominent transpiler out there.
Modern project build systems, such as webpack
, provide
means to run transpiler automatically on every code change, so it's very
easy to integrate into development process
Polyfills
New language features may include not only syntax constructs and operators , but also built-in functions. A script that updates / adds new functions is called "polyfill". It "fills in" the gap and adds missing implementations
JS is a highly dynamic language, scripts may add/modify any functions, even including built-in ones. Two interesting libraries of polyfills are:
core js
that supports a lot, allows to include only needed featurespolyfill.io
service that provides a script with polyfills, depending on the features and user's browser
Objects: the basics
4.1 Objects
An object can be created with figure brackets {...}
with
an optional list of properties. A property is a "key: value" pair, where
key
is a string (also called a "property name"), and
value
can be anything
An empty object can be created using one of two syntaxes:
1 | let user = new Object(); |
Literals and properties
For example:
1 | let user = { |
The dot requires the key to be a valid variable identifier. That
implies: contains no spaces, doesn't start with a digit and doesn't
include special characters ($
and _
are
allowed)
Property value shorthand
For instance:
1 | function makeUser(name, age) { |
Property names limitations
A variable cannot have a name equal to one of language-reserved words like "for", "let", "return" etc. But for an object property, there is no such restriction. They can be any strings or symbols. Other types are automatically converted to strings
1 | let obj = { |
Property existence test, "in" operator
For instance:
1 | let user { name: "John", age: 30, unde: undefined }; |
The "for...in" loop
1 | let user = { |
Ordered like an object
The properties is ordered in a special fashion: integer properties are sorted, others appear in creation order
If we want integer properties with creation order, we can "cheat" by making the codes non-integer. For instance:
1 | let codes = { |
4.2 Object references and copying
One of the fundamental differences of objects versus primitives is that objects are stored and copied "by reference", whereas primitive values: strings, numbers, booleans, etc. - are always copied "as a whole value"
Comparison by reference
Two objects are equal only if they are the same object. For instance:
1 | let a = {}; |
Cloning and merging,
Object.assign
We can copy an object in the following way:
1 | let user = { name: "John", age: 30 }; |
Also we can use the method Object.assign
for that. The
syntax is:
1 | Object.assign(dest, [src1, src2, src3 ...]) |
- The first argument
dest
is a target object - Further arguments
src1, ..., srcN
are source objects - It copies the properties of all source objects into the target
- If the copied property name already exists, it gets overwritten
- The call returns
dest
For instance:
1 | let user = { name: "John" }; |
We also can use Object.assign
for simple cloning:
1 | let clone = Object.assign({}, user); |
Nested cloning
What if properties are references to other objects? Then it is not
enough to just copy all properties. We then need to perform a "deep
cloning". We can use recursion to implement it. Or take an existing
implementation, for instance _.cloneDeep(obj)
from the JS
library lodash
Const objects can be modified
The value of the entire object is constant, it must always reference the same object, but properties of that object are free to change
4.3 Garbage collection
Reachability
The main concept of memory management in JS is reachability. Simply put, "reachable" values are those that are accessible or usable somehow. They are guaranteed to be stored in memory
- There's a base set of inherently reachable values, that cannot be
deleted for obvious reasons. These values are called roots. For
instance:
- The currently executing function, its local variables and parameters
- Other functions on the current chain of nested calls, their local variables and parameters
- Global variables
- (There are some other, internal ones as well)
- Any other value is considered reachable if it's reachable from a root by a reference or by a chain of references
There is a background process in the JS engine that is called garbage collector. It monitors all objects and removes those that have become unreachable
Internal algorithms
The basic garbage collection algorithm is called "mark-and-sweep". The following "garbage collection" steps are regularly performed:
- The garbage collector takes roots and "marks" (remembers) them
- Then it visits and "marks" all references from them
- Then it visits marked objects and marks their references. All visited objects are remembered, so as not to visit the same object twice in the future
- And so on until every reachable (from the roots) references are visited
- All objects except marked ones are removed
That's the concept of how garbage collection works. JS engines apply many optimizations to make it run faster and not affect the execution. Some of the optimizations:
- Generational collection: objects are split into two sets: "new ones" and "old ones". Many objects appear, do their job and die fast, they can be cleaned up aggressively. Those that survive for long enough, become "old" and are examined less often
- Incremental collection: If there are many objects, and we try to walk and mark the whole object set at once, it may take some time and introduce visible delays in the execution. So the engine tries to split the garbage collection into pieces. Then the pieces are executed one by one, separately. That requires some extra bookkeeping between them to track changes, but we have many tiny delays instead of a big one
- Idle-time collection: the garbage collector tries to run only while the CPU is idle, to reduce the possible effect on the execution
- There exist other optimizations
4.4 Object methods, "this"
Method examples
A function that is property of an object is called its method
1 | let user = { |
Method shorthand
These two notations are not fully identical. There are subtle differences related to object inheritance. In almost all cases the shorter syntax is preferred
1 | user = { |
"this" in methods
1 | let user = { |
We can do the following, but the code is unreliable:
1 | let user = { |
"this" is not bound
In JS, keyword this
behaves unlike most other
programming languages. It can be used in any function, even if it's not
a method of an object. The value of this
is evaluated
during the run-time, depending on the context. The rule is simple: if
obj.f()
is called, then this
is
obj
during the call of f
. For instance:
1 | let user = { name: "John" }; |
In strict mode, if we call this
without an object,
this
is undefined
. In non-strict mode, the
value of this
will be the global object(window
in a browser). This is a historical behavior
Arrow functions have no "this"
Arrow functions don't have their "own" this
. If we
reference this
from such a function, it's taken from the
outer "normal" function. For instance:
1 | let user = { |
4.5 Constructor, operator "new"
Constructor function
Constructor functions technically are regular functions. There are two conventions though:
- They are named with capital letter first
- They should be executed only with
"new"
operator
For instance:
1 | function User(name) { |
When a function is executed with new
, it does the
following steps:
- A new empty object is created and assigned to
this
- The function body executes. Usually it modifies
this
, adds new properties to it - The value of
this
is returned
In other words, new User(...)
does something like:
1 | function User(name) { |
We can omit parentheses after new
, if it has no
arguments. But it is not considered a "good style"
Constructor mode test:
new.target
Inside a function, we can check whether it was called with
new
or without it, using a special new.target
property. It is undefined for regular calls and equals the function if
called with new
:
1 | function User() { |
Return from constructors
If there is a return
statement, then the rule is
simple:
- If
return
is called with an object, then the object is returned instead ofthis
- If
return
is called with a primitive, it is ignored
4.6 Optional chaining '?.'
The optional chaining ?.
is a safe way to access nested
object properties, even if an intermediate property
The "non-existing property" problem
In many practical cases, we'd prefer to get undefined
instead of an error when we attempt to get an element of an
undefined
object. That's why the optional chaining
?.
was added to the language.
Optional chaining
The optional chaining ?.
stops the evaluation
(short-circuiting) if the value before ?.
is
undefined
or null
and returns
undefined
. For example:
1 | let user = {}; |
The ?.
syntax makes optional the value before it, but
not any further. We should use ?.
only where it's ok that
something doesn't exist. If there is no variable user
at
all, then user?.anything
triggers an error
Other variants: ?.()
,
?.[]
The optional chaining ?.
is not an operator, but a
special syntax construct, that also works with functions and square
brackets. For example, ?.()
is used to call a function that
may not exist.
The ?.()
checks the left part, if it exists, then it
runs. Otherwise the evaluation stops without errors.
The ?.[]
syntax also works, if we'd like to use brackets
[]
to access properties instead of dot .
.
Similar to previous case, it allows to safely read a property from an
object that may not exist.
Also we can use ?.
with delete
:
1 | delete user?.name; // delete user.name if user exists |
4.7 Symbol type
By specification, object property keys may be either of string type, or of symbol type. Not numbers, not booleans.
Symbols
A "symbol" represents a unique identifier. A value of this type can
be created using Symbol()
:
1 | let id = Symbol(); |
Upon creation we can give symbol a description (also called a symbol name), mostly useful for debugging purposes:
1 | let id = Symbol("id"); |
Symbols are guaranteed to be unique. Even if we create many symbols with the same description, they are different values. The description is just a label that doesn't affect anything.
To print symbols:
1 | let id = Symbol("id"); |
"Hidden" properties
Symbols allow us to create "hidden" properties of an object, that no
other part of code can accidentally access or overwrite. Symbolic
properties do not participate in for..in
loop but the
direct access works.
1 | let user = { |
Object.keys(user)
also ignores them. That's a part of
the general "hiding symbolic properties" principle.
If user
objects belong to another code, and that code
also works with them, we shouldn't just add any fields to it. That is
unsafe.
Symbols in an object literal
1 | let id = Symbol("id"); |
Global symbols
There is a global symbol registry. We can create symbols in it and
access them later, and it guarantees that repeated accesses by the same
name return exactly the same symbol. In order to read (create if absent)
a symbol from the registry, use Symbol.for(key)
. That call
checks the global registry, and if there's a symbol described as
key
, then returns it, otherwise creates a new symbol
Symbol(key)
and stores it in the registry by the given
key
. For instance:
1 | let id = Symbol.for("id"); |
Symbol.keyFor
For global symbols, not only Symbol.for(key)
returns a
symbol by name, but there's a reverse call:
Symbol.keyFor(sym)
, that does the reverse: returns a name
by a global symbol.
1 | let sym = Symbol.for("name"); |
The Symbol.keyFor
internally uses the global symbol
registry to look up the key for the symbol. So it doesn't work for
non-global symbols. It returns undefined
for non-global
symbols.
System symbols
There exist many "system" symbols that JS uses internally, and we can use them to fine-tune various aspects of our objects. For instance:
Symbol.hasInstance
Symbol.isConcatSpreadable
Symbol.iterator
Symbol.toPrimitive
- ...
4.8 Object to primitive conversion
JS doesn't exactly allow to customize how operators work on objects. We can't implement a special object method to handle, for example, an addition. In case of such operations, objects are auto-converted to primitives, and then the operation is carried out over these primitives and results in a primitive value.
Conversion rules
- All object are
true
in a boolean context. There are only numeric and string conversions - The numeric conversion happens when we subtract objects or apply matematical functions
- As for the string conversion - it usually happens when we output an
object like
alert(obj)
and in similar contexts
We can fine-tune string and numeric conversion, using special object methods. There are three variants of type conversion, that happen in various situations. They are called "hints", as described in the specification:
"string"
: For an object-to-string conversion, when we're doing an operation on an object that expects a string"number"
: For an object-to-number conversion, like when we're doing maths"default"
: Occurs in rare cases when the operator is "not sure" what type to expect. For instance, binary plus+
can work both with strings and numbers, so both strings and numbers would do. So if a binary plus gets an object as an argument, it uses the"default"
to convert it. The greater and less comparison operators can work with both strings and numbers too. Still, they use the"number"
hint, not"default"
. That's for historical reasons
In practice though, we don't need to remember these peculiar details,
because all built-in objects except for one case, Date
,
implement "default"
conversion the same way
"number"
. And we can do the same
To do the conversion, JS tries to find and call three object methods:
- Call
obj[Symbol.toPrimitive](hint)
- the method with the symbolic keySymbol.toPrimitive
(system symbol), if such method exists - Otherwise if hint is
"string"
: tryobj.toString()
andobj.valueOf()
, whatever exists - Otherwise if hint is
"number"
or"default"
: tryobj.valueOf()
andobj.toString()
, whatever exists
Symbol.toPrimitive
There's a built-in symbol named Symbol.toPrimitive
that
should be used to name the conversion method, like this:
1 | let user = { |
If the method Symbol.toPrimitive
exists, it's used for
all hints, and no more methods are needed
toString
/
valueOf
If there's no Symbol.toPrimitive
then JS tries to find
methods toString
to valueOf
- For the "string" hint:
toString
- For other hints:
valueOf
These two methods come from ancient times. They are not symbols, but rather "regular" string-named method. They provide an alternative "old-style" way to implement the conversion
By default, a plain object has following methods:
- The
toString
method returns a string"[object Object]"
- The
valueOf
method returns the object itself
A conversion can return any primitive type
The important thing to know about all primitive-conversion methods is
that they do not necessarily return the "hinted" primitive. There is no
control whether toString
returns exactly a string, or
whether Symbol.toPrimitive
method returns number for a hint
"number"
Further conversions
Many operators and functions perform type conversions, e.g.
multiplication *
converts operands to numbers. If we pass an
object as an argument, then there are two stages:
- The object is converted to a primitive (using the rules described above)
- If the resulting primitive isn't of the right type, it's converted
For instance:
1 | let obj = { |
Data types
5.1 Methods of primitives
JS allows us to work with primitives (strings, numbers, etc) as if they were objects. They also provide methods to call as such.
Let's look at the key distinctions between primitives and objects:
A primitive
- Is a value of a primitive value
- There are 7 primitive types:
string
,number
,bigint
,boolean
,symbol
,null
andundefined
An object
- Is capable of storing multiple values as properties
- Can be created with
{}
, for instance:{name: "John", age: 30}
. There are other kinds of objects in JS: functions, for example, are objects
A primitive as an object
Primitives are still primitive. A single value, as desired to make
them as fast and lightweight as possible. The language allow access to
methods and properties of strings, numbers, booleans and symbols. In
order for that to work, a special "object wrapper" that provides the
extra functionality is created, and then is destroyed. The "object
wrappers" are different for each primitive type and are called:
String
, Number
, Boolean
and
Symbol
. For instance, there exists a string method
str.toUpperCase()
that returns a capitalized
str
:
1 | let str = "hello"; |
Constructors String/Number/Boolean
are for internal use
only, It is possible, but highly unrecommended.
null
/undefined
have no methods.
5.2 Numbers
In modern JS, there are two types of numbers:
- Regular numbers in JS are stored in 64-bit format IEEE-754, also known as "double precision floating point numbers". These are numbers that we're using most of the time
- BitInt numbers, to represent integers of arbitrary length
More ways to write a number
1 | let billion = 1000000000; |
Hex, binary and octal numbers
Hexadecimal numbers are widely used in JS to represent colors, encode
chararcters, and for many other things. It is supported by the
0x
number. For instance:
1 | alert( 0xff ); // 255 |
Binary and octal numeral systems are rarely used, but also supported
using the 0b
and 0o
prefixes.
1 | let a = 0b11111111; // binary form of 255 |
toString(base)
For instance:
1 | let num = 255; |
Here the two dots mean calling a method.
Rounding
There are several built-in functions for rounding:
Math.floor
: rounds downMath.ceil
: rounds upMath.round
: rounds to the nearest integerMath.trunc
(not supported by internet explorer): removes anything after the decimal point without rounding
Imprecise calculations
A number is represented in 64-bit format IEEE-754 internally so
imprecision related to float number occurs. To fix this, you can use the
method toFixed
:
1 | let sum = 0.1 + 0.2; |
Tests: isFinite
and
isNaN
Infinity
(and-Infinity
) is a special numeric value that is greater (less) than anythingNaN
represents an errorisNaN(value)
converts its argument to a number and then tests it for beingNaN
1
2
3
4
5
6
7
8isNaN(NaN); // true
isNan("str"); // true
NaN === NaN; // false
Object.is(NaN, NaN); // true
Object.is(0, -0); // false
isFinite("15"); // true
isFinite("str"); // false
isFinite(Infinity); // false
parseInt
and
parseFloat
They "read" a number from a string until they can't. In case of an
error, the gathered number is returned. The parseInt()
function has an optional second parameter. It specifies the base of the
numeral system.
1 | parseInt("100px"); // 100 |
Other math functions
Math.random()
: returns a random number from 0 to 1 (not including 1)Math.max(a, b, c...)
/Math.min(a, b, c...)
: returns the greatest / smallest from the arbitrary number of argumentsMath.pow(n, power)
: Returnsn
raised to the given power
5.3 Strings
In JS, the textual data is stored as strings. There is no separate type for a single character. The internal format for strings is always UTF-16, it is not tied to the page encoding.
Quotes
String can be enclosed within either single quotes, double quotes or
backticks. Single and double quotes are essentially the same. Backticks
allow use to embed any expression into the string, by wrapping it in
${...}
1 | function sum (a, b) { |
Another advantage of using backticks is that they allow a string to span multiple lines
Special characters
Character | Description |
---|---|
\n |
New line |
\r |
Carriage return |
\' , \" |
Quotes |
\\ |
Backslash |
\t |
Tab |
\b , \f , \v |
Backspace, Form feed, Vertical tab - not used nowadays |
\xXX |
Unicode character with the given hexadecimal Unicode
XX |
\uXXXX |
A Unicode symbol with the hex code XXXX in UTF-16
encoding |
\u{X...XXXXXX} (1-6 hex characters) |
A Unicode symbol with the given UTF-32 encoding |
Accessing characters
To get a character at position pos
, use square bracket
[pos]
or call the method str.charAt(pos)
. The
first character starts from the zero position. The difference between
them is that if no character is found, []
returns
undefined
, and charAt
returns an empty
string.
We can also iterate over characters using for ... of
Strings are immutable
It is impossible to change a character. The usual workaround is to
create a whole new string and assign it to str
instead of
the old one.
Searching for substring
There are multiple ways to look for a substring within a string
str.indexOf(substr, pos)
It looks for the substr
in str
, starting
from the given position pos
, and returns the position where
the match was found or -1
if nothing can be found.
1 | let str = "Widget with id"; |
There is a similar method
str.lastIndexOf(substr, position)
that searches from the
end of a string to its beginning. It would list the occurrences in the
reverse order.
The bitwise NOT trick
The bitwise NOT ~
operator converts the number to a
32-bit integer (removes the decimal part if exist) and then reverses all
bits in its binary representation. In practice, that means for 32-bit
integers ~n
, becomes -(n+1)
. Thus we can use
this to test is a substring is found:
1 | let str = "Widget"; |
This only works when the length of the string is not 4294967295
includes, startsWith, endsWith
The more modern method str.includes(substr, pos)
return
true / false
depending on whether str
contains
substr
within. The methods str.startsWith
and
str.endsWith
do exactly what they say
Getting a substring
There are 3 methods in JS to get a substring: substring
,
substr
and slice
str.slice(start [, end])
- Returns the part of the string from
start
to (but not including)end
- If there is no second argument, then
slice
goes till the end of the string. - Negative values for
start/end
are also possible. They mean the position is counted from the string end
- Returns the part of the string from
str.substring(start [, end])
- Returns the part of the string between
start
andend
- This is almost the same as
slice
, but it allowsstart
to be greater thanend
- Negative arguments are not supported. They are treated as 0
- Returns the part of the string between
str.substr(start [, length])
- Returns the part of the string from
start
, with the givenlength
- The first argument may be negative, to count from the end
- Returns the part of the string from
Comparing strings
Strings are compared character-by-character in alphabetical order. There are some oddities:
- A lowercase letter is always greater than the uppercase
- Letters with diacritical marks are "out of order"
All strings are encoded using UTF-16. That is, each character has a corresponding numeric code. There are special methods that allow to get the character for the code and back:
str.codePointAt(pos)
String.fromCodePoint(code)
1 | "z".codePointAt(0); // 122 |
The browser needs to know the language to compare strings. All modern
browsers (IE10 - requires the additional library Intl.js
)
support the internationalization standard ECMA-402. It provides a
special method to compare strings in different languages, following
their rules. The call str.localeCompare(str2)
returns an
integer indicating whether str
is less, equal or greater
than str2
according to the language rules
Internals, Unicode
All frequently used characters have 2-byte codes. Letters in most
European languages, numbers, and even most hieroglyphs, have a 2-byte
representation. But 2 bytes only allow 65536 combinations and that's not
enough for every possible symbol. So rare symbols are encoded with a
pair of 2-byte characters called "a surrogate pair". The surrogate pairs
did not exist at the time when JS was created, and thus are not
correctly processed by the language. We can have a single symbol in a
string while the length
of the string being 2.
String.fromCodePoint
and str.codePointAt
are
few rare methods that deal with surrogate pairs right. They recently
appeared in the language
Diacritical marks and normalization
In many languages there are symbols that are composed of the base character with a mark above / under it. Most common "composite" character have their own code in the UTF-16 table. But not all of them, because there are too many possible combinations. To support arbitrary compositions, UTF-16 allows us to use several Unicode characters: the base character followed by one or many "mark" characters that "decorate" it.
Since there are many ways to composite a character. There can be situations where two characters may visually look the same, but be represented with different Unicode compositions. To solve this, there exists a "Unicode normalization" algorithm that brings each string to the single "normal" form:
1 | let s1 = "S\u0307\u0323"; // Ṩ, S + dot above + dot below |
5.4 Arrays
Array is a data structure to store ordered collections
Declaration
1 | let arr = new Array(); |
Array elements are numbered, starting with zero. We can replace an element or add a new one to the array:
1 | fruits[2] = "Pear"; // now ["Apple", "Orange", "Pear"] |
An array can store elements of any type:
1 | let arr = ["Apple", { name: "John" }, true, function() { alert("Hello"); } ]; |
An array, just like an object, may end with a comma:
1 | let fruits = ["Apple", "Orange", "Plum", ]; |
Methods pop / push, shift / unshift
A queue is one of the most common uses of an array. In computer science, this means an ordered collection of elements which supports two operations.
push
appends an element to the endshift
get an element from the beginning, advancing the queue, so that the 2nd element becomes the 1st
There is another use case for arrays - the data structure named stack. It supports two operations:
push
adds an element to the endpop
takes an element from the end
Arrays in JS can work both as a queue and as a stack. They allow you to add / remove elements both to / from the beginning or the end. In computer science the data structure that allows this, is called deque.
Internals
An arrays is a special kind of object. The square brackets used to
access a property arr[0]
actually come from the object
syntax. They extend objects providing special methods to work with
ordered collections of data and also the length
property.
But at the core it's still an object. For instance, it is copied by
reference.
What makes arrays really special is their internal representation. The engine tries to store its elements in the contiguous memory area, one after another. There are other optimizations as well, to make arrays work really fast. But they all break if we quit working with an array as with an "ordered collection" and start working with it as if it were a regular object. The ways to misuse an array:
- Add a non-numeric property like
arr.test = 5
- Make holes, like: add
arr[0]
and thenarr[1000]
- Fill the array in the reverse order, like
arr[1000]
,arr[999]
and so on
Performance
Methods push / pop
run fast, while
shift / unshift
are slow. The more elements in the array,
the more time to move them, more in-memory operations. The
pop
method does not need to move anything, because other
elements keep their indexes. That's why it's blazingly fast
Loops
1 | let arr = ["Apple", "Orange", "Pear"]; |
It is possible to use for ... in
to loop through array.
But that is a bad idea. There are potential problems with it:
- The loop
for...in
iterates over all properties, not only the numeric ones. - The
for...in
loop is optimized for generic objects, not arrays, and thus is 10-100 times slower.
Length
The length property automatically updates when we modify the array.
To be precise, it is actually not the count of values in the array, but
the greatest numeric index plus one. The length
property is
writable. If we increase it manually, nothing interesting happens. But
if we decrease it, the array is truncated. The process is
irreversible
new Array()
There is one more syntax to create an array:
1 | let arr = new Array ("Apple", "Pear", "etc"); |
If new Array
is called with a single argument which is a
number, then it creates an array without items, but with the given
length.
toString
Arrays have their own implementation of toString
method
that returns a comma-separated list of elements. Arrays do not have
Symbol.toPrimitive
, neither a viable valueOf
they implement only toString
conversion
Don't compare arrays with ==
This operator has no special treatment for arrays, it works with them
as with any objects. Thus two arrays are equal ==
only if
they're references to the same object. So don't use the ==
operator. Instead, compare them item-by-item in a loop or using
iteration methods explained in the next chapter.
5.5 Array methods
Arrays provide a lot of methods.
Add / remove items
arr.push(...items)
: adds items to the endarr.pop()
: extracts an item from the endarr.shift()
: extracts an item from the beginningarr.unshift(...items)
: adds items to the beginning
Splice
The arr.splice
method is a swiss army knife for arrays.
It can do everything: insert, remove and replace elements. The syntax
is:
1 | arr.splice(start[, deleteCount, elem1, ..., elemN]) |
It modifies arr
starting from the index
start
: removes deleteCount
elements and then
inserts elem1, ..., elemN
at their place. Returns the array
of removed elements.
The splice
method is also able to insert the elements
without any removals. For that we need to set deleteCount
to 0
Here and in other array methods, negative indexes are allowed. They specify the position from the end of the array
Slice
The method arr.slice
is much simpler than
similar-looking arr.splice
. The syntax is:
1 | arr.slice([start], [end]) |
It returns a new array copying to it all items from index
start
to end
(not including end
).
Both start
and end
can be negative, in that
case position from array end is assumed
It is similar to a string method str.slice
, but instead
of substrings, it makes subarrays
1 | let arr = ["t", "e", "s", "t"]; |
We can also call it without arguments: arr.slice()
creates a copy of arr
. That's often used to obtain a copy
for further transformations that should not affect the original
array
Concat
The method arr.concat
creates a new array that includes
values from other arrays and additional items. The syntax is:
1 | arr.concat(arg1, arg2...) |
It accepts any number of arguments - either arrays or values. The
result is a new array containing items from arr
, then
arg1
, arg2
etc. If an argument
argN
is an array, then all its elements are copied.
Otherwise the argument itself is copied.
Normally, it only copies elements from arrays. Other objects, even if they look like arrays, are added as whole:
1 | let arr = [1, 2]; |
But if an array-like object has a special
Symbol.isConcatSpreadable
property, then it's treated as an
array by concat
: its element are added instead:
1 | let arr = [1, 2]; |
Iterate: forEach
The arr.forEach
method allows to run a function for
every element of the array. The syntax:
1 | arr.forEach(function(item, index, array) { |
For instance, to show each element of an array:
1 | ["Bilbo", "Gandalf", "Nazgul"].forEach(alert); |
Or:
1 | ["Bilbo", "Gandalf", "Nazul"].forEach((item, index, array) => { |
Searching in array
indexOf / lastIndexOf and includes
The methods arr.indexOf
, arr.lastIndexOf
and arr.includes
have the same syntax and do essentially
the same as their string counterparts, but operate on items instead of
characters
arr.indexOf(item, from)
: looks foritem
starting from indexfrom
, and returns the index where it was found, otherwise -1arr.lastIndexOf(item, from)
: same, but looks for it from right to leftarr.includes(item, from)
: looks foritem
starting from indexfrom
, returnstrue
if found
A minor difference of includes
is that it correctly
handles NaN
, unlike indexOf / lastIndexOf
:
1 | const arr = [NaN]; |
find and findIndex
The find method finds an object in an array with a specific condition. The syntax is:
1 | let result = arr.find(function(item, index, array) { |
The function is called for elements of the array, one after another.
item
is the elementindex
is its indexarray
is the array itself
For example:
1 | let users = [ |
The arr.findIndex
method is essentially the same, but it
returns the index where the element was found instead of the element
itself and -1
is returned when nothing is found
filter
The find
method looks for a single element. if there may
be many, we can use arr.filter(fn)
. The syntax is similar
to find
, but filter
returns an array of all
matching elements
1 | let results = arr.filter(function(item, index, array) { |
map
The arr.map
method calls the function for each element
of the array and returns the array of results. The syntax is:
1 | let result = arr.map(function(item, index, array) { |
For example:
1 | let lengths = ["Bilbo", "Gandalf", "Nazgul"].map(item => item.length); // 5,7,6 |
sort(fn)
The call to arr.sort()
sorts the array in place,
changing its element order. It also returns the sorted array, but the
returned value is usually ignored.
For instance:
1 | let arr = [1, 2, 15]; |
The items are sorted as strings by default. To use our own sorting
order, we need to supply a function as the argument of
arr.sort()
:
1 | function compareNumeric(a, b) { |
A shorter way to write it:
1 | arr.sort( (a, b) => a - b ); |
Use localeCompare
for strings
For many alphabets, it's better to use str.localeCompare
method to correctly sort letters, such as Ö
. For example,
let's sort in German:
1 | let countries = ["Österreich", "Andorra", "Vietnam"]; |
reverse
The method arr.reverse
reverses the order of elements in
arr
split and join
The str.split(delim)
method splits the string into an
array by the given delimiter delim
:
1 | let names = "Bilbo, Gandalf, Nazgul"; |
The split
method has an optional second numeric argument
- a limit on the array length. If it is provided, then the extra
elements are ignored.
The call arr.join(glue)
creates a string of
arr
items joined by glue
between them
reduce / reduceRight
The methods arr.reduce
and arr.reduceRight
are used to calculate a single value based on the array. The syntax
is:
1 | let value = arr.reduce(function(accumulator, item, index, array) { |
The function is applied to all array elements one after another and "carries on" its result to the next call.
Arguments:
accumulator
: is the result of the previous function call, equalsinitial
the first time (ifinitial
is provided). If there is no initial, thenreduce
takes the first element of the array as the initial value and starts the iteration from the 2nd elementitem
: is the current array itemindex
: is its positionarray
: is the array
As function is applied, the result of the previous function call is
passed to the next one as the first argument. So, the first argument is
essentially the accumulator that stores the combined result of all
previous executions. And at the end it becomes the result of
reduce
. For instance:
1 | let arr = [1, 2, 3, 4, 5]; |
With initial
requires extra care. If the array is empty,
then reduce
call without initial value gives an error. So
it's advised to always specify the initial value.
The arr.reduceRight
method does the same, but goes from
right to left
Array.isArray
Arrays do not form a separate language type. They are based on
objects. So typeof
does not help to distinguish a plain
object from an array:
1 | alert(typeof {}); // object |
But there is a special method, Array.isArray(value)
. It
returns true
if the value
is an array, and
false
otherwise
Most methods support "thisArg"
Almost all array methods that call functions - like
find
, filter
, map
, with a notable
exception of sort
, accept an optional additional parameter
thisArg
. For example:
1 | arr.find(func, thisArg); |
The value of thisArg
parameter becomes this
for func
1 | let army = { |
The filter function can be replaced with
1 | users.filter(user => army.canJoin(user)); |
There are many other array methods. For example,
arr.some(fn)
, arr.every(fn)
,
arr.fill(value, start, end)
,
arr.copyWithin(target, start, end)
,
arr.flat(depth)
, arr.flatMap(fn)
. Check manual
for more information.
5.6 Iterables
Iterable objects are a generalization of arrays. That's a concept
that allows us to make any object useable in a for..of
loop. If an object isn't technically an array, but represents a
collection (list, set) of something, then for..of
is a
great syntax to loop over it
Symbol.iterator
We can making an iterator like this range
object that
represents an interval of numbers:
1 | let range = { |
To make the range object iterable (and thus let for..of
work), we need to add a method to the object named
Symbol.iterator
- When
for..of
starts, it calls that method once (or errors if not found). The method must return an iterator - an object with the methodnext
- Onward,
for..of
works only with that returned object - When
for..of
wants the next value, it callsnext()
on that object - The result of
next()
must have the form{done: Boolean, value: any}
, wheredone = true
means that the iteration is finished, otherwisevalue
is the next value
Here's the full implementation for range:
1 | let range = { |
Note the core feature of iterables: separation of concerns
The
range
itself does not have thenext()
methodInstead, another object, a so-called "iterator" is created by the call to
range[Symbol.iterator]()
, and itsnext()
generates values for the iterationSo the iterator object is separate from the object it iterates over
We can put the next()
function inside of
range
and let Symbol.iterator()
return
this
. It works too. The downside is that now it's
impossible to have two for..of
loops running over the
object simultaneously: they'll share the iteration state.
String is iterable
Arrays and strings are most widely used built-in iterables.
Calling an iterator explicitly
For deeper understanding, let's see how to use an iterator
explicitly. We'll iterate over a string in exactly the same way as
for..of
, but with direct calls:
1 | let str = "Hello"; |
Iterable and array-likes
- Iterables are objects that implement the
Symbol.iterator
method, as described above - Array-likes are object that have indexes and
length
, so they look like arrays
Array.from
There is a universal method Array.from
that takes an
iterable or array-like value and makes a "real" Array
from
it. For instance:
1 | let arrayLike = { |
The full syntax for Array.from
is:
1 | Array.from(obj[, mapFn, thisArg]) |
The optional second argument mapFn
can be a function
that will be applied to each element before adding it to the array, and
thisArg
allows us to set this
for it
5.7 Map and set
Map
Map is a collection of keyed data items, just like an
Object
. But the main difference is that Map allows keys of
any type
Methods and properties are:
new Map()
: creates the mapmap.set(key, value)
: stores the value by the key- Every
map.set
call returns the map itself, so we can "chain" the calls
- Every
map.get(key)
: returns the value by the key,undefined
ifkey
doesn't exist in mapmap.has(key)
: returns true if the key exists, false otherwisemap.delete(key)
: removes the value by the keymap.clear()
: removes everything from the mapmap.size
: returns the current element count
For instance:
1 | let map = new Map(); |
To test keys for equivalence, Map
uses the algorithm
SameValueZero
. It is roughly the same as strict equality
===
, but the difference is that NaN
is
considered equal to NaN
Map can also use objects as keys
For instance:
1 | let john = { name: "John" }; |
Object
cannot use objects as keys
Iteration over Map
For looping over a Map
, there are 3 methods:
map.keys()
: returns an iterable for keysmap.values()
: returns an iterable for valuesmap.entries()
: returns an iterable for entries[key, value]
, it's used by default infor..of
The iteration goes in the same order as the values were inserted.
Map
preserves this order, unlike a regular
Object
. Besides that, Map
has a built-in
forEach
method, similar to Array
Object.entries: Map from Object
When a Map
is created, we can pass an array (or another
iterable) with key/value pairs for initialization:
1 | let map = new Map([ |
If we have a plain object, and we'd like to create a Map
from it, then we can use built-in method
Object.entries(obj)
that returns an array of key/value
pairs for an object exactly in that format:
1 | let obj = { |
Object.fromEntries: Object from Map
The Object.fromEntries
method does the reverse of
Object.entries
. Given an array of [key, value]
pairs, it creates an object from them. We can use
Object.fromEntries
to get a plain object from
Map
:
1 | let map = new Map(); |
Set
A Set
is a special type collection - "set of values"
(without keys), where each value may occur only once. Its main methods
are:
new Set(iterable)
: creates the set, and if aniterable
object is provided, copies values from it into the setset.add(value)
: adds a value, returns the set itselfset.delete(value)
: removes the value, returns true if value existed at the moment of the call, otherwise falseset.has(value)
: returns true if the value exists in the set, otherwise falseset.clear()
: removes everything from the setset.size
: is the elements count
Iteration over Set
We can loop over a set either with for..of
or using
forEach
1 | let set = new Set(["oranges", "apples", "bananas"]); |
The forEach
has this form for compatibility with
Map
where the callback passed forEach
has
three arguments. Thus the same methods Map
has for
iterators are also supported by Set
where
set.keys()
and set.values()
are the same.
5.8 WeakMap and WeakSet
JS engine keeps a value in memory while it is "reachable" and can
potentially be used. Usually, properties of an object or elements of an
array or another data structure are considered reachable and kept in
memory while that data structure is in memory. Similar to that, if we
use an object as the key in a regular Map
, then while the
Map
exists, that object exists as well. It occupies memory
and may not be garbage collected.
WeakMap
is fundamentally different in this aspect. It
doesn't prevent garbage-collection of key objects.
WeakMap
The first difference between Map
and
WeakMap
is that keys must be objects, not primitive values.
If we use an object as the key in it, and there are no other references
to that object - it will be removed from memory (and from the map)
automatically. If an object only exists as the key of
WeakMap
- it will be automatically deleted from the map
(and memory).
WeakMap
does not support iteration and methods
keys()
, values()
, entries()
, so
there's no way to get all keys or values from it. WeakMap
has only the following methods:
weakMap.get(key)
weakMap.set(key, value)
weakMap.delete(key)
weakMap.has(key)
Use case: additional data
The main area of application for WeakMap
is an
additional data storage. If we are working with an object that "belongs"
to another code, maybe even a third-party library, and would like to
store some data associated with it, that should only exist while the
object is alive - then WeakMap
is exactly what's needed
Use case: caching
Another common example is caching. We can store ("cache") results
from a function, so that future calls on the same object can reuse it.
With WeakMap
, the cached result will be removed from memory
automatically after the object gets garbage collected.
WeakSet
WeakSet
behaves similarly:
- It is analogous to
Set
, but we may only add objects toWeakSet
(not primitives) - An object exists in the set while it is reachable from somewhere else
- Like
Set
, it supportsadd
,has
anddelete
, but notsize
,keys()
and no iterations
Being "weak", it also serves as additional storage. But not for
arbitrary data, rather for "yes/no" facts. A membership in
WeakSet
may mean something about the object.
The most notable limitation of WeakMap
and
WeakSet
is the absence of iterations, and the inability to
get all current content. That may appear inconvenient, but does not
prevent WeakMap / WeakSet
from doing their main job - be an
"additional" storage of data for objects which are stored / managed at
another place
5.9 Object.keys, values, entries
For plain objects, the following methods are available
Object.keys(obj)
: returns an array of keysObject.values(obj)
: returns an array of valuesObject.entries(obj)
: returns an array of[key, value]
pairs- They ignore symbolic properties
Transforming objects
Objects lack many methods that exist for arrays, e.g.
map
, filter
and others. If we'd like to apply,
then we can Object.entries
followed by
Object.fromEntries
:
1 | let prices = { |
5.10 Destructuring assignment
Destructuring assignment is a special syntax that allows us to "unpack" arrays or objects into a bunch of variables, as sometimes that is more convenient. Destructuring also works great with complex functions that have a lot of parameters, default values, and so on.
Array destructuring
For example:
1 | let arr = ["John", "Smith"] |
Unwanted elements of the array can also be thrown away via an extra comma. This syntax works with any iterable on the right-side and can use any "assignables" at the left side
The rest "..."
Usually, if the array is longer than the list at the left, the "extra" items are omitted. If we'd like to gather all that follows, we can add one more parameter that gets "the rest":
1 | let [name1, name2, ...rest] = ["Julius", "Caesar", "Consul", "of the Roman Republic"] |
Default values
If the array is shorter than the list of variables at the left,
there'll be no errors. Absent values are considered undefined. If we
want a "default" value, we can provide it using =
:
1 | let [name = "Guest", surname = "Anonymous"] = ["Julius"]; |
Object destructuring
The destructuring assignment also works with objects. The basic syntax is:
1 | let {var1, var2} = {var1:..., var2:...} |
We should have an existing object at the right side, that we want to
split into variables. The left side contains an object-like "pattern"
for corresponding properties. In the simplest case, that's a list of
variable names in {...}
. For instance:
1 | let options = { |
The order does not matter.
If we want to assign a property to a variable with another name, we can set the variable name using a colon:
1 | let {width: w, height: h, title} = options; |
For potentially missing properties, we can set default values using
=
like with arrays.
Or another way of writing it:
1 | let title, width, height; |
Nested destructuring
1 | let options = { |
5.11 Date and time
Date
stores the date, time and provides methods for
date/time management
Creation
1 | let now = new Date(); // current date/time |
The full syntax:
1 | new Date(year, month, date, hours, minutes, seconds, ms) |
Access date components
getFullYear()
getMonth()
getDate()
getHours(), getMinutes(), getSeconds(), getMilliseconds()
getDay()
getUTCFullYear(), getUTFMonth(), getUTCDay()
Setting date components
setFullYear(year, [month], [date])
setMonth(month, [date])
setDate(date)
setHours(hour, [min], [sec], [ms])
setMilliseconds(ms)
setTime(milliseconds)
Every one of them except setTime()
has a UTC-variant
Autocorrection
We can set out-of-range values, and it will auto-adjust itself.
1 | let date = new Date(2013, 0, 32); // 2013-02-01 |
Date to number, date diff
When a Date
object is converted to number, it becomes
the timestamp same as date.getTime()
1 | let start = new Date(); |
Date.parse
from a
string
The method Date.parse(str)
can read a date from a
string. The string format should be:
YYYY-MM-DDTHH:mm:ss.sssZ
, where
YYYY-MM-DD
is the date: year-month-day- The character
T
is used as the delimiter HH:mm:ss.sss
is the time: hours, minutes, seconds and milliseconds- The optional
Z
part denotes the time zone in the format+-hh:mm
. A single letterZ
would mean UTC+0
1 | let ms = Date.parse('2012-01-26T13:51:50.417-07:00'); |
5.12 JSON methods, toJSON
JSON.stringify
The JSON (JavaScript Object Notation) is a general format to represent values and objects. It is easy to use JSON for data exchange when the client uses JS and the server is written on Ruby/PHP/Java/Whatever. JS provides methods:
JSON.stringify
to convert objects into JSONJSON.parse
to convert JSON back into an object
For instance:
1 | let student = { |
The method JSON.stringify(student)
takes the object and
converts it into a string. The resulting json
string is
called a JSON-encoded or serialized or stringified or marshalled object.
We can now send it over the wire or put it into a plain data store. Note
that a JSON-encoded object has several important differences from the
object literal:
- Strings use double quotes. No single quotes or backticks in JSON
- Object property names are double quoted also. That's obligatory
JSON.stringify
can be applied to primitives as well.
JSON supports following data types:
- Objects
- Arrays
- Primitives
- Strings
- Numbers
- Boolean values
- Null
JSON is data-only language-independent specification, so some
JS-specific object properties are skipped by
JSON.stringify
. Namely:
- Function properties (Methods)
- Symbolic keys and values
- Properties that store
undefined
1 | let user = { |
The nested objects are supported and converted automatically. But there must be no circular references.
Excluding and transforming: replacer
The full syntax of JSON.stringify
is:
1 | let json = JSON.stringify(value[, replacer, space]) |
- value: a value to encode
- replacer: array of properties to encode or mapping function
function(key, value)
. If we pass an array of properties to it, only these properties will be encoded. If a function is used, then the function will be called for every(key, value)
pair and should return the "replaced" value, which will be used instead of the original one - space: amount of space to use for formatting
Formatting: space
The third argument of
JSON.stringify(value, replacer, space)
is the number of
spaces to use for pretty formatting. For example, space = 2
tells JS to show nested objects on multiple lines, with indentation of 2
spaces inside an object
Custom "toJSON"
Like toString
for string conversion, an object may
provide method toJSON
for to-JSON conversion.
JSON.stringify
automatically calls it if available. For
instance:
1 | let room = { |
JSON.parse
The deode a JSON-string, we need JSON.parse
. The syntax:
1 | let value = JSON.parse(str, [reviver]); |
Here, * str
: JSON-string to parse *
reviver
: optional function(key, value)
that
will be called for each (key, value)
pair and can transform
the value
JSON does not support comments. Adding a comment to JSON makes it invalid.
Using reviver
For example:
1 | let str = '{"title":"Conference","date":"2017-11-30T12:00:00.000Z"}'; |
This works for nested objects as well.
Advanced working with functions
6.1 Recursion and stack
The information about the process of execution of a running function is stored in its execution context. The execution context is an internal data structure that contains details about the execution of a function. One function call has exactly one execution context associated with it.
When a function makes a nested call, the following happens:
- The current function is paused
- The execution context associated with it is remembered in a special data structure called execution context stack
- The nested call executes
- After it ends, the old execution context is retrieved from the stack, and the outer function is resumed from where it stopped
6.2 Rest parameters and spread syntax
Rest parameters ...
A function can be called with any number of arguments, no matter how it is defined. For example:
1 | function showName(firstName, lastName, ...titles) { |
The rest parameters must be at the end
The "arguments" variable
There is also a special array-like object named
arguments
that contains all arguments by their indexes. For
instance:
1 | function showName() { |
Although arguments
is both array-like and iterable, it
is not an array. It does not support array methods. Also, it always
contains all arguments. We can't capture them partially, like we did
with rest parameters
Spread syntax
Spread syntax looks similar to rest parameters, also using
...
, but does the opposite. For example:
1 | let arr = [3, 5, 1] |
Also, the spread syntax can be used to merge arrays:
1 | let merged = [0, ...arr, 2, ...arr2] |
And we can use the spread syntax to turn the string into array of characters:
1 | let str = "Hello"; |
The spread syntax internally uses iterators to gather elements, the
same way as for..of
does
Copy an array/object
It is possible to copy arrays and objects with spread syntax:
1 | let arr = [1, 2, 3]; |
6.3 Variable scope, closure
Code blocks
If a variable is declared inside a code block {...}
, it
is only visible inside that block
Nested functions
A nested function can be returned: either as a property of a new object or as a result by itself. It can then be used somewhere else. No matter where, it still has access to the same outer variables. For example:
1 | function makeCounter() { |
Lexical environment
Variables
In JS, every running function, code block {...}
, and the
script as a whole have an internal (hidden) associated object know as
the Lexical Environment. The Lexical Environment object consists of two
parts:
- Environment Record: an object that stores all local variables as its
properties (and some other information like the value of
this
) - A reference to the outer lexical environment, the one associated with the outer code
A "variable" is just a property of the special internal object,
Environment Record
. "To get or change a variable" means "to
get or change a property of that object"
In a simple code without functions, there is only one Lexical Environment, a.k.a. global Lexical Environment, that associated with the whole script
Function declarations
A function is also a value, like a variable. The difference is that a
Function Declaration is instantly fully initialized. When a Lexical
Environment is created, a Function Declaration immediately becomes a
ready-to-use function (unlike let
, that is unusable till
the declaration). What's why we can use a function, declared as Function
Declaration, even before the declaration itself.
Naturally, this behavior only applies to Function Declarations, not
Function Expressions where we assign a function to a variable, such as
let say = function()...
Inner and outer Lexical Environment
When a function runs, at the beginning of the call, a new Lexical Environment is created automatically to store local variables and parameters of the call. During the function call we have two Lexical Environments: the inner one (for the function call) and the outer one (global)
- The inner Lexical Environment corresponds to the current execution of the function. It has the function arguments as its properties
- The outer Lexical Environment is the global Lexical Environment. It has the global variables and the function itself
The inner Lexical Environment has a reference to the outer one. When
the code wants to access a variable - the inner Lexical Environment is
searched first, then the outer one, then the more outer one and so on
until the global one. If a variable is not found anywhere, that's an
error in strict mode (without use strict
, an assignment to
a non-existing variable creates a new global variable, for compatibility
with old code)
Returning a function
All functions remember the Lexical Environment in which they were
made. Technically, there's no magic here: all functions have the hidden
property named [[Environment]]
, that keeps the reference to
the Lexical Environment where the function was created
So, a function func.[[Environment]]
has the reference to
variables in its outer Lexical Environment. That's how the function
remembers where it was created, no matter where it's called. The
[[Environment]]
reference is set once and forever at
function creation time.
A variable is updated in the Lexical Environment where it lives
Closure
There is a general programming term "closure". A closure is a function that remembers its outer variables and can access them. In some languages, that's not possible, or a function should be written in a special way to make it happen. But in JS, all functions are naturally closures (with only one exception to be covered later)
That is: they automatically remember where they were created using a
hidden [[Environment]]
property, and then their code can
access outer variables.
Garbage collection
Usually, a Lexical Environment is removed from memory with all the
variables after the function call finishes. That's because there are no
references to it. However, if there is a nested function that is still
reachable after the end of a function, then it has
[[Environment]]
property that references the lexical
environment. In that case the Lexical Environment is still reachable
even after the completion of the function, so it stays alive
Real-life optimizations
In practice, JS engines try to analyze variable usage and if it's obvious from the code that an outer variable is not used - it is removed. An important side effect in V8 is that such variable will be come unavailable in debugging
That may lead to funny debugging issues.
Does a function pickup latest changes?
A function gets outer variables as they are now, it uses the most recent values
6.4 The old "var"
The var
declaration is similar to let
. Most
of the time we can replace let
by var
or
vice-versa and expect things to work. But internally var
is
very different.
"var" has no block scope
Variables, declared with var
, are either function-scoped
or global-scoped. They are visible through blocks. For instance:
1 | if (true) { |
If a code block is inside a function, then var
becomes a
function-level variable:
1 | function sayHi() { |
"var" tolerates redeclarations
If we declare the same variable with let
twice in the
same scope, that's an error. With var
, we can redeclare a
variable any number of times.
"var" variables can be declared below their use
var
declarations are processed when the function starts
(or script starts for globals). Even in the following situation:
1 | function sayHi() { |
People also call such behavior "hoisting" (raising), because all
var
are "hoisted" (raised) to the top of the function.
Declarations are hoisted, but assignments are not.
1 | function sayHi() { |
IIFE
In the past, as there was only var
, and it has no
block-level visibility, programmers invented a way to emulate it. What
they did was called "immediately-invoked function expressions" (IIFE).
That's not something we should use nowadays, but you can find them in
old scripts:
1 | (function() { |
The parentheses around the function is a trick to show JS that the function is created in the context of another expression, and hence it's a Function Expression. It needs no name and can be called immediately
There exist other ways besides parentheses to tell JS that we mean a Function Expression:
1 | (function() { |
Nowadays there is no reason to write such code
6.5 Global object
The global object provides variables and functions that are available
anywhere. By default, those that are built into the language or the
environment. In a browser it is named window
, for Node.js
it is global
, for other environments it may have another
name.
Recently, globalThis
was added to the language, as a
standardized name for a global object, that should be supported across
all environment. It's supported in all major browsers.
All properties of the global object can be accessed directly:
1 | alert("Hello"); |
Using for polyfills
We use the global object to test for support of modern language
features. For instance, test if a built-in Promise
object
exists:
1 | if (!window.Promise) { |
6.6 Function object, NFE
A good way to imagine functions in JS is as callable "action objects". We can not only call them, but also treat them as objects: add / remove properties, pass by reference etc.
The "name" property
Function objects contain some usable properties. For instance, a function's name is accessible as the "name" property:
1 | function sayHi() { |
Also works if the assignment is done via a default value:
1 | function f(sayHi = function() {}) { |
In the specification, this feature is called a "contextual name". If the function does not provide one, then in an assignment it is figured out from the context
The "length" property
The built-in property "length" returns the number of function parameters, for instance:
1 | function f1(a) {} |
Custom properties
We can also add properties of our own. Here we add the
counter
property to track the total calls count:
1 | function sayHi() { |
A property assigned to a function like sayHi.counter = 0
does not define a local variable counter
inside it. In
other words, a property counter
and a variable
let counter
are two unrelated things
We can treat a function as an object, store properties in it, but that has no effect on its execution. Variables are not function properties and vice versa. These are just parallel worlds
Function properties can replace closure sometimes. For instance, we can rewrite the counter function example previously to use a function property:
1 | function makeCounter() { |
The count
is now stored in the function directly, not in
its outer Lexical Environment. The main difference is that if the value
of count
lives in an outer variable, then external code is
unable to access it. Only nested functions may modify it. And if it's
bound to a function, then such a thing is possible.
Named Function Expression
Named Function Expression, or NFE, is a term for Function Expressions that have a name.
For instance, let's take an ordinary Function Expression:
1 | let sayHi = function(who) { |
And add a name to it:
1 | let sayHi = function func(who) { |
This is still a Function Expression. Adding the name
func
after function
did not make it a Function
Declaration, because it is still created as a part of an assignment
expression. Adding such a name also did not break anything. The function
is still available as sayHi()
There are two special things about the name func
, that
are the reasons for it:
- It allows the function to reference itself internally
- It is not visible outside of the function
Why do we use func
? Maybe just use sayHi
for the nested call? Actually, in most cases we can. The problem with
that code is that sayHi
may change in the outer code. If
the function gets assigned to another variable instead, the code will
start to give errors. Because the name func
is
function-local. It is not taken from outside (and not visible there).
The specification guarantees that it will always reference the current
function.
There is no such thing for Function Declaration.
6.7 The "new Function" syntax
There is one more way to create a function. It is rarely used, but sometimes there's no alternative.
Syntax
The syntax for creating a function
1 | let func = new Function ([arg1, arg2, ...argN], functionBody); |
The function is created with the arguments arg1...argN
and the given functionBody
. For example:
1 | let sum = new Function('a', 'b', 'return a + b'); |
For a function without arguments:
1 | let sayHi = new Function('alert("Hello")'); |
The major difference from other ways we've seen is that the function is created literally from a string, that is passed at run time. It is used in very specific cases, like when we receive code from a server, or to dynamically compile a function from a template, in complex web-applications
Closure
When a function is created using new Function
, its
[[Environment]]
is set to reference not the current Lexical
Environment, but the global one. So, such function doesn't have access
to outer variables, only to the global ones. If
new Function
had access to outer variables, it would have
problems with minifiers
6.8 Scheduling: setTimeout and setInterval
We may decide to execute a function not right now, but at a certain time later. That's called "scheduling a call". There are two methods for it:
setTimeout
allows us to run a function once after the interval of timesetInterval
allows us to run a function repeatedly, starting after the interval of time, then repeating continuously at that interval
These methods are not a part of JS specification. But most environments have the internal scheduler and provide these methods. In particular, they are supported in all browsers and Node.js
setTimeout
The syntax
1 | let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...) |
Parameters:
func|code
: Function or a string of code to execute. Usually, that's a function. For historical reasons, a string of code can be passed, but not recommended. If the first argument is a string, then JS creates a function from itdelay
: The delay before run, in milliseconds, by default 0arg1
,arg2
,...: Arguments for the function (not supported in IE9- )
For instance:
1 | function sayHi() { |
Canceling with
clearTimeout
A call to setTimeout
returns a "timer identifier"
timerId
that we can use to cancel the execution. The syntax
to cancel:
1 | let timerId = setTimeout(...); |
setInterval
The setInterval
method has the same syntax as
setTimeout
:
1 | let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...); |
All arguments have the same meaning. But unlike
setTimeout
it runs the function not only once, but
regularly after the given interval of time. To stop further calls
clearInterval(timerId)
.
Nested setTimeout
For example:
1 | let timerId = setTimeout(function tick() { |
The nested setTimeout
is a more flexible method thant
setInterval
The real delay between function calls for setInterval
is
less than in the code. That's normal, because the time taken by the
function's execution "consumes" a part of the interval. The nested
setTimeout
guarantees the fixed delay.
Zero delay setTimeout
There is a special use case: setTimeout(func, 0)
, or
just setTimeout(func)
. This schedules the execution of
func
as soon as possible. But the scheduler will invoke it
only after the currently executing script is complete. For instance:
1 | setTimeout(()=> alert("World")); |
6.9 Decorators and forwarding, call/apply
Transparent caching
For example:
1 | function slow(x) { |
In the code above, cachingDecorator
is a decorator: a
special function that takes another function and alters its behavior.
There are several benefits of using a separate
cachingDecorator
instead of altering the code of
slow
itself:
- The
cachingDecorator
is reusable. We can apply it to another function - The caching logic is separate, it did not increase the complexity of
slow
itself - We can combine multiple decorators if needed
Using func.call
for the context
The caching decorator mentioned above is not suited to work with
object methods because it cannot access this
of the object.
There is a special built-in function method
func.call(context, ...args)
that allows to call a function
explicitly setting this
. The syntax is:
1 | func.call(context, arg1, arg2, ...) |
It runs func
providing the first argument as
this
, and the next as the arguments. For example, these two
calls do almost the same:
1 | func(1, 2, 3); |
The only difference is that func.call
also sets
this
to obj
For example:
1 | function sayHi() { |
So, we can use call
in the wrapper to pass the context
to the original function.
1 | let worker = { |
To make it more clear:
- After the decoration
worker.slow
is now the wrapperfunction (x) {...}
- So when for example
worker.slow(2)
is executed, the wrapper gets2
as an argument andthis = worker
(the object before dot) - Inside the wrapper, assuming the result is not yet cached,
func.call(this, x)
passes the currentthis = worker
and the current argument (=2
) to the original method
Going multi-argument
There are many solutions possible:
- Implement a new (or use a third-party) map-like data structure that is more versatile and allows multi-keys
- Use nested maps
- Join two values into one. For flexibility, we can allow to provide a hashing function for the decorator, that knows how to make one value from many
For many practical applications, the 3rd variant is good enough. Here
is a more powerful cachingDecorator
:
1 | let worker = { |
func.apply
Instead of func.call(this, ...arguments)
, we could use
func.apply(this, arguments)
. The syntax of built-in method
func.apply
is:
1 | func.apply(context, args) |
It runs the func
setting this = context
and
using an array-like object args
as the list of arguments.
The only syntax difference between call
and
apply
is that call
expects a list of
arguments, while apply
takes an array-like object with
them. So these two calls are almost equivalent:
1 | func.call(context, ...args); |
Passing all arguments along with the context to another function is called call forwarding. The simplest form of it:
1 | let wrapper = function() { |
When an external code calls such wrapper
, it is
indistinguishable from the call of the original function
func
Borrowing a method
The hash function used above can be improved like this:
1 | function hash() { |
This trick is called method borrowing. We take (borrow) a join method
from a regular array ([]
) and use [].join.call
to run it in the context of arguments
.
Why does it work? Because the internal algorithm of the native method
arr.join(glue)
is very simple:
- Let
glue
be the first argument or, if no arguments, then a comma,
- Let
result
be an empty string - Append
this[0]
toresult
- Append
glue
andthis[1]
- Append
glue
andthis[2]
- ... Do so until
this.length
items are glued - Return
result
So, technically it takes this
and joins
this[0]
, this[1]
, ... etc together.
Decorators and function properties
It is generally safe to replace a function or a method with a
decorated one, except for one thing. If the original function had
properties on it, then the decorated one will not provide them. There
exists a way to create decorators that keep access to function
properties, but this requires using a special Proxy
object
to wrap a function
6.10 Function binding
Losing "this"
Once a method is passed somewhere separately from the object -
this
is lost.
1 | let user = { |
The task is quite typical - we want to pass an object method somewhere else where it will be called. How to make sure that it will be called in the right context?
Solution 1: a wrapper
The simplest solution is to use a wrapper function
1 | let user = { |
It works because it receives user
from the outer lexical
environment, and then calls the method normally.
The same, but shorter:
1 | setTimeout(() => user.sayHi(), 1000); |
What if before setTimeout
triggers, user
changes value? Then, suddenly, it will call the wrong object.
Solution 2: bind
Functions provide a built-in method bind
that allows to
fix this
. The basic syntax is:
1 | let boundFunc = func.bind(context); |
The result of func.bind(context)
is a special
function-like object that is callable as function and transparently
passes the call to func
setting
this = context
. For instance:
1 | let user = { |
All arguments are passed to the original func
"as is".
It works even with an object method:
1 | let user = { |
The arguments are passed "as is", only this
is fixed by
bind
Convenience method:
bindAll
If an object has many methods and we plan to actively pass it around, then we could bind them all in a loop:
1 | for (let key in user) { |
JS libraries also provide functions for convenient mass binding, e.g.
_.bindAll(object, methodNames)
in lodash
Partial functions
We can bind not only this
, but also arguments. That's
rarely done, but sometimes can be handy. The full syntax of
bind
:
1 | let bound = func.bind(context, [arg1], [arg2], ...); |
It allows to bind context as this
and starting arguments
of the function. For instance:
1 | function mul(a, b) { |
The call to mul.bind(null, 2)
creates a new function
double
that passes calls to mul
, fixing
null
as the context and 2
as the first
argument. Further arguments are passed "as is"
That's called partial function application - we create a new function
by fixing some parameters of the existing one. Note that we actually
don't use this
here. But bind
requires it, so
we must put in something like null
The benefit of making a partial function is that we can create an
independent function with a readable name (double
,
triple
). In other cases, partial application is useful when
we have a very generic function and want a less universal variant of it
for convenience
Going partial without context
A function partial
for bind only arguments can be easily
implemented:
1 | function partial(func, ...argsBound) { |
The result of partial(func[, arg1, arg2...])
call is a
wrapper that calls func
with:
- Same
this
as it gets (foruser.sayNow
call it'suser
) - Then gives it
...argsBound
- arguments from thepartial
call (10:00
) - Then gives it
...arg
- arguments given to the wrapper (Hello
)
Also there's a ready _.partial
implementation from
lodash library
6.11 Arrow functions revisited
Arrow functions are not just a "shorthand" for writing small stuff. They have some very specific and useful features
Arrow functions have no
this
Arrow functions do not have this
. If this
is accessed, it is taken from the outside.
1 | let group = { |
Arrow functions cannot run with new
. Because arrow
function does not have this
, it cannot be used as
constructors. They cannot be called with new
Arrow functions vs bind
There is a subtle difference between an arrow function
=>
and a regular function called with
.bind(this)
.bind(this)
creates a "bound version" of the function- The arrow
=>
doesn't create any binding. The function simply doesn't havethis
Arrows have no "arguments" variable
That is great for decorators, when we need to forward a call with the
current this
and arguments
. For instance,
defer(f, ms)
gets a function and returns a wrapper around
it that delays the call by ms
milliseconds
1 | function defer(f, ms) { |
Object Properties Configuration
7.1 Property flags and descriptors
Until now, a property was a simple "key-value" pair to us. But an object property is actually a more flexible and powerful thing.
Property flags
Object properties, besides a value
, have three special
attributes (so-called "flags"):
writable
: iftrue
, the value can be changed, otherwise it's read-onlyenumerable
: iftrue
, then listed in loops, otherwise not listedconfigurable
: iftrue
, the property can be deleted and these attributes can be modified, otherwise not- The non-configurable flag is sometimes preset for built-in objects and properties. A non-configurable property can't be deleted, its attributes can't be modified
configurable: false
prevents changes of property flags and its deletion, while allowing to change its value- The only attribute change possible
wrtable true -> false
- The only attribute change possible
When we create a property "the usual way", all of them are
true
. But we also can change them anytime. The method
Object.getOwnPropertyDescriptor
allows to query the full
information about a property. The syntax is:
1 | let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName); |
The returned value is a so-called "property descriptor" object: it contains the value and all the flags. For instance:
1 | let user = { |
To change the flags, we can use Object.defineProperty
.
The syntax is:
1 | Object.definePropery(obj, propertyName, descriptor) |
If the property exists, defineProperty
updates its
flags. Otherwise, it creates the property with the given value and
flags; in that case, if a flag is not supplied, it is assumed
false
. For instance:
1 | let user = {}; |
Object.defineProperties
There is a method
Object.defineProperties(obj, descriptors)
that allows to
define many properties at once. The syntax is:
1 | Object.defineProperties(obj, { |
Object.getOwnPropertyDescriptors
To get all property descriptors at once, we can use the method
Object.getOwnPropertyDescriptors(obj)
. Together with
Object.defineProperties
it can be used as a "flags-aware"
way of cloning an object
1 | let clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(obj)); |
Normally when we clone an object, we use an assignment to copy
properties, but that does not copy flags. So if we want a "better" clone
then Object.defineProperties
is preferred
Sealing an object globally
Property descriptors work at the level of individual properties. There are also methods that limit access to the whole object
Object.preventExtensions(obj)
: forbids the addition of new properties to the objectObject.seal(obj)
: forbids adding / removing of properties. Setconfigurable: false
for all existing propertiesObject.freeze(obj)
: forbids adding / removing / changing of properties. Setsconfigurable: false, writable: false
for all existing properties.
And also there are tests for them:
Object.isExtensible(obj)
Object.isSealed(obj)
Object.isFrozen(obj)
7.2 Property getters and setters
There are two kinds of object properties. The first kind is data properties. All properties that we have been using until now were data properties. The second type of properties is accessor properties. They are essentially functions that execute on getting and setting a value, but look like regular properties to an external code.
Getters and setters
Accessor properties are represented by "getter" and "setter" methods.
In an object literal they are denoted by get
and
set
:
1 | let obj = { |
For instance:
1 | let user = { |
From the outside, an accessor property look like a regular one.
That's the idea of accesssor properties. We don't call
user.fullName
as a function, we read it normally.
Accessor descriptors
Descriptors for accessor properties are different from those for data
properties. For accessor properties, there is no value
or
writable
, but instead there are get
and
set
functions.
get
: a function without arguments, that works when a property is readset
: a function without one argument, that is called when the property is setenumerable
: same as for data propertiesconfigurable
: same as for data properties
Smarter getters / setters
Getters / setters can be used as wrappers over "real" property values to gain more control over operations with them. For instance:
1 | let user = { |
There is a widely known convention that properties starting with an
underscore _
are internal and should not be touched from
outside the object
Using for compatibility
One of the great uses of accessors is that they allow to take control over a "regular" data property at any moment by replacing it with a getter and a setter and tweak its behavior
Prototypes, inheritance
8.1 Prototypal inheritance
[[Prototype]]
In JS, objects have a special hidden property
[[Prototype]]
, that is either null
or
references another object. That object is called "a prototype". When we
read a property from an object
, and it's missing, JS
automatically takes it from its prototype. This is called "prototypal
inheritance". The property [[Prototype]]
is internal and
hidden, but there are many ways to set it:
1 | let animal = { |
The prototype chain can be longer. There are only two limitations:
- The references can't go in circles. JS will throw an error if we try
to assign
__proto__
in a circle. - The value of
__proto__
can be either an object ornull
. Other types are ignored.
The __proto__
property is a bit outdated. It exists for
historical reasons, modern JS suggests that we should use
Object.getPrototypeOf/Object.setPrototypeOf
functions
instead of it.
Writing doesn't use prototype
The prototype is only used for reading properties. Write/delete operations work directly with the object. Accessor properties are an exception, as assignment is handled by a setter function. So writing to such a property is actually the same as calling a function. For instance:
1 | let user = { |
The value of "this"
this
is not affected by prototypes at all. No matter
where the method is found: in an object or its prototype. In a method
call, this
is always the object before the dot. We may have
a big object with many methods, and have objects that inherit from it.
And when the inheriting objects run the inherited methods, they will
modify only their own states, not the state of the big object. as a
result, methods are shared, but the object state is not.
for...in loop
The for..in
loop iterates over inherited properties too.
We can filter out inherited properties:
1 | let animal = { |
Almost all other key-value getting methods, such as
Object.keys
, Object.values
and so on, ignore
inherited properties.
8.2 F.prototype
New objects can be created with a constructor function, like
new F()
. If F.prototype
is an object, then the
new
operator uses it to set [[Prototype]]
for
the new object. The F.prototype
here means a regular
property named "prototype"
on F
. For
instance:
1 | let animal = { |
F.prototype
property is only used when
new F
is called, it assigns [[Prototype]]
of
the new object.
Default F.prototype, constructor property
Every function has the "prototype"
property even if we
don't supply it. The default "prototype"
is an object with
the only property constructor
that points back to the
function itself. We can use constructor
property to create
a new object using the same constructor as the existing one:
1 | function Rabbit(name) { |
However, if we replace the default prototype as a whole, then there
will be no "constructor" in it. To keep the right "constructor", we can
choose to add / remove properties to the default "prototype" instead of
overwriting it as a whole or, alternatively, recreate the
constructor
property manually:
1 | function Rabbit () {} |
8.3 Native prototypes
The prototype
property is widely used by the core of JS
itself. All built-in constructor functions use it.
Object.prototype
For example, an empty object:
1 | let obj = {}; |
The short notation obj = {}
is the same as
obj = new Object()
, where Object
is a built-in
object constructor function, with its own prototype
referencing a huge object with toString
and other methods.
When new Object()
is called (or a literal object
{...}
is created), the [[Prototype]]
of it is
set to Object.prototype
according to the rule that we
discussed previously. So then when obj.toString()
is called
the method is taken from Object.prototype
.
Other build-in prototypes
Other built-in objects such as Array
, Date
,
Function
and others also keep methods in prototypes. By
specification, all of the built-in prototypes have
Object.prototype
on the top. That's why "everything
inherits from objects".
1 | let arr = [1, 2, 3]; |
Primitives
The most intricate thing happens with strings, numbers and booleans.
They are not objects. But if we try to access their properties,
temporary wrapper objects are created using built-in constructors
String
, Number
and Boolean
. They
provide the methods and disappear. These objects are created invisibly
to us and most engines optimize them out, but the specification
describes it exactly this way. Methods of these objects also reside in
prototypes
Changing native prototypes
Native prototypes can be modified. For instance:
1 | String.prototype.show = function() { |
In modern programming, there is only one case where modifying native prototypes is approved. That's polyfilling. Polyfilling is a term for making a substitute for a method that method that exists in the JS specification, but is not yet supported by a particular JS engine. We may then implement it manually and populate the built-in prototype with it.
Borrowing from prototypes
That's when we take a method from one object and copy it into another. For instance:
1 | let obj = { |
8.4 Prototype
methods, objects without __proto__
There are modern methods to setup a prototype:
Object.create(proto, [descriptors])
: creates an empty object with givenproto
as[[Prototype]]
and optional property descriptorsObject.getPrototypeOf(obj)
: returns the[[Prototype]]
ofobj
Object.setPrototypeOf(obj, proto)
: sets the[[Prototype]]
ofobj
toproto
For instance:
1 | let animal = { |
Store user-provided keys in object
If we try to store user-provided keys in it (for instance, a
user-entered dictionary), we can see an interesting glitch: all keys
work fine except "__proto__"
. If we intend to use an object
as an associative array and be free of such problems, we can do it with
a little trick:
1 | let obj = Object.create(null); |
Classes
9.1 Class basic syntax
The basic syntax is:
1 | class MyClass { |
For example:
1 | class User { |
A common pitfall for novice developers is to put a comma between class methods, which would result in a syntax error. The notation here is not to be confused with object literals. Within the class, no commas are required.
What is a class?
In JS, a class is a kind of function
1 | class User { |
What class User {...}
construct really does is:
- Creates a function named
User
, that becomes the result of the class declaration. The function code is taken from theconstructor
method - Stores class methods, such as
sayHi
, inUser.prototype
After new User
object is created, when we call its
method, it's taken from the prototype, just like
F.prototype
1 | alert(User === User.prototype.constructor); // true |
Not just a syntactic sugar
There are important differences between class
and
function
with prototype
:
- A function created by
class
is labelled by a special internal property[[IsClassConstructor]]: true
. So it's not entirely the same as creating it manually. For example, unlike a regular function, it must be called withnew
- Class methods are non-enumerable. A class definition sets
enumerable
flag tofalse
for all methods in theprototype
- Classes always
use strict
. All code inside the class construct is automatically in strict mode
Class expression
Just like functions, classes can be defined inside another expression, passed around, returned, assigned, etc.
1 | let User = class { |
Getters / setters
Just like literal objects, classes may include getters / setters, computed properties etc.
1 | class User { |
Computed method names [...]
1 | class User { |
Class fields
1 | class User { |
The important difference of class fields is that they are set on
individual objects, not User.prototype
Making bound methods with class fields
To solve the problem of "losing this
":
1 | class Button { |
The class field click = () => {...}
is created on a
per-object basis, there's a separate function for each
Button
object, with this
inside it referencing
that object. That's especially useful in browser environment, for event
listeners.
9.2 Class inheritance
The "extends" keyword
For example:
1 | class Animal { |
Internally, extends
keyword works using the good old
prototype mechanics. It sets Rabbit.prototype.[[Prototype]]
to Animal.prototype
Class syntax allows to specify not just a class, but any expression
after extends
Overriding a method
Classes provide "super"
keyword to call the parent
method
super.method(...)
to call a parent methodsuper(...)
to call a parent constructor (inside child constructor only)
For example:
1 | class Rabbit extends Animal { |
However, arrow functions have no super
. If accessed,
it's taken from the outer function.
Overriding constructor
According to the specification, if a class extends another class and
has no constructor
, then the following "empty" constructor
is generated:
1 | class Rabbit extends Animal { |
Constructors in inheriting classes must call super(...)
,
and do it before using this
. In JS, a derived constructor
has a special internal property
[[ConstructorKind]]:"derived"
. That label affects its
behavior with new
:
- When a regular function is executed with
new
, it creates an empty object and assigns it tothis
- But when a derived constructor runs, it expects the parent constructor to do this job
For example:
1 | class Rabbit extends Animal { |
Thus parent constructor always uses its own field value, not the overridden one. However, it will use the overridden method.
[[HomeObject]]
When a function is specified as a class or object method, its
[[HomeObject]]
property becomes that object.
super
uses it to resolve the parent prototype and its
methods. For example:
1 | let animal = { |
Methods, not function properties
For objects, methods must be specified exactly as
method()
, not as "method: function()"
9.3 Static properties and methods
We can also assign a method to the class function itself, not to its
"prototype"
. Such methods are called static. For
example:
1 | class Article { |
Static properties
Static properties are also possible
Inheritance of static properties and methods
Static properties and methods are inherited using prototypes.
extends
gives the child the [[Prototype]]
reference to Animal
9.4 Private and protected properties and methods
- Public: accessible from anywhere. They comprise the external interface
- Private: accessible only from inside the class. These are for the internal interface
- Protected: accessible only from inside the class and those extending
it
- Protected fields are not implemented in JS on the language level, but in practice they are very convenient, so they are emulated
Protecting "waterAmount"
Let's make a simple coffee machine class. Protected properties are
usually prefixed with an underscore _
. That is not enforced
on the language level, but there's a well-known convention between
programmers that such properties and methods should not be accessed from
the outside
1 | class CoffeeMachine { |
Read-only "power"
If you want to make a property, for example power
, then
we only need to make getter, but not the setter.
Private "#waterLimit"
There is a finished JS proposal, almost in the standard, that
provides language-level support for private properties and methods.
Privates should start with #
. They are only accessible from
inside the class. For instance:
1 | class CoffeeMachine { |
On the language level, #
is a special sign that the
field is private. Private fields do not conflict with public ones. We
can have both private #waterAmount
and public
waterAmount
field at the same time.
9.5 Extending built-in classes
Built-in classes like Array, Map, and others are extendable also. For instance:
1 | class PowerArray extends Array { |
When arr.filter()
is called, it internally creates the
new array of results using exactly arr.constructor
, not
basic Array
. We can add a special static getter
Symbol.species
to the class. If it exists, it should return
the constructor that JS will use internally to create new entities in
map
, filter
and so on. If we'd like built-in
methods like map
or filter
to return regular
array, we can return Array
in
Symbol.species
:
1 | class PowerArray extends Array { |
No static inheritance in built-ins
Normally, when one class extends another, both static and non-static methods are inherited. But built-in classes are an exception. They don't inherit statics from each other.
9.6 Class checking: "instanceof"
The instanceof
operator allows to check whether an
object belongs to a certain class. It also takes inheritance into
account. The syntax is:
1 | obj instanceof Class |
For instance:
1 | class Rabbit {} |
It also works with constructor functions:
1 | function Rabbit() {} |
The algorithm of obj instanceof Class
works roughly as
follows:
If there's a static method
Symbol.hasInstance
, then just call it:Class[Symbol.hasInstance](obj)
. It should return eithertrue
orfalse
, and we are done. For instance:1
2
3
4
5
6
7
8
9class Animal {
static [Symbol.hasInstance](obj) {
if (obj.canEat) return true;
}
}
let obj = {canEat: true}
alert(obj instanceof Animal); // true: Animal[Symbol.hasInstance](obj) is calledMost classes do not have
Symbol.hasInstance
. In that case, the standard logic is used:obj instanceof class
check whetherclass.prototype
is equal to one of the prototypes in theobj
prototype chain
There is also a method objA.isPrototypeOf(objB)
, that
returns true
if objA
is somewhere in the chain
of prototypes for objB
. So the test of
obj instanceof Class
can be rephrased as
Class.prototype.isPrototypeOf(obj)
Symbol.toStringTag
The behavior of Object toString
can be customized using
a special object property Symbol.toStringTag
. For
instance:
1 | let user = { |
For most environment-specific objects, there is such a property. We
can use {}.toString.call
instead of instanceof
for built-in objects when we want to get the type as a string rather
than just to check.
9.7 Mixins
In JS we can only inherit from a single object. There can be only one
[[Prototype]]
for an object. And a class may extend only
one other class. But sometimes that feels limiting. There's a concept
that can help here, called "mixins". A mixin is a class containing
methods that can be used by other classes without a need to inherit from
it. In other words, a mixin provides methods that implement a certain
behavior, but we do not use it alone, we use it to add the behavior to
other classes
A mixin example
The simplest way to implement a mixin in JS is to make an object with useful methods, so that we can easily merge them into a prototype of any class. For instance:
1 | // mixin |
There is no inheritance, but a simple method copying. So
User
may inherit from another class and also include the
mixin to "mix-in" the additional methods, like this:
1 | class User extends Person { |
Mixins can make use of inheritance inside themselves. For instance:
1 | let sayMixin = { |
EventMixin
Now let's make a mixin for real life. An important feature of many browser objects is that they can generate events. Events are a great way to "broadcast information" to anyone who wants it. So let's make a mixin that allows us to easily add event-related functions to any class/object.
- The mixin will provide a method
.trigger(name, [...data])
to "generate an event" when something important happens to it. Thename
argument is a name of the event, optionally followed by additional arguments with event data - Also the method
.on(name, handler)
that addshandler
function as the listener to events with the given name. It will be called when an event with the givenname
triggers, and get the arguments from the.trigger
call - And the method
.off(name, handler)
that removes the handler listener
For instance:
1 | let eventMixin = { |
.on(eventName, handler)
: assigns functionhandler
to run when the event with that name occurs.off(eventName, handler)
: removes the function from the handlers list.trigger(event, ...args)
: generates the event: all handlers from_eventHandlers[eventName]
are called, with a list of arguments...args
Usage:
1 | class Menu { |
Now, if we'd like any code to react to a menu selection, we can
listen for it with menu.on(...)
. And
eventMixin
mixin makes it easy to add such behavior to as
many classes as we'd like, without interfering with the inheritance
chain.
Error handling
10.1 Error handling, "try...catch"
The syntax
1 | try { |
try...catch
only works for runtime errorstry...catch
works synchronously- If an exception happens in "scheduled" code, like in
setTimeout
, thentry...catch
won't catch it
- If an exception happens in "scheduled" code, like in
Error object
When an error occurs, JS generates an object containing the details
about it. The object is then passed as an argument to
catch
. For all built-in errors, the error object has two
main properties:
name
: Error name. For instance, for an undefined variable that's"ReferenceError"
message
: Textual message about error details
There are other non-standard properties available in most environments. One of most widely used and supported is:
stack
: Current call stack
Throwing our own errors
The throw
operator generates an error. The syntax
is:
1 | throw <error object> |
Technically, we can use anything as an error object. That may be even
a primitive, like a number or a string, but it's better to use objects,
preferably with name
and message
properties.
JS has many built-in constructors for standard errors,
Error
, SyntaxError
,
ReferenceError
, TypeError
and others. We can
use them to create error objects as well. The syntax is:
1 | let error1 = new Error (message); |
Catch should only process errors that it knows and "rethrow" all others
try...catch...finally
If finally
code clause exists, it runs in all cases:
- after
try
, if there were no errors, - after
catch
, if there were errors
1 | try { |
The finally
clause works for any exit from
try...catch
. That includes an explicit return
.
For instance:
1 | function func() { |
The try...finally
construct, without catch
clause, is also useful. We apply it when we don't want to handle errors
here, but want to be sure that processes that we started are
finalized.
Global catch
What if we want to log the error, show something to the user, etc.
when a fatal error outside of try...catch
occurs. There is
no solution in the specification, but environments usually provide it,
because it's really useful. For instance, Node.js has
process.on("uncaughtException")
for that. And in the
browser we can assign a function to the special
window.onerror
property, that will run in case of an
uncaught error. The syntax:
1 | window.onerror = function(message, url, line, col, error) { |
message
: Error messageurl
: URL of the script where error happenedline
,col
: Line and column numbers where error happenederror
: Error object
For instance:
1 | <script> |
10.2 Custom errors, extending error
JS allows to use throw
with any argument, so technically
our custom error classes don't need to inherit from Error
but it is a better practice to do so.
Extending error
For instance:
1 | class ValidationError extends Error { |
To make the custom errors shorter:
1 | class MyError extends Error { |
Promises, async/await
11.1 Introduction: callbacks
A "callback-based" style of asynchronous programming means a function
that does something asynchronously should provide a
callback
argument where we put the function to run after
it's complete. For instance:
1 | function loadScript(src, callback) { |
Handling errors
An improved version of loadScript
that tracks loading
errors:
1 | function loadScript(src, callback) { |
11.2 Promise
The constructor syntax for a promise object is:
1 | let promise = new Promise(function(resolve, reject) { |
The function passed to new Promise
is called the
executor. When new Promise
is created, the executor runs
automatically. It contains the producing code which should eventually
produce the result. Its arguments resolve
and
reject
are callbacks provided by JS itself. Our code is
only inside the executor. When the executor obtains the result, be it
soon or late, doesn't matter, it should one of these callbacks:
resolve(value)
: if the job is finished successfully, with resultvalue
reject(error)
: if an error has occurred,error
is the error object
The promise
object returned by the
new Promise
constructor has these internal properties:
state
: initially"pending"
, then changes to either"fulfilled"
whenresolve
is called or"rejected"
whenreject
is calledresult
: initiallyundefined
, then changes tovalue
whenresolve(value)
called orerror
whenreject(error)
is called
For instance:
1 | let promise = new Promise(function(resolve, reject) { |
or
1 | let promise = new Promise(function(resolve, reject) { |
But there can be only a single result or an error:
1 | let promise = new Promise(function(resolve, reject) { |
Consumers: then, catch, finally
The properties state
and result
of the
Promise object are internal. We can't directly access them. We can use
the method .then
/.catch
/.finally
for that. A Promise object serves as a link between the executor and the
consuming functions, which will receive the result or error. Consuming
functions can be registered (substribed) using methods
.then
, .catch
and .finally
then
The syntax is:
1 | promise.then( |
The first argument of .then
is a function that runs when
the promise is resolved, and receives the result. The second argument of
.then
is a function that runs when the promise is rejected,
and receives the error. For example:
1 | promise.then( |
catch
If we are interested only in errors, we can use null
as
the first argument in then
or use .catch
:
1 | promise.catch(alert); // shows for example "Error: Whoops!" |
finally
The call .finally(f)
is similar to
.then(f, f)
in the sense that f
always runs
when the promise is settled. finally
is a good handler for
performing cleanup, e.g. stopping our loading indicators, as they are
not needed anymore, no matter what the outcome is.
The differences between finally(f)
and
then(f, f)
are:
- A
finally
handler has no arguments. - A
finally
handler passes through results and errors to the next handler
For instance:
1 | new Promise((resolve, reject) => { |
11.3 Promises chaining
If we have a sequence of asynchronous tasks to be performed one after another, how can we code it well? One solution is promise chaining:
1 | new Promise(function(resolve, reject) { |
Technically we can also add many .then
to a single
promise. This is not chaining. It is just several handlers to one
promise. They don't pass the result to each other; instead they process
it independently
Returning promises
A handler, used in .then(handler)
may create and return
a promise. In that case further handlers wait until it settles, and then
get its result. For instance:
1 | new Promise(function(resolve, reject) { |
Thenables
To be precise, a handler may return not exactly a promise, but a
so-called "thenable" object - an arbitrary object that ahas a method
.then
. It will be treated the same way as a promise.
11.4 Error handling with promises
Promise chains are great at error handling. When a promise rejects, the control jumps to the closest rejection handler. That's very convenient in practice.
Implicit try...catch
The code of a promise executor and promise handlers has an "invisible
"try...catch"
around it. If an exception happens, it gets
caught and treated as a rejection. This happens not only in the executor
function, but in its handlers as well. If we throw
inside a
.then
handler, that means a rejected promise, so the
control jumps to the nearest error handler.
1 | new Promise((resolve, reject) => { |
Rethrowing
If we throw
inside .catch
, then the control
goes to the next closest error handler. And if we handle the error and
finish normally, then it continues to the next closest successful
.then
handler
11.5 Promise API
There are 6 static methods in the Promise
class
Promise.all
Let's say we want many promises to execute in parallel and wait until
all of them are ready. That's what Promise.all
is for.
Promise.all
takes an array of promises (it technically can
be any iterable, but is usually an array) and returns a new promise. The
new promise resolves when all listed promises are resolved, and the
array of their results becomes its result. The syntax is:
1 | let promise = Promise.all([...promises...]); |
For instance:
1 | Promise.all([ |
The order of the resulting array members is the same as in tis source
promises. If any of the promises is rejected, the promise returned by
Promise.all
immediately rejects with that error and other
promises are ignored. Promise.all(iterable)
allows
non-promise "regular" values in iterable.
Promise.allSettled
Promise.allSettled
just waits for all promises to
settle, regardless of the result. The resulting array has:
{status:"fulfilled", value:result}
for successful responses{status:"rejected", reason:error}
for error
We can use Promise.allSettled
to get the results of all
given promises, even if some of them reject.
Promise.race
Similar to Promise.all
, but waits only for the first
settled promise and gets its result. The promise might be the fastest or
the one that get ran first, so it become the result. After the first
settled promise "wins the race", all further results / errors are
ignored.
Promise.any
Similar to Promise.race
, but waits only for the first
fulfilled promise and gets its result. If all of the given promises are
rejected, then the returned promise is rejected with
AggregateError
- a special error object that stores all
promise errors in its errors
property.
Promise.resolve/reject
Methods Promise.resolve
and Promise.reject
are rarely needed in modern code, because async/await
syntax makes them somewhat obsolete.
Promise.resolve
Promise.resolve(value)
creates a resolved promise with
the result value
Promise.reject
Promise.reject(error)
creates a rejected promise with
error
11.6 Promisification
"Promisification" is the conversion of a function that accepts a callback into a function that returns a promise. Such transformations are often required in real-life, as many functions and libraries are callback-based. But promises are more convenient, so it makes sense to promisify them. For example, the following callback style function
1 | function loadScript(src, callback) { |
After promisify it:
1 | let loadScriptPromise = function(src) { |
A more general way to promisify functions with a helper function
promisift(f)
. It accepts a to-promisift function
f
and returns a wrapper function
1 | function promisify(f) { |
11.7 Microtasks
Promise handlers .then
, .catch
,
.finally
are always asynchronous. Even when a Promise is
immediately resolved, the code on the line below them will still execute
before these handlers.
Microtasks queue
Asynchronous tasks need proper management. For that, the ECMA
standard specifies an internal queue PromiseJobs
, more
often referred to as the "microtask queue".
- The queue is first-in-first-out: tasks enqueued first are run first
- Execution of a task is initiated only when nothing else is running
What if the order matters for us? Just put it into the queue with
.then
Unhandled rejection
An "unhandled rejection" occurs when a promise error is not handled
at the end of the microtask queue. Normally, if we expect an error, we
add .catch
to the promise chain to handle it. But if we
forget to add .catch
, then, after the microtask queue is
empty, the engine triggers the event:
1 | let promise = Promise.reject(new Error("Promise Failed!")); |
If we handle the error later, the event will still be triggered. When the microtask queue is complete, the engine examines promises and, if any of them is in the "rejected" state, then the event triggers.
11.8 Async/await
There is a special syntax to work with promises in a more comfortable fashion, called "async/await". It's surprisingly easy to understand and use.
Async functions
The word "async" before a function means one simple thing: a function always return a promise. Other values are wrapped in a resolved promise automatically.
Await
The syntax:
1 | let value = await promise; |
The keyword await
makes JS wait until that promise
settles and returns its result. For instance:
1 | async function f() { |
The function execution "pauses" at the line with await
and resumes when the promise settles, with result
becoming
its result.
If we try to use await
in a non-async function, there
would be a syntax error. Thus it can't be used in top-level code. But we
can do the following:
1 | (async () => { |
await
also accept "thenable" objects.
Error handling
If a promise resolves normally, then await promise
returns the result. But in the case of a rejection, it throws the error,
jsut as if there were a throw
statement at that line.
async/await
works well with Promise.all
For instance:
1 | let results = await Promise.all([ |
Generators, advanced iteration
12.1 Generators
Regular functions return only one, single value (or nothing). Generators can return ("yield") multiple values, one after another, on-demand. They work great with iterables
Generator functions
Its special syntax:
1 | function* generateSequence() { |
Generators are iterable
We can loop over their values using for..of
:
1 | let generator = generateSequence(); |
Using generators for iterables
Previously, we created an iterable range
object that
returns values from..to
. We can use a generator function
for iteration by providing it as Symbol.iterator
:
1 | let range = { |
Generator composition
Generator composition is a special feature of generators that allows
to transparently "embed" generators in each other. There is a special
yield*
syntax to compose one generator into another. For
instance:
1 | function* generateSequence(start, end) { |
The yield*
directive delegates the execution to another
generator. This term means that yield* gen
iterates over
the generator gen
and transparently forwards its yields
outside.
yield
is a two-way
street
yield
not only returns the result to the outside, but
also can pass the value inside the generator. To do so, we should call
generator.next(arg)
. That argument becomes the result of
yield
. For instace:
1 | function* gen() { |
- The first call
generator.next()
should be always made without an argument (the argument is ignored is passed). It starts the execution and returns the result of the firstyield "2+2=?"
. At this point the generator pauses the execution - Then, the result of
yield
gets into thequestion
variable in the calling code - On
generator.next(4)
, the generator resumes, and4
gets in as the result:let result = 4
generator.throw
The outer code may pass an error into the generator with the call
generator.throw(err)
. In that case, the err
is
thrown in the line with the yield
.
generator.return
generator.return(value)
finishes the generator execution
and return the given value
:
1 | function* gen() { |
12.2 Async iteration and generators
Async iterables
To make an object iterable asynchronously: 1. Use
Symbol.asyncIterator
instead of
Symbol.iterator
2. The next()
method should
return a promise * The async
keyword handles it, we can
simply make async next()
3. To iterate over such an object,
we should use a for await (let item of iterable)
loop
For instance:
1 | let range = { |
However, features that require regular, synchronous iterators, don't
work with asynchronous ones. For instance, a spread syntax won't work:
alert( [...range] );
Async generators
For most practical applicatioins, when we'd like to make an object that asynchronously generates a sequence of values, we can use an asynchronous generator.
1 | async function* generateSequence(start, end) { |
Modles
13.1 Modules, introduction
What is a module?
A module is just a file. One script is one module. Modules can load
each other and use special directives export
and
import
to interchange functionality, call functions of one
module from another one. For instance:
1 | // in file sayHi.js |
1 | // in file main.js |
Modules work only via HTTP(s), not locally.
Core module features
- Always "use strict"
- Modules always work in strict mode
- Module-level scope
- Each module has its own top-level scope.
- A module code is evaluated only the first time when imported
- If the same module is imported into multiple other modules, its code is executed only once, upon the first import. Then its exports are given to all further importers.
- Such behavior allows us to configure modules. Here's the classical
pattern
- A module exports some means of configuration, e.g. a configuration object
- On the first import we initialize it, write to its properties. The top-level application script may do that.
- Further imports use the module
- import.meta
- The object
import.meta
contains the information about the current module. Its content depends on the environment. In the browser, it contains the URL of the script, or a current webpage URL if inside HTML
- The object
- In a module, "this" is undefined
- In a non-module scripts,
this
is a global object
- In a non-module scripts,
Browser-specific features
There are also several browser-specific differences of scripts with
type="module"
compared to regular ones.
- Module scripts are deferred
- Module scripts are always deferred, same effect as
defer
attribute - Thus downloading external module scripts
<script type="module" src="...">
does not block HTML processing - Module scripts wait until the HTML document is fully read, and then run
- Relative order of scripts is maintained: scripts that go first in the document, execute first
- Module scripts are always deferred, same effect as
- Async works on inline scripts
- For non-module scripts, the
async
attribute only works on external scripts. Async scripts run immediately when ready, independently of other scripts or the HTML document - For module scripts, it works on inline scripts as well
- For example:
1
2
3
4<script async type="module">
import {counter} from "./analytics.js";
counter.count();
</script>
- For non-module scripts, the
- External scripts that have
type="module"
are different in two aspects:- External scripts with the same
src
run only once - External scripts that are fetched from another origin require CORS headers
- External scripts with the same
- No "bare" modules allowed
- In the browser,
import
must get either a relative or absolute URL. Modules without any path are called "bare" modules. Such modules are not allowed in "import"
- In the browser,
- Old browsers do not understand
type="module"
. For them, it's possible to provide a fallback using thenomodule
attribute1
2
3
4
5
6
7
8<script type="module">
alert("Runs in modern browsers");
</script>
<script nomodule>
alert("Modern browsers ignore this");
alert("Old browsers execute this.");
</script>
13.2 Export and import
Export and import directives have several syntax variants.
Export before declarations
We can label any declaration as exported by placing
export
before it, be it a variable, function or a
class.
1 | export let months = ['Jan', 'Feb', 'Mar']; |
Note that there is no semicolons after export class / function. The
export
before a class or a function does not make it a
function expression. It's still a function declaration. Most JS style
guides don't recommend semicolons after function and class
declarations.
Export apart from declarations
We can also put export
separately.
1 | function sayHi(user) { |
import *
We can import everything as an object using
import * as <obj>
:
1 | import * as say from '.say.js'; |
import as
1 | import { sayHi as hi, sayBye as bye } from './say.js'; |
export as
1 | export { sayHi as hi, sayBye as bye }; |
Export default
There are modules that declare a single entity, e.g. a module
user.js
exports only class User
. This approach
is preferred, so that every "thing" resides in its own module. Modules
provide a special export default
syntax to make the "one
thing per module" way look better.
1 | // In user.js |
As there may be at most one default export per file, the exported entity may have no name.
The "default" name
In some situations the default
keyword is used to
reference the default export. For example:
1 | function sayHi(user) { |
or
1 | import * as user from './user.js'; |
A word against default exports
Named exports are explicit. They exactly name what they import, so we have that information from them. For a default export, we always choose the name when importing:
1 | import User from './user.js'; |
Re-export
"Re-export" syntax export ... from ...
allows to import
things and immediately export them (possibly under another name), like
this:
1 | export {sayHi} from './say.js'; |
Re-exporting the default export
The default export needs separate handling when re-exporting. We can do it in two following way:
export {default as User} from './user.js'
will re-export the default exportexport * from './user.js'
will re-exports only named exports, but ignores the default one.
If we'd like to re-export both named and the default export, then two statements are needed.
13.3 Dynamic imports
Export and import statements that we covered in previous chapters are
called "static". The syntax is very simple and strict. First, we can't
dynamically generate any parameters of import
. Second, we
can't import conditionally or at run-time.
The import() expression
The import(module)
expression loads the module and
returns a promise that resolves into a module object that contains all
its exports. It can be called from any place in the code. For
instance:
1 | let modulePath = prompt("Which module to load?"); |
or
1 | let { hi, bye } = await import('./say.js'); |
or if say.js
has the default export:
1 | let obj = await import('./say.js'); |
Dynamic imports work in regular scripts, they don't require
script type="module"
. Although import()
looks
like a function call, it's a special syntax that just happens to use
parentheses (similar to super()
).
Miscullaneous
14.1 Proxy and Reflect
A Proxy
object wraps another object and intercepts
operations, like reading/writing properties and others, optionally
handling them on its own, or transparently allowing the object to handle
them. Proxies are used in many libraries and some browser
frameworks.
Proxy
The syntax:
1 | let proxy = new Proxy(target, handler) |
target
is an object to wraphandler
is proxy configuration. An object with "traps", methods that intercept operations, e.g.get
trap for reading a property,set
trap for writign a property.
For every internal method of objects, there is a Proxy trap for it:
The name of the method that we can add to the handler
parameter of new Proxy
to intercept the operation
Default value with "get" trap
The most common tarps are for reading/writing properties. To
intercept reading, the handler
should have a method
get(target, property, receiver)
. It triggers when a
property is read, with following arguments: * target
is the
target object, the one passed as the fiorst argument to
new Proxy
* property
is the property name *
receiver
: if the target property is a getter, then
receiver
is the object that's going to be used as
this
in its call. Usually that is the proxy
object itself.
For instance:
1 | let numbers = [0, 1, 2]; |
Validation with "set" trap
The set
trap triggers when a property is written:
set(target, property, value, receiver)
: *
target
is the target object, the one passed as the first
argument to new Proxy
* property
is the
property name * value
is the property value *
receiver
is similar to get
trap, matters only
for setter properties
The set
trap should return true
if setting
is successful, and false
otherwise (triggers
TypeError
). For instance:
1 | let numbers = []; |
Note that the built-in functionality of arrays is still working.
Protected properties with "deleteProperty"
For example:
1 | let user = { |
A call to user.checkPassword()
gets proxied
user
as this
(the object before dot becomes
this
), so when it tries to access
this._passsword
, the get
trap activates (it
triggers on any property read) and throws an error. So we bind the
context of object methods to the original object, target
.
Then their future calls will use target
as
this
, without any trap. That solution usually works, but
isn't ideal. So, such a proxy shouldn't be used everywhere.
"In range" with "has" trap
We have a range object:
1 | let range = { |
We'd like to use the in
operator to check that a number
is in range
. The has
trap intercepts
in
calls: has(target, property)
*
target
is the target object, passed as the first argument
to new Proxy
* property
is the property
name
1 | range = new Proxy(range, { |
Wrapping functions: "apply"
We can wrap a proxy around a function as well. The
apply(target, thisArg, args)
trap handles calling a proxy
as function. For instance:
1 | function delay(f, ms) { |
Reflect
Reflect
is a built-in object that simplifies creation of
Proxy
. The internal methods, such as [[Get]]
,
[[Set]]
and others are specification-only, they can't be
called directly. The Reflect
object makes that somewhat
possible. Its methods are minimal wrappers around the internal
methods.
1 | let user = {}; |
For every internal method, trappable by Proxy
, there is
a corresponding method in Reflect
, with the same name and
arguments as the Proxy
trap.
1 | let user = { |
If a trap wants to forward the call to the object, it's enough to
call Reflect.<method>
with the same arguments.
Proxy limitations
- Many built-in objects, for example Map, Set, Date, Promise and
others make use of so-called "internal slots". These are like
properties, but reserved for internal, specification-only purpose.
Proxy
can't intercept that. To get around it:1
2
3
4
5
6
7
8
9
10
11let map = new Map();
let proxy = new Proxy(map, {
get(target, prop, receiver) {
let value = Reflect.get(...arguments);
return typeof value == "function" ? value.bind(target) : value;
}
});
proxy.set("test", 1);
alert(proxy.get("test")); - A similar thing happens with private class fields. The reason is
that private fields are implemented using internal slots. JS does not
use
[[Get]]/[[Set]]
when accessing them.
Revocable proxies
A revocable proxy is a proxy that can be disabled. Such a proxy will forward operations to object, and we can disable it at any moment. For instance:
1 | let revokes = new WeakMap(); |
We use WeakMap
instead of Map
here because
it won't block garbage collection.
14.2 Eval: run a code string
The built-in eval
function allows to execute a string of
code. The syntax is:
1 | let result = eval(code); |
The eval'ed code is executed in the current lexical environment, so
it can see outer variables. It can change outer variables as well. In
strict mode, eval
has its own lexical environment. So
functions and variables, declared inside eval, are no visible
outside.
In modern programming eval
is used very sparingly. A
better way to use eval
:
- If eval'ed code doesn't use outer variables, please call
eval
aswindow.eval(...)
- If eavl'ed code needs local variables, change
eval
tonew Function
and pass them as arguments.1
2
3let f = new Function('a', 'alert(a)');
f(5);
14.3 Currying
Currying is a transformation of functions that translates a function
from callable as f(a, b, c)
into callable as
f(a)(b)(c)
. Currying doesn't call a function. It just
transforms it.
Advanced implementations of currying, such as _.curry
from lodash library, return a wrapper that allows a function to be
called both normally and partially.
1 | function sum(a, b) { |
What for?
For instance, we have the logging function
log(date, importance, message)
that formats and outputs the
information:
1 | function log(date, importance, message) { |
Then we can create convenience functions for current logs:
1 | let logNow = log(new Date()); |
Advanced curry implementation
1 | function curry(func) { |
14.4 Reference type
Reference type explained
Looking closely, we may notice two operations in
obj.method
statement: 1. First, the dot .
retrieves the property obj.method()
. 2. Then parenthese
()
execute it.
The .
returns not a function, but a value of the special
Reference Type. The Reference Type is a "specification type". We can't
explicitly use it, but it is used internally by the language. The value
of Reference Type is a three-value combination
(base, name, strict)
, where: * base
is the
object * name
is the property name * strict
is
true if use strict
is in effect
When parentheses ()
are called on the Reference Type,
they receive the full information about the object and its method, and
can set the right this
. Reference type is a special
"intermediary" internal type, with the purpose to pass information from
dot .
to calling parentheses ()
.
Any other operation like assignment fun = obj.method
discards the reference type as a whole, takes the value of
obj.method
and passes it on. So any further operation
"loses" this
.
14.5 BigInt
BigInt
is a special numberic tpye that provides support
for integers of arbitrary length. A bigint is created by appending
n
to the end of an integer literal or by calling the
function BigInt
that creates bigints from strings, numbers
etc.
For instance:
1 | const bigint = 12345678901234567890234567890n; |
Polyfills
Polyfilling bigints is tricky. It is cumbersome and would cost a lot of performance. The other way around is proposed by the developers of JSBI library. This library implements big numbers uing its own methods. We can use them instead of native bigints. For example:
1 | a = JSBI.BigInt(789); |
Then the polyfill plygin will convert JSBI calls to native bigints for those browsers that support them.