-
Notifications
You must be signed in to change notification settings - Fork 421
JS Modernization Plan Notes
There are a few small holes left for us to do in modernizing the codebase. Our es6 score over 2021 did cross 1.0 🎉
The score is computed by the following checks. The way this list works, is a JS file starts with score 0.0 . For every sub item it meets (e.g. Using const/let), it wins points. If it wins all the es5 points, then and only then does it cross the score 1.0 threshold, and can start winning points for es6. Note each point is further explained below.
-
score 0.0: ES5
-
+0.2: Uses
const
/let
instead ofvar
-
+0.2: Uses
class
instead ofprototype
manipulation -
+0.2: Uses arrow functions (
() => {}
) instead ofself = this
-
+0.2: No globals: uses
import
/export
-
+0.2: Uses
async
/await
instead of.then
where appropriate
-
+0.2: Uses
-
Score 1.0: ES Modern
-
+0.5: Uses class fields instead of
this.foo = '...'
where possible -
+0.5: Uses decorators where appropriate (namely lit's
@property
)
-
+0.5: Uses class fields instead of
- Score 2.0: ES Future 🎉
Goal for the year is to get every individual file at 2.0.
In detail of what each of these means, and why it's important:
Besides better communicating and enforcing when a variable is read-only, const
and let
are also scope specific. So a const
/let
defined variable is only accessible from the current scope. E.g.
const GLOBAL = 'foo';
if (3 > 0) {
const x = 'blah';
const f = function() {
// Accessible in child scopes
console.log(x)
}
console.log(f());
} else {
// ERROR: No 'x' here!
console.log(x);
// But GLOBAL is accessible in child scopes
console.log(GLOBAL);
}
// ERROR: And no 'x' here, either!
Being able to know which variables apply where, and when they’re no longer accessible, avoids a number of bugs and means you have to hold less in your head while you’re coding.
BookReader is largely driven by prototype manipulation. Using classes lets us and our code editor know earlier what methods a class should have, providing better autocomplete.
The biggest examples of these are the BookReader plugins. They largely modify BookReader's prototype directly. To avoid this, new plugins (like the TextSelection plugin) use a pattern like this:
export const DEFAULT_OPTIONS = {
enabled: true,
/** @type {StringWithVars} The URL to fetch the entire DJVU xml. Supports options.vars */
fullDjvuXmlUrl: null,
/** @type {StringWithVars} The URL to fetch a single page of the DJVU xml. Supports options.vars. Also has {{pageIndex}} */
singlePageDjvuXmlUrl: null,
};
class TextSelectionPlugin {
constructor(options) {
this.options = options;
}
customMethod() { /** ... */ }
}
export class BookReaderWithTextSelectionPlugin extends window.BookReader {
init() {
const options = Object.assign({}, DEFAULT_OPTIONS, this.options.plugins.textSelection);
if (options.enabled) {
this.textSelectionPlugin = new TextSelectionPlugin(options, this.options.vars);
// Write this back; this way the plugin is the source of truth, and BR just
// contains a reference to it.
this.options.plugins.textSelection = options;
this.textSelectionPlugin.init();
}
super.init();
}
}
window.BookReader = BookReaderWithTextSelectionPlugin;
Whereas previously you might something like, which you can see in many of the older plugins:
window.BookReader.prototype.init = (function (super_) {
return function () {
super_.call(this);
if (this.options.enableTextSelection) {
this.textSelEndpoint = this.options.singlePageDjvuXmlUrl;
}
};
})(BookReader.prototype.init);
window.BookReader.prototype.textSelCustomMethod = function() { /** ... */ };
This approach lets us:
- use a separate class for all TextSelectionPlugin's internal state/methods and not muddy BookReader's.
- Extend any BookReader core methods that might need to be expanded.
- Expose
.textSelectionPlugin
on BookReader for easy access. - Avoid all the es5 inheritance goop.
- Avoid prefixing all the methods.
- Avoid having access to all of BookReader's internals throughout the entire plugin.
- Avoid all the internal state being attached to/bloating BookReader.
Longer term we want to have a more registration based plugin architecture, but this is a strong step in that direction.
Arrow functions let us avoid having to call .bind
to keep the this
from changing. For example:
class BookReader {
constructor() {
this.loaded = false;
}
init() {
// OLD:
// jQuery (and many addEventListener calls) change the `this` object!
// This will silently set the wrong variable, meaning you
// will now have to spend a bunch of time debugging a run-
// time issue 😭
$(function() { this.loaded = true; });
// In the past you would fix this by doing one of:
// A: By binding `this` from the outer scope, we force the
// `this` inside the function to be the bookreader instance
$(function() { this.loaded = true; }.bind(this));
// B: Here we use closures--the fact that in JS a function
// has access to the variables defined in its parent scope
// even when called--to pass this forward under the name `self`.
const self = this;
$(function() { self.loaded = true; });
// NEW: Arrow functions are essentially equivalent to adding
// .bind(this) at the end; it'll always use the same `this`
// as the scope it was defined in!
$(() => { this.loaded = true; })
}
}
Some more examples. Note we’re using class properties here to define our functions.
class Foo {
x = 3;
// This behaves similarly to setting `this.f = () => { ... };`
// in the constructor
f = () => this.x;
// This behaves similarly to Foo.prototype.g = function() { ... };
g() { return this.x; }
}
f = new Foo()
//> Object { x: 3, f: f() }
f.g.bind({x:7})()
//> 7
f.g.call({x:7})
//> 7
f.f.call({x:7})
//> 3
Also worth noting is the short-forms of arrow functions:
- Short form:
[1,2,3].map(x => x + 1)
– Long form:[1,2,3].map((x) => { return x + 1; })
- Note we don’t have
()
around thex
; this short-hand is allowed if we only care about one parameter - Note we don’t have
{}
around the function body,x + 1
; note the implicitreturn
when using this form.
- Note we don’t have
In the past everything was global. Currently in BookReader, the only global we have is BookReader
the class and its relationship to plugins. This should be addressed by whatever we decided our plugin architecture should be. This requirement is otherwise already completed.
Ahhh, promises! You might be used to doing things like this with promises:
function() {
return fetch('some url')
.then(r => r.json())
.then(json => json.summary.counts)
}
With async
/await
, we can do:
async function() {
const json = await fetch('some url').then(r => r.json());
return json.summary.counts;
}
Benefits include:
- Ability to easily chain async calls! No more recursive chaining of
.then
's.
// Before
urls.reduce(
(promise, url) => {
return promise
.then(() => someAsyncFunction(url))
.then(result => {
// Do something with the result
});
},
Promise.resolve()
);
// After
for (const url of urls) {
const result = await someAsyncFunction(url);
// Do something with the result
}
- Less nesting!
- Less knowledge of promise internals required! You don’t need to know that the parameter of the
.then
is the return of the previous promise’s.then
- Easier to read; using
async
/await
gives us more opportunities to create useful/readable variable names throughout a computation -
throw
/catch
work normally; no need to use.catch
methods - Easier forking; trying to fork a promise chain with
.then
results in convoluted code.Async
/await
cleans that up, because now we can just usif
/else
like normal.
// Before (can you spot the bug?)
let canSave = false;
isLoggedIn()
.then(loggedIn => loggedIn || askToLogin())
.then(loggedIn => {
canSave = loggedIn;
return validateResult();
})
.then(valid => {
if (valid) {
return save();
}
});
// After
if ((await isLoggedIn() || await askToLogin()) && await validateResult()) {
await saveResult();
}
Class fields (also known as class properties) allow us to define properties separately at the top of a class instead of intermingled inside the constructor.
// Before
class Foo {
constructor(br) {
this.initialized = false;
this.br = br;
}
}
// After
class Foo {
initialized = false;
br;
constructor(br) {
this.br = br;
}
}
The code quality benefits of this aren’t too big, but it does make it more explicit where things are set. And it also opens the door for lit-style decorators, which are very helpful when working with lit.
Note you can have complicated values on the right hand side of a class property! The right hand side of the assignment is computed during construction, not during the class definition. That’s why we can reference this.x
here:
class Foo {
x = 4;
xSquared = this.x * this.x;
_handleClick() { ... }
handleClick = debounce(this.handleClick)
}
WARNING! This is still an ECMAScript proposal, it could be scrapped. But Lit basically requires decorators for any sort of sane development flow, so we consider it necessary. See
// Before
export class IABookReader extends LitElement {
static get properties() {
return {
item: { type: Object },
baseHost: { type: String },
signedIn: { type: Boolean },
};
}
constructor() {
super();
this.item = undefined;
this.bookreader = undefined;
this.baseHost = 'https://archive.org';
this.fullscreen = false;
this.signedIn = false;
}
}
// After
export class IABookReader extends LitElement {
@property({type: Object})
item;
/** @type {BookReader} */
bookreader;
@property({type: String})
baseHost = 'https://archive.org';
@property({ type: Boolean, reflect: true, attribute: true })
fullscreen = false;
@property({ type: Boolean })
signedIn = false;
}
The big benefits of this is the properties are all in one place now, and do not need to be written twice, in static properties and in the constructor.