Modules and the Development/Deploy Pipeline
SPAs consist of many JS and HTML templates which isn’t ideal in terms of resources and latency:
- Downloading many small files has overhead
- Data that is not needed at runtime is included (e.g. un-minified code)
- May not be written in JS (e.g. TypeScript, Dart)
Bundling is the process of taking the source code and optimizing it into a format that is better for the browser to consume.
In its simplest form, bundling simply concatenates all JS files into one big file (and the same thing for CSS) - tools such as Gulp follow this pattern.
Minification
The build process will also often minify (or Uglify) the JS by removing functions, whitespace, reducing function/variable names (except global function names cannot be renamed) etc.
To aid in debugging, sourcemaps can be made so that the dev tools can present the code as it originally was.
Webpack
Current best practice. To configure webpack, simply point it to the source directory and the app starting point - it will then recursively follow imports, ensuring that only deadcode is removed.
The output is something like main.${hash}.js.
Webpack supports TypeScript and other transpiled languages.
Lazy Loading
Breaks the SPA into logical chunks so that the entire JS code does not need to be downloaded at the start.
Framework and bundler need to agree on how to split the import graph - usually with bundler plugins.
To do it natively in Webpack:
// Instead of
import Component from "/Component";
// Use
const Component = () => import("./Component");
Compression
To reduce network transfer (but not the cost of parsing the JS), use a compression algorithm such as gzip. There is a small CPU cost to decompress, but this is small on modern machines.
In the HTTP request, the browser adds a header indicating the encodings it supports e.g. accept-encoding: gzip, deflate, br. Then, the server can optionally encode it in one of the given options, indicating this using content-encoding: gzip.
Caching
Can add HTTP headers to allow the browser to cache content.
expires:
- Expires on specific date:
expires: DoW, DD MMM YYYY HH:MM:SS GMT
cache-control:
no-store: don’t cache (unless already cached)max-age=${time_in_seconds}: expires after some durationmust-revalidate max-age=${time_in_seconds}: revalidate with server aftermax-age, use cached version otherwiseno-cache: cache, but always revalidate with server- Send
ETag: ${some_long_number}on first request - Send
If-None-Match: ${etag}for subsequent requests- If 304 Not Modified, use cached value
- Alternatively,
Last-ModifiedandIf-Unmodified-Since
- Send
ETag caching
On first request, server responds with ETag: ${some_long_number} and cache-control: no-cache.
On subsequent requests, browser adds If-None-Match: ${etag}; if the ETag matches the current version, the server returns a 304 not modified.
This requires a round-trip and the server to be available, but allows for changes to instantly propagate to all clients.
Caching in a SPA
index.html: use a cache that suits the release cadence. Probably an ETag.
All other file names contain their hash, so you can set cache-control: max-age=315360000 (one year).
Modules
Originally, JS functions existed in the global namespace and had no way of partitioning code. Hence, there was no way to protect module-internal symbols.
Modules solve the problem but… it was solved multiple times:
- ES2015 (now the official standard)
- CommonJS
- AMD (Asynchronous Module Definition)
- JS module pattern: `function(…) { /your entire module here/ }()
ES2015
ES2015 is:
- Part of the official ECMAScript2015 standard
- Well-supported natively or with polyfills
// lib.js
export default export0;
export const export1;
export const export2;
// main.js
// Import specific components
import theDefaultExport, { export1, export2 as alias } from "lib";
// To import anything
import * as lib from "lib";
// Run global code but don't import anything
import "lib";
CommonJS
CommonJS was adopted early on by NodeJS:
// lib.js
exports.export1 = "bla";
// main.js
const lib = require("./lib.js");
The DOM
JS can dynamically change any element in the DOM as well as their properties, including their CSS styles.
The DOM can become excessively large in some applications, with performance being hurt when a large number of nodes need to be modified.
Virtual DOM
The virtual DOM, popularized by React, is an abstraction of the DOM that exists in the JS. The virtual DOM is first modified, and the real DOM is updated only if required. Because repainting is a very expensive operation so by batching changes, it can lead to increased performance.
Hence, when using libraries such as React and Vue, you do not work directly with the DOM.
In order to update the virtual DOM, it needs to know when the state has changed. To do this it has two main methods:
- Dirty checking: poll the data at regular intervals to recursively check the data structure
- Observables: use observable data objects so that it is notified on changes
When the state is updated, it needs to compute the diff so that only the required DOM nodes are updated.
DOM-based XSS Injection
XSS: when a malicious script is run on a trusted side, getting full access to the DOM and cookies.
Hence, when manipulating the DOM, care must be taken to ensure that untrusted data is only ever treated as displayable text, not executed as code. This can be done by avoiding dangerous functions such as eval, innerHTML. Sanitizing the input data is difficult as some payloads can take advantage of browsers helpfully ‘fixing’ malformed HTML that gets through the sanitizer.
Performance
Response Times: 3 Important Limits
100 milliseconds: the limit for having the user feel the system is reacting instantaneously
1 seconds: the limit for the user’s flow of thought staying uninterrupted
10 seconds: the limit for keeping the user’s attention focused on the program
Yahoo Performance Rules
- Minimize HTTP requests; CSS sprites (NB: horizontal over vertical layout reduces file size), image maps etc.
- Use CDNs; closer is better
- Cache resources
- Use Gzip
- Put stylesheets in the head; this makes it seem faster
- Defer script loading - they can block parallel downloads from the same origin
- Make JS/CSS external
- Reduce DNS lookups; the number of unique hostnames used
- Minify JS/CSS
- Avoid redirects; each adds another round-trip
- Remove duplicate scripts
- Use ETags
- Cache and optimize AJAX requests
- Flush the buffer early; start sending before the full page loads (e.g. flush once after the head)
- Use GET over POST; headers and data are sent separately
- Post-load non-critical components
- Preload components - make use of idle time
- Reduce the number of DOM elements
- Reduce the number of
iframes - No 404s - they still download the body
- Reduce cookie size
- Minimize DOM access - create detached trees first before adding it to the DOM
- Reduce the number of event handlers - events bubble up so attach it to a parent element, then alter behavior depending on the target
- Optimize images; don’t scale in HTML
- Avoid empty
<img src="">- some browsers may make a request to the page or its directory
Lazy Loading with Intersection Observer
Intersection Observer API: register an element and a callback is executed when the element enters/exits another element, the viewport, or when the amount of intersection changes enough.
const observer = new IntersectionObserver(callback, {
root: document.querySelector("#elementToObserve"),
rootMargin: "0px 0px 0px 0px", // margin surrounding the root element to grow/shrink its bounding box
threshold: 0.5 // 0.0 means if a single pixel is visible, the target is visible. 1.0 means the entire element must be visible
})
Progressive Web Apps
Web apps that appear to be ‘installed’ like native applications.
Service workers allow notifications and background sync (offline cache). Service workers:
- Act as proxy servers sitting between the app and the network
- Run on their own thread
- Are headless: cannot access the DOM
- Require HTTPS
- Associated with a specific server/website