In diesem Kapitel untersuchen wir mehrere Ansätze zur Erstellung von Klasseninstanzen: Konstruktoren, Factory-Funktionen usw. Wir tun dies, indem wir ein konkretes Problem mehrmals lösen. Der Fokus dieses Kapitels liegt auf Klassen, weshalb Alternativen zu Klassen ignoriert werden.
Die folgende Container-Klasse soll den Inhalt ihrer Property .data asynchron empfangen. Dies ist unser erster Versuch
class DataContainer {
#data; // (A)
constructor() {
Promise.resolve('downloaded')
.then(data => this.#data = data); // (B)
}
getData() {
return 'DATA: '+this.#data; // (C)
}
}Hauptproblem dieses Codes: Die Property .data ist anfänglich undefined.
const dc = new DataContainer();
assert.equal(dc.getData(), 'DATA: undefined');
setTimeout(() => assert.equal(
dc.getData(), 'DATA: downloaded'), 0);In Zeile A deklarieren wir das private Feld .#data, das wir in Zeile B und Zeile C verwenden.
Das Promise innerhalb des Konstruktors von DataContainer wird asynchron aufgelöst, weshalb wir den endgültigen Wert von .data erst sehen können, wenn wir die aktuelle Aufgabe beenden und eine neue starten, über setTimeout(). Anders ausgedrückt, die Instanz von DataContainer ist noch nicht vollständig initialisiert, wenn wir sie zum ersten Mal sehen.
Was wäre, wenn wir den Zugriff auf die Instanz von DataContainer verzögern, bis sie vollständig initialisiert ist? Dies können wir erreichen, indem wir einen Promise vom Konstruktor zurückgeben. Standardmäßig gibt ein Konstruktor eine neue Instanz der Klasse zurück, zu der er gehört. Wir können dies überschreiben, indem wir explizit ein Objekt zurückgeben
class DataContainer {
#data;
constructor() {
return Promise.resolve('downloaded')
.then(data => {
this.#data = data;
return this; // (A)
});
}
getData() {
return 'DATA: '+this.#data;
}
}
new DataContainer()
.then(dc => assert.equal( // (B)
dc.getData(), 'DATA: downloaded'));Jetzt müssen wir warten, bis wir auf unsere Instanz zugreifen können (Zeile B). Sie wird uns übergeben, nachdem die Daten „heruntergeladen“ wurden (Zeile A). Es gibt zwei mögliche Fehlerquellen in diesem Code
.then()-Callbacks ausgelöst werden.In beiden Fällen werden die Fehler zu Ablehnungen des Promises, das vom Konstruktor zurückgegeben wird.
Vor- und Nachteile
DataContainer zu erstellen.Anstatt die Promise API direkt zu verwenden, um das vom Konstruktor zurückgegebene Promise zu erstellen, können wir auch eine asynchrone Pfeilfunktion verwenden, die wir sofort aufrufen
constructor() {
return (async () => {
this.#data = await Promise.resolve('downloaded');
return this;
})();
}Eine statische Factory-Methode einer Klasse C erstellt Instanzen von C und ist eine Alternative zur Verwendung von new C(). Gängige Namen für statische Factory-Methoden in JavaScript
.create(): Erstellt eine neue Instanz. Beispiel: Object.create().from(): Erstellt eine neue Instanz basierend auf einem anderen Objekt, indem es kopiert und/oder konvertiert wird. Beispiel: Array.from().of(): Erstellt eine neue Instanz durch Zusammenstellung von über Argumente angegebenen Werten. Beispiel: Array.of()Im folgenden Beispiel ist DataContainer.create() eine statische Factory-Methode. Sie gibt Promises für Instanzen von DataContainer zurück
class DataContainer {
#data;
static async create() {
const data = await Promise.resolve('downloaded');
return new this(data);
}
constructor(data) {
this.#data = data;
}
getData() {
return 'DATA: '+this.#data;
}
}
DataContainer.create()
.then(dc => assert.equal(
dc.getData(), 'DATA: downloaded'));Diesmal ist die gesamte asynchrone Funktionalität in .create() enthalten, was den Rest der Klasse vollständig synchron und damit einfacher macht.
Vor- und Nachteile
new DataContainer() zu erstellen.Wenn wir sicherstellen wollen, dass Instanzen immer korrekt eingerichtet sind, müssen wir sicherstellen, dass nur DataContainer.create() den Konstruktor von DataContainer aufrufen kann. Dies können wir über ein geheimes Token erreichen
const secretToken = Symbol('secretToken');
class DataContainer {
#data;
static async create() {
const data = await Promise.resolve('downloaded');
return new this(secretToken, data);
}
constructor(token, data) {
if (token !== secretToken) {
throw new Error('Constructor is private');
}
this.#data = data;
}
getData() {
return 'DATA: '+this.#data;
}
}
DataContainer.create()
.then(dc => assert.equal(
dc.getData(), 'DATA: downloaded'));Wenn secretToken und DataContainer im selben Modul liegen und nur letzteres exportiert wird, dann haben externe Parteien keinen Zugriff auf secretToken und können daher keine Instanzen von DataContainer erstellen.
Vor- und Nachteile
Die folgende Variante unserer Lösung deaktiviert den Konstruktor von DataContainer und verwendet einen Trick, um Instanzen davon auf andere Weise zu erstellen (Zeile A)
class DataContainer {
static async create() {
const data = await Promise.resolve('downloaded');
return Object.create(this.prototype)._init(data); // (A)
}
constructor() {
throw new Error('Constructor is private');
}
_init(data) {
this._data = data;
return this;
}
getData() {
return 'DATA: '+this._data;
}
}
DataContainer.create()
.then(dc => {
assert.equal(dc instanceof DataContainer, true); // (B)
assert.equal(
dc.getData(), 'DATA: downloaded');
});Intern ist eine Instanz von DataContainer jedes Objekt, dessen Prototyp DataContainer.prototype ist. Deshalb können wir Instanzen über Object.create() erstellen (Zeile A) und deshalb funktioniert instanceof in Zeile B.
Vor- und Nachteile
instanceof funktioniert.Object.create() auch für unsere früheren Lösungen verwendet werden kann.DataContainer verwenden, da diese nur für Instanzen korrekt eingerichtet sind, die über den Konstruktor erstellt wurden.Eine weitere, umständlichere Variante ist, dass Instanzen standardmäßig über das Flag .#active ausgeschaltet sind. Die Initialisierungsmethode .#init(), die sie einschaltet, kann nicht extern aufgerufen werden, aber Data.container() kann sie aufrufen
class DataContainer {
#data;
static async create() {
const data = await Promise.resolve('downloaded');
return new this().#init(data);
}
#active = false;
constructor() {
}
#init(data) {
this.#active = true;
this.#data = data;
return this;
}
getData() {
this.#check();
return 'DATA: '+this.#data;
}
#check() {
if (!this.#active) {
throw new Error('Not created by factory');
}
}
}
DataContainer.create()
.then(dc => assert.equal(
dc.getData(), 'DATA: downloaded'));Das Flag .#active wird über die private Methode .#check() erzwungen, die am Anfang jeder Methode aufgerufen werden muss.
Der Hauptnachteil dieser Lösung ist ihre Ausführlichkeit. Es besteht auch die Gefahr, dass man vergisst, .#check() in jeder Methode aufzurufen.
Der Vollständigkeit halber zeige ich eine weitere Variante: Anstatt eine statische Methode als Factory zu verwenden, kann man auch eine separate, eigenständige Funktion verwenden.
const secretToken = Symbol('secretToken');
class DataContainer {
#data;
constructor(token, data) {
if (token !== secretToken) {
throw new Error('Constructor is private');
}
this.#data = data;
}
getData() {
return 'DATA: '+this.#data;
}
}
async function createDataContainer() {
const data = await Promise.resolve('downloaded');
return new DataContainer(secretToken, data);
}
createDataContainer()
.then(dc => assert.equal(
dc.getData(), 'DATA: downloaded'));Eigenständige Funktionen als Factories sind gelegentlich nützlich, aber in diesem Fall bevorzuge ich eine statische Methode
DataContainer zugreifen.DataContainer.create().Im Allgemeinen sollte die Unterklasse sparsam eingesetzt werden.
Mit einer separaten Factory-Funktion ist es relativ einfach, DataContainer zu erweitern.
Leider führt die Erweiterung der Klasse mit dem Promise-basierten Konstruktor zu erheblichen Einschränkungen. Im folgenden Beispiel unterklassifizieren wir DataContainer. Die Unterklasse SubDataContainer hat ihr eigenes privates Feld .#moreData, das sie asynchron initialisiert, indem sie sich in das von super-Konstruktor zurückgegebene Promise einklinkt.
class DataContainer {
#data;
constructor() {
return Promise.resolve('downloaded')
.then(data => {
this.#data = data;
return this; // (A)
});
}
getData() {
return 'DATA: '+this.#data;
}
}
class SubDataContainer extends DataContainer {
#moreData;
constructor() {
super();
const promise = this;
return promise
.then(_this => {
return Promise.resolve('more')
.then(moreData => {
_this.#moreData = moreData;
return _this;
});
});
}
getData() {
return super.getData() + ', ' + this.#moreData;
}
}Leider können wir diese Klasse nicht instanziieren
assert.rejects(
() => new SubDataContainer(),
{
name: 'TypeError',
message: 'Cannot write private member #moreData ' +
'to an object whose class did not declare it',
}
);Warum das Scheitern? Ein Konstruktor fügt seine privaten Felder immer zu seinem this hinzu. Hier ist jedoch this im Unterklassen-Konstruktor das von super-Konstruktor zurückgegebene Promise (und nicht die von Promise gelieferte Instanz von SubDataContainer).
Dieser Ansatz funktioniert jedoch weiterhin, wenn SubDataContainer keine privaten Felder hat.
Für das in diesem Kapitel untersuchte Szenario bevorzuge ich entweder einen Promise-basierten Konstruktor oder eine statische Factory-Methode plus einen privaten Konstruktor über ein geheimes Token.
Die anderen hier vorgestellten Techniken können jedoch in anderen Szenarien nützlich sein.