Table of contents
- Getting Started with JavaScript
- Deep JavaScript Foundations
- Rethinking Asynchronous JavaScript
- Functional-Light JavaScript
- Digging into Node.js
- JavaScript: The Recent Parts
Rethinking Asynchronous JavaScript
- Concurrency: Two higher level tasks happening within the same timeframe.
- Parallelism: Multiple operations taking place at the same time to leverage multiple cores. Not possible with JavaScript.
- Asynchronicity: Only a single operation takes place at a time, but the execution of multiple high-level tasks are interleaved and happen within the same timeframe.
Callbacks
- Callbacks can be thought of as 'continuations' of code.
- Indentation itself is not callback hell
- Causes of callback hell:
- Inversion of Control: In domain of concurrency, when a developer gives away a callback and thus loses control over the execution of that part of the program. Requires trusting other parts of the program to use your callback in ways you expect
- Not Reasonable: Our brains think in a sequential fashion, so using callbacks as our pattern for asynchrony limits our ability to reason about what our code is doing.
Thunks
- Thunk: Pattern for using callbacks. Create a function that can output a value without any need to pass in new arguments. Time-independent wrapper around a value. Essentially a promise without the specialized interface. Does not solve inversion of control.
- Time is the most complex factor of state in your program.
- Thunk example:
function addAsync(x, y, cb) {
setTimeout(function() {
cb(x + y);
}, 1000);
}
let thunk = function(cb) {
addAsync(10, 15, cb);
};
thunk(function(sum) {
sum;
})
Promises
- Think about promises as receipts for purchased food. It is a placeholder that can be exchanged for the actual value when it's ready to use.
- Instead of giving away control of execution as with callbacks, promises return back control when events are fired.
- Guarantees when using promises for asynchrony:
- Only resolved once
- Either success or error
- Messages passed/kept
- Exceptions become errors that can be processed
- Immutable once resolved
- Promises can be chained. Promise handlers return new promises that can be handled in .then statements
Promise.all([arr]); // Execute all functions in array. Requires all to be successful. Returns promise with array of results
Promise.allSettled([arr]) // Execute all functions. Returns promise with array of results once all are settled
Promise.race([arr]); // Execute all functions in array, returns first complete task result
Promise.any([arr]); // Executes all tasks, returns first fulfilled task or if all tasks are rejected returns AggregateError
Generators
- Unlike normal functions, generators don't run to completion, they run until a 'yield statement' is encountered and pause execution until the generator's
.next()
function is called again. - Declared via
function* foo() {}
syntax - Calling a generator doesn't start execution but instead returns an iterator
.next()
returns an object with keys "value" and "done", where value is the yielded/returned value and done is a boolean indicating whether the function has completed.- Generator iterators can be used in for..of loops
- Values can be passed into generators via using
(yield)
inside a statement in the generator and then using.next(value)
in the driving code - Generators do not have to yield all their values, they'll be garbage collected
- Combining with promises solves both causes of callback hell. Promises resolve inversion of control and generators solve reasonability.
Observables
- Promises do not model events well, considering promises are designed to resolve only once and attempts to create event-handling promises can result in a messy promise hell.
- Observable: Adapter hooked onto an event source that produces a promise every time an event comes through. Event handling is done declaratively
- Example using RxJS:
const obsv = Rx.Observable.fromEvent(btn, "click");
obsv
.map(function mapper(evt) { // extracts event name
return evt.target.className;
})
.filter(function filterer(className) { // excludes events of a certain name
return /foobar/.text(className);
})
.distinctUntilChanged() // Ignore duplicate events
.subscribe(function(data) {
const className = data[1];
console.log(className);
})
RxJS playground at https://rxmarbles.com
CSP
- CSP (Communicating Sequential Processes): Pattern where independent processes run separately have a blocking communication channel with which they can share messages
const ch = chan();
function* process1() {
yield put(ch, "Hello"); // put "Hello onto channel". yield makes process1 pause until someone is ready to take from the channel.
const msg = yield take(ch);
console.log(msg);
}
function* process2() {
const greeting = yield take(ch);
yield put(ch, greeting + " World");
console.log("done!");
}
Functional-Light JavaScript
- Why functional programming?
- Functional programming is by its nature more declarative, making it more intuitive to understand
Functional Purity
- Function: The semantic relationship between an input and an output
- Pure functions:
- Must only call functions, not procedures
- Must not have 'indirect inputs' or 'indirect outputs'
- Have no side effects (impacts on the state of anything outside of the function)
- Due to the nature of JavaScript it doesn't have pure functions but can have pure function calls
- It is impossible to write a useful program without some sort of side effect. If we are going to do side effects we want to make sure they're obvious and we use them intentionally
- As much as possible we want our function calls to behave as pure, meaning that every time we give it the same input we get the same output.
- Wherever possible and practical extract impurity out to separate procedures so it's obvious which parts of our code are pure and which aren't
- Impurity can be contained (e.g. make a copy of a global variable so it can be used in a pure function)
Argument Adapters
- Parameters are the placeholder names for value passed into a function (e.g. a, b, size) while arguments are values that are assigned to the parameters
- Shape of a function refers to the number of inputs and outputs of a function. This includes the inputs and outputs of functions returned by functions.
- A Higher-Order Function is a function that receives or returns functions (as opposed to a single-order function that does not)
- Functions can be reshaped by adapters if they need to be in order to fit well with other functions
- Parameters can be 'flipped' by adapters
- The spread or apply of a function is the version that spreads out an array argument into individual arguments
Point Free
- Point-Free: Defining a function without needing to define its points (i.e. its inputs)
- Equational Reasoning: Reasoning about equivalency between functions based on their shapes
- Example of refactoring a function to be point-free
function isOdd(v) {
return v % 2 == 1;
}
/**
* function isEven(v) {
* return !isOdd(v);
* }
*/
const isEven = R.not(isOdd); // HERE! Function is now defined solely as the negation of another function without needing to state anything about the implementation locally
- An advanced example of point-free:
function mod(y) {
return function forX(x) {
return x % y;
};
}
function eq(y) {
return function forX(x) {
return x === y;
};
}
let isOdd = R.compose(eq(1), mod(2));
let isEven = not(isOdd);
Closure
- Closure is when a function remembers the variables around it even when that function is executed elsewhere
- Closures are not pure functions as they hold onto state and aren't expected to have the same output for the same input
- Work inside a nested function is deferred
- Even if a function is reassigned
- Memoization caches a function's results for a specific input so it won't be computed every time. This comes at the cost of increased memory overhead
- Ideal function parameter order goes from general to specific, so that functions can be specialized more easily
- Partial Application: Prespecifying arguments to get more specialized functions, e.g.
let getCustomer = R.partial(ajax, CUSTOMER_API);
- Currying: Defining functions so that passing in arguments returns reshaped or specialized functions, e.g.
let getCustomer = ajax(CUSTOMER_API)
. No presetting. Originally from Haskell, where all functions are curried - (Strictly) curried functions are a chained series of unary functions (single input, single output).
- Loosely curried functions can take multiple arguments for convenience's sake
Composition
- Composition: When the output of one function is set as the input to another function. When composing functions they are in right-to-left order, e.g.
R.compose(third, second, first)
as the right-most argument would be the most 'inner' function in a traditional composition - Piping: Like composition, but evaluated left-to-right, e.g.
R.pipe(first, second, third);
- Composition is associative, meaning that no matter how composed functions are grouped they will have the same result when composed.
- Example of composition with currying:
let sum = (x, y) => { return x + y; }
let triple = (x) => {return x * 3; }
let divBy = (y, x) => { return x / y; }
divBy(2, triple(sum(3, 5))); // Original statement
let sum = R.curry(sum); // Curry sum with arity of 2
let divBy = R.curry(divBy); // Curry divBy
R.compose( // Curried + Composed, unary version of statement
divBy(2),
triple,
sum(3)
)(5);
Immutability
- const prevents reassigned, not mutation
Object.freeze(obj)
make all properties of obj read-only (does not freeze nested objects). Most useful in signalling to users that obj shouldn't change. - When you write a function that receives data structures, work with it as if it was immutable
- Don't mutate, copy in your functions, e.g.
let processedOrder = { ...order };
- Return a new, processed version of the structure or just the data needed for the caller to apply the changes
- Don't mutate, copy in your functions, e.g.
- Libraries for structured mutation include immutable.js and Mori
Recursion
- Recursive functions are sometimes better off doing the work first and then deciding whether or not to return the base case
- Every call to a function creates a new scope to use, called a stack frame or memory frame. Recursion can become an issue when too many calls are made recursively, overflowing the stack.
- Tail calls solve this, as the previous stack frame can be thrown away. Put the recursive calls at the return statement, or the tail position
- Tail calls are only optimized when:
- You're in strict mode (
"use strict";
) - The call is in tail call position (after a return keyword, where the only thing left to do in the function is return the result of the call) OR a ternary operator with the function call is in tail call position
- Be running on WebKit (Safari)
- You're in strict mode (
- Trampolines: Alternative to tail call. Converts recursion into a while loop where the function keeps being called until it doesn't return a function and has the result we need. Example:
function trampoline(fn) {
return function trampolined(...args) {
let result = fn(...args);
while (typeof result == "function") {
result = result();
}
return result;
}
}
let countVowels = trampoline((count, str) => {
count += (isVowel(str[0] ? 1 : 0));
if (str.length <= 1) return count;
return function f() { // if tail calling becomes widely supported, simply unwrap this return
return countVowels(count, str.slice(1));
}
});
countVowels = R.curry(countVowels)(0);
let count = countVowels("eeeee"); // 5
List Operations
- Map: A transformation of a collection by which a specific conversion is performed on each item
- Functor: A mappable collection
- Filter: A transformation of a collection by which values are filtered in if they meet a specific condition
- Reduce: A transformation of a collection by which values are combined into a single value using a specific function that takes in an accumulator and one item of the collection. The accumulator may be set to an initial value.
- Reducing is a valid implementation of composition. The reducer would compose two functions and reduce over a collection of functions
- Fusion: Using a composed function as a map conversion
- Member functions like list.map() are impure, which is a problem as they are inherently uncomposable.
Transduction
- Transduction: Composition of reducers. Other list operations like map, filter can be reshaped into composable reducers
- A transducer is a prototypal shaped reduce which will become a reducer if passed a reducer.
- Example:
let transducer = R.compose(
R.map(add1),
R.filter(isOdd)
);
R.transduce(
transducer,
sum,
0,
[1, 2, 3, 4]
); // 10
R.into( // like transduce, but infers iterator function from the accumulator type. For numbers, the inferred function is sum
0,
transduce,
[1, 2, 3, 4]
); // 10
Data Structure Operations
- Monad: A pattern for pairing data with a set of predictable behaviours that let it interact with other data and behaviour pairings (monads)
- Ideally you're not supposed to be able to unwrap
- Example of a 'Just' monad (monad for a single value)
function Just (val) {
return { map, chain, ap };
function map(fn) {
return Just(fn(val));
}
// aka: bind, flatMap
function chain(fn) {
return fn(val); // if fn is identity (x => x) you can unwrap the value. this is impure, so only do it at the end of your functional operations
}
function ap(anotherMonad) { // When using ap you're assuming val was a function so it can be used with .map
return anotherMonad.map(val);
}
}
- flatMap: Maps over monad, flattening each value
- Why use a Monad? Monads have a clean and predictable way of working together
- 'Nothing' monad: map, ap and chain will all return Nothing monads
- 'Maybe' monad: Either holds a Just or Nothing monad. Creates a nullable value
Async
- Observable: Asynchronous streams of data that declaratively flow to each other.
- .map, .filter, .distinctUntilChanged etc will all return new observables like with monads, so FP concepts can be applied for async operations using observables
Digging into Node.js
Introduction
- Node was created by Ryan Dahl in 2009 as a way of building high-throughput and low-latency socket services for efficient network communications and initially used Ruby. After realising that he needed the concept of an 'event loop', he switched to JavaScript as it already had the concept built-in. Node.js was not created just to put JS in the backend, it was to build a model for efficient asynchronous I/O tasks.
- Node uses a POSIX-like interface for standard I/O. Consists of three streams:
- stdin
- stdout
- stderr
- JavaScript doesn't have a spec for I/O operations, node.js does.
process.stdout.write() // write directly to stdout
process.stderr.write() // write directly to stderr
process.stdin.read() // read directly from stdin. Don't use this, instead import a module for this or define an async adapter
console.log(); // efficient wrapper around process.stdout.write with formatted output
console.err(); // ditto for stderr
Command-Line Scripts
- When working in a linux-like environment, add the following to the first line of the script:
#~/usr/bin/env node
"use strict";
can be placed after this statement (shebang)
Argument Parsing
process.argv; // contains all arguments used to invoke the script
process.argv[0]; // path used to invoke node
process.argv[1]; // path of script
process.argv.slice(2) // will contain all other arguments
- Minimist: library enabling easy parsing of cli arguments
let minimist = require("minimist");
minimist(process.argv.slice(2));
/* Returns object like the following:
{ _: [], <parameter>: '<value>', <numericParam>: 0 }
_ key holds an array of all arguments minimist doesn't know how to store as parameter-value pairs
*/
minimist(process.argv.slice(2), {
boolean: ["help"], // Minimist will store help as a boolean flag
string: ["file"] // Minimist will store file as a string
}
Working with Files
let path = require('path'); // Resolves filepaths. Built into node.js
let filepath = path.resolve(file);
__dirname; // Magic variable in all node programs that holds directory currently executing script is held in
process.cwd(); // Returns current working directory of process
let fs = require('fs'); // File system module
let contents = fs.readFileSync(filepath); // Synchronously reads from file. Returns binary buffer by default, not string
fs.readFile(filepath, callback); // Asynchronously read file
Streams
More info at substack's Stream Handbook (archived)
- Simplex Streams: Unidirectional streams (either readable or writable)
- Duplex Streams: Readable and writable streams
readStream.pipe(writeStream) // Sets up a listener that will read readStream and pipe chunks into writeStream. Returns a readable stream
fs.createReadStream(file) // Returns a read stream of chunks from file
fs.createWriteStream(file) // Returns write stream for file
myStream.on("end", function() { // Wrap in promise to effectively determine end of stream
// Perform some resolution...
})
Transforms
let modifiedStream = new Transform({
transform(chunk, encoding, callback) {
this.push(chunk.toString().toUpperCase()); // chunk is a buffer from stream
callback(); // notify that transform is done
}
});
GZIP/ZLIB
let gzipStream = zlib.createGzip(); //create zipping stream
let zippedStream = origStream.pipe(gzipStream); // zip stream
let gunzipStream = zlib.createGunzip(); //create unzipping stream
let unzippedStream = zippedStream.pipe(gunzipStream); // zip stream
Databases
More info at https://github.com/TryGhost/node-sqlite3/wiki/API
- SQLite3 has the advantage of not requiring a separate DB program to run on your system, it's maintained directly by your application.
require("sqlite3");
require("util"); // inbuilt node module with many useful utility functions
let myDB = new sqlite3.Database(DB_PATH);
let initSQL = fs.readFileSync(DB_SQL_PATH, "utf-8");
SQL3 = {
run(...args) {
return new Promise(function c(resolve, reject) {
myDB.run(...args, function onResult(err) { // see below for description of run()
if (err) {
reject(err);
} else {
resolve(this);
}
});
});
},
// util.promisify(callback) will return a promise interface for a callback
get: util.promisify(myDB.get.bind(myDB)),
all: util.promisify(myDB.all.bind(myDB)),
exec: util.promisify(myDB.exec.bind(myDB))
}
/** FROM DOCS: **/
// all functions here return the DB object to allow function chaining!
await SQL3.get(sql [, param, ...] [, callback]); // executes sql query and calls callback function on first result row
await SQL3.all(sql, params, (err, row) => {}); // executes sql query and calls callback on all rows in result set.
// Places all rows in memory so each() is better for large DBs
await SQL3.exec(sql [, callback(err)]) // executes sql query
// can init DB with stored SQL by passing in string/buffer from read file
// callback function executed when execution is complete or error has occurred
myDB.run(sql [, param, ...] [, callback]) // exec, but sql statement can use placeholders passed in as params
Web Servers
More info at https://nodejs.org/api/http.html
const http = require('http');
const server = http.createServer([options][, requestListener]); // Instantiates a http.Server
const requestListener = (req, res) => { //expected callback format
req //http.IncomingMessage object
// can reroute through conditional execution based on req.url
res //http:ServerResponse object. call methods to customize server response
res.writeHead(...) // to write header
res.write(...) // to write content
res.end() // closes res stream
}
server.listen(port) // must be called to start listening
// will run until halted through signal
Routing and Serving Static Files
More info at https://https://github.com/cloudhead/node-static
const fileServer = new require('node-static').Server( // with no arguments will serve files in current directory
'./myfolder', // pass string to serve files in specific directory
{cache: 3600}, // specify how long client is supposed to cache files in seconds
// sets Cache-Control header
);
const server = require('http').createServer(function(request, response) {
request.addListener('end', function() { // listen for end of a request stream using emitter.addListener(eventName, listener)
fileServer.serve(request, response, function (e, res) {
if (e && (e.status === 404)) { // If the file wasn't found
fileServer.serveFile( // Serve an error page
'/not-found.html', 404, {}, request, response
);
}
})
}).resume(); // to consume event and keep listening
}).listen(port);
Serving an API Endpoint
async function handleRequest(req, res) {
if (req.url == "/my-api-endpoint") {
let toServe = getMyServable();
res.writeHead(200, {
"Content-Type": "application/json",
"Cache-Control": "no-cache"
})
res.end(JSON.stringify(toServe));
} else {
// code for your non-API serves..
}
}
Express.js Routing and Serving
More info at https://expressjs.com/en/5x/api.html
- Middleware: Function that gets called if incoming request matches some criteria you've set
const express = require("express");
const app = express(); // app is an automatically generated handleRequest function
const httpServer = http.createServer(app);
function defineRoutes() {
app.get(urlPath, callback [,callback ...]); // middleware mounter. route HTTP GETs to specified path with middleware callbacks
app.use([path,] callback [, callback...]) // middleware mounter for all incoming requests, not just get
//path defaults to "/"
}
- Note that middleware functions are executed sequentially, so if two middleware mounters use the same path only the first middleware will be used unless
next()
is called within the middleware function.
Child Processes
const childProc = require("child_process"); // node builtin
const child = childProc.spawn("node", [ "my-script.js"]);
child.on("exit", function(exitCode)) { // On child exit, check exit code
console.log("Child finished", exitCode)
}
/* in child */
process.exitCode = -1 // to set exit code
return
Debugging Node.js
- In Chrome open up chrome://inspect, listens on port 9229
- Run node with --inspect to broadcast on port 9229
JavaScript: The Recent Parts
Strings
- Template Strings / Interpolated Literals: Defines a string that uses variables. Denoted with backticks. Variables are wrapped in
${}
. e.g.\`Your name is ${name}.\
- Tagged Templates allow you to parse template literals with a function, e.g.
const person = "Mike";
const age = 28;
function myTag(strings, personExp, ageExp) { // Note that
const ageStr = ageExp < 100 ? "youngster" : "centenarian";
return `${strings[0]}${personExp}${strings[1]}${ageStr}${strings[2]}`;
}
const output myTag`That ${person} is a ${age}.`;
// That Mike is a youngster
- Left and right padding/trimming are deprecated due to the difficulty of working with both left-to-right and right-to-left languages. Use:
str.padStart(<targetLength>[, <padString>])
- padString is the string to pad the currentstr
with. If it's too long to stay within the target length it will be truncated from the end.str.padEnd(<targetLength>[, <padString>])
str.trim()
str.trimStart()
str.trimEnd()
Array Destructuring
- Destructuring: Decomposing a structure into its individual parts. Describes to JavaScript declaratively how it should break down the structure of a statement on the right-hand side of an assignment and how it should assign them. The pattern does not have to match the entirety of the value, just the parts we care about.
- Default values can be set in any position of a destructuring statement via assignment. Only kicks in when the value is undefined (via strict equality).
- gather/rest operator can be used to gather the rest of the values.
- the destructured data structure can also be stored via a chained equals.
- Destructuring is about assignments, not declaration. Variables used in destructuring can be defined ahead of time and then reassigned.
- Any valid left-hand side target can be used in array destructuring. Includes object properties.
- Data can be ignored by using a separating comma
function data() {
return [1, 2, 3, 4, 5];
}
let [
first, //1
second = 10, // 2. if data()[1] is undefined, second would be 10
third, // 3
, // fourth value from data() is ignored
...fifth // [5]
] = tmpData = data() || []; // if data() fails, use an empty array for destructuring
Object Destructuring
- Object destructuring is done in mostly the same way as array destructuring except variables are assigned via property names, not positions
- Unless you're declaring variables while destructuring you need to wrap the assignment in parentheses to ensure JavaScript doesn't see it as a code block
- You can destructure from the same source property multiple times
function data() {
return {a: 1, b: 2, c: [3, 2], d: 4 };
}
let {
a: first,
c: third = 10,
c: [
third_0,
third_1
] = []
b: second
...fourth
} = data();
- The object destructuring syntax can be used in a function signature to define named arguments for the function. Arguments should be named according to a discussed convention that is consistent across a project (e.g.
arr
for arrays,cb
for callbacks). - Destructuring and Restructuring: Extend your objects by creating a function that takes in an object and returns an object with additional properties. Lets you have a logical place for default options, e.g.:
function ajaxOptions({
url = "http://url/api",
method = "post",
data,
callback,
headers: [
headers0 = "Content-Type: text/plain",
...otherHeaders
] = []
} = {}) {
return {
url, method, data, callback,
headers: [
headers0,
...otherHeaders
]
}
}
Array Methods
arr.find(function(item[, index[, array]]) {
// function is called for each element of the array
// if function returns truthy value for an item, returns item
// if function doesn't find a suitable item, returns undefined
})
arr.findLast(/* same syntax as above */)
arr.findIndex(function(item[, index[, array]]) {
//if truthy value is returned, index is returned
// if function doesn't find a suitable item, returns -1
})
arr.findLastIndex(/* same syntax as above */)
arr.includes(value[, from]) // returns true if value exists in array (search starting at 'from' index)
arr.flat(depth) // if arr is nested, creates a new value that is 'unnested' to a specified level
arr.flatMap(fn) // equivalent to arr.map(fn).flat(1)
Iterators and Generators
iterable[Symbol.iterator]()
creates an iterator from an iterable object like arrays, strings, etc.iterator.next()
will return an object with two properties - value (undefined if iteration is done) and done (boolean indicating iteration status)
- Adding a
[Symbol.iterator]
method to a data structure allows usage of JavaScript features that consume iterators (e.g.for..of
loops,...spread
operator,Array.from()
, etc). Declarative example using generators:
let obj = {
a: 1,
b: 2
*[Symbol.iterator]() {
for (let key of Object.keys(this)) {
yield this[key];
}
}
}
Regular Expressions
-
Lookahead: Regex match assert modifier. A match will only fire if something immediately after also matches. Types:
(l.)(?=o)
- Positive lookahead. Matches an l and any character so long as an o is after those two characters(l.)(?!o)
- Negative lookahead. Matches an l and any character so long as an o is not after those two characters
-
Lookbehind: Another match assert modifier. Matches only fire if something immediately behind also matches. Types:
(?<=e)(l.)
- Positive lookbehind. Matches an l and any character so long as they are preceded by an e.(?<!e)(l.)
- Negative lookbehind. Matches an l and any character so long as they are not preceded by an e.
-
Named capture groups: To name a capture group use the angle-bracket syntax
(?<name>l.)
.msg.match(...).groups()
to retrieve named capturing groups as properties in an object\k<name>
to reference a capture group in a lookahead/behind$<name>
to reference captured group in a regex replace call.
-
Adding
/s
to the end of your regex turns on dotall mode, allowing the.
character to match all characters including newlines.