Skip to content

Instantly share code, notes, and snippets.

@chipx86
Created January 17, 2023 05:21
Show Gist options
  • Save chipx86/539adeedf577d12bc57204f0335c2fe4 to your computer and use it in GitHub Desktop.
Save chipx86/539adeedf577d12bc57204f0335c2fe4 to your computer and use it in GitHub Desktop.
Example code for implementing deferred object init for ES6 classes (useful for Backbone).
/*
* ES6 classes have what may be considered unexpected behavior when
* working with constructors. A parent class's constructor can invoke
* a method in a subclass, but that method can't access its class's
* own variables.
*
* For example, this fails:
*
* class Parent {
* // To be provided by subclasses.
* id = '';
*
* constructor() {
* this.setupState();
* }
*
* setupState() {
* registerThing(this.id, this);
* }
* }
*
* class Child extends Parent {
* id = 'my-id';
* }
*
* In this example, Child.id won't be set until its own constructor
* finishes, which is guaranteed to be _after_ Parent's finishes.
* So, we have an initialization order issue.
*
* It's important instead to invoke any post-creation setup after
* the constructor chain finishes. The Child class can do it, but
* what if there's a Grandchild class? Then _it_ should be the one
* that invokes it.
*
* This is a problem for us when using prototype-based libraries like
* Backbone.js, which assumes it can manage object initialization.
* Prototypes don't have the same restriction.
*
* The example code here shows a pattern for this. It requires
* wrapping the classes to set up these light-weight intermediary
* classes that can provide special constructors to short-circuit any
* default constructor behavior and to add an object initialization
* step when the target object is instantiated.
*
* This can be made cleaner with decorators when those become standard.
* (Conceivably it can be done today, if you're willing to take the
* TypeScript/Babel decorator transpiling code injection hit).
*/
/* An internal symbol used to flag a deferred construction path. */
const _constructing = Symbol();
/*
* Create a base class supporting deferred object initialization.
*
* This sets up a base class to allow for deferred object initialization.
* It provides an `initObject()` method that handles the object initialization,
* which will be invoked by a subclass (when utilizing `setupSubclass()`) to
* perform any initialization required.
*
* If making a base class off of a prototype-based class, this will default to
* invoking the constructor. Since ES6 class constructors can't be invoked
* manually, this will require an explicit `options.initObject` to either
* be passed in or defined on the provided class.
*
* By default, object initialization just invokes the parent class's
* constructor. This can be overridden.
*
* The resulting class can't be instantiated directly, and will raise a
* TypeError if attempted. It must be subclassed.
*
*/
function makeBaseClass(baseClass, options={}) {
const props = Object.getOwnPropertyDescriptor(baseClass, 'prototype');
const isClass = !props.writable;
const initObject = options.initObject ||
baseClass.prototype.initObject ||
(isClass ? function() {} : baseClass);
const name = options.name || baseClass.name;
return {[name]: class extends baseClass {
constructor(arg1) {
if (arg1 === _constructing) {
/* Skip calling super(). */
return Object.create(new.target.prototype);
} else {
throw TypeError(
`${name} is abstract and cannot be instantiated directly.`
);
/* Satisfy lint checks. This doesn't actually get executed. */
super();
}
}
initObject() {
initObject.apply(this, arguments);
}
}}[name];
}
/*
* Set up a subclass of a base class.
*
* This is used for any subclasses of a class constructed using
* `makeBaseClass()`. It will ensure that object initialization only happens
* once the entire constructor chain has finished.
*
* The result is an intermediary class that has custom constructor logic.
* All other attribute accesses fall back to the wrapped class.
*/
function setupSubclass(cls) {
const name = `_${cls.name}_`;
const d = {[name]: class extends cls {
constructor(...args) {
if (new.target === d[name]) {
/*
* We're constructing the desired object. Return an instance
* of the actual class, rather than this intermediary class.
*/
const obj = new cls(_constructing, ...args);
if (obj.initObject) {
obj.initObject(...args);
}
return obj;
}
super(_constructing);
}
}};
return d[name];
}
/*
* Let's create our new base class for Backbone.Model.
*
* This is going to ensure that Backbone.Model's constructor doesn't call
* its initialization code in the constructor, letting us defer until the
* entire constructor chain has finished.
*/
export const BackboneModel = makeBaseClass(Backbone.Model, {
name: 'BackboneModel',
});
/* Alternatively: */
export const BackboneModel2 = makeBaseClass(
class BackboneModel extends Backbone.Model {
initObject(...args) {
Backbone.Model.apply(this, args);
}
}
);
/*
* Now let's set up some test subclasses.
*/
export const MyModel = setupSubclass(class MyModel extends BackboneModel {
static MY_CONST = 1234;
bar = {
abc: 123,
}
defaults = {
myAttr: 'myvalue',
}
#priv = 's3cr3t';
initialize() {
console.log('initialize');
console.log('foo = [%o]', this.bar);
console.log('secret = [%o]', this.#priv);
this.myFunc();
}
myFunc() {
console.log('base myFunc');
}
});
export const MyModel2 = setupSubclass(class MyModel2 extends MyModel {
baz = 123;
myFunc() {
console.log('sub myFunc: %o', this.bar, this.baz);
}
});
/*
* This gives us an unwrapped MyModel. Class hierarchy is:
*
* MyModel -> BackboneModel -> Backbone.Model
*/
const myModel = new MyModel({a: 1, b: 2});
console.assert(myModel.bar && myModel.bar.abc === 123);
console.assert(myModel.attributes.a === 1);
console.assert(myModel.attributes.b === 2);
console.assert(MyModel.MY_CONST === 1234);
/*
* This gives us an unwrapped MyModel2. Class hierarchy:
*
* MyModel2 -> _MyModel_ -> MyModel -> BackboneModel -> Backbone.Model
*/
const myModel2 = new MyModel2({a: 1, b: 2});
console.assert(myModel2.bar && myModel.bar.abc === 123);
console.assert(myModel2.baz === 123);
console.assert(myModel2.attributes.a === 1);
console.assert(myModel2.attributes.b === 2);
console.assert(MyModel2.MY_CONST === 1234);
/* Here's an example of the issue documented in the description above. */
const Parent = makeBaseClass(class Parent {
id = '';
initObject() {
this.setupState();
}
setupState() {
console.log('ID = %s', this.id);
}
});
const Child = setupSubclass(class Child extends Parent {
id = 'my-id';
});
/* This will output the correct ID to console when run. */
new Child();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment