Created
January 17, 2023 05:21
-
-
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).
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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