Singleton-pattern in JavaScript: ES6-klasse, module en TypeScript

Het Singleton-pattern garandeert een unieke instantie. Drie uitgevoerde JavaScript-implementaties (klasse met privé static veld, constructor die de instantie teruggeeft, ES-module), de TypeScript-versie, het PHP-equivalent, en de gevallen waarin je het beter kunt laten.

Het Singleton-pattern in JavaScript met ES6-klassen
Kort antwoord

Een Singleton garandeert dat een klasse maar één instantie heeft en biedt daar één centraal toegangspunt toe. In modern JavaScript is de eenvoudigste aanpak een privé static veld met een getInstance()-methode: static #instance; static getInstance() { return Database.#instance ??= new Database(); }. Een ES-module die een object exporteert, is al een singleton: vaak het beste antwoord.

Het Singleton-pattern is waarschijnlijk het bekendste en meest besproken designpattern. De belofte is simpel: een klasse waarvan er maar één instantie kan bestaan, overal toegankelijk. Een databaseverbinding, een logger, de configuratie van de applicatie zijn de klassieke voorbeelden. In JavaScript schrijf je het in een paar regels, en de taal biedt zelfs een gratis alternatief: de module. Hier zijn de implementaties die vandaag werken, met hun valkuilen.

Het Singleton-pattern in één zin

Een Singleton voorkomt dat er meerdere instanties van een klasse ontstaan en biedt één centraal toegangspunt tot de bestaande instantie. De eerste aanroep maakt het object aan, de volgende aanroepen geven hetzelfde object terug. Je gebruikt het wanneer een resource echt gedeeld moet worden: tien verbindingen met dezelfde database openen omdat tien modules new Database() hebben gedaan, zou verspilling zijn, en een logboek dat over tien bestanden verspreid is, zou onbruikbaar zijn.

Het wordt afgeraden zodra het alleen dient om te voorkomen dat je een parameter moet doorgeven: het wordt dan een verkapte globale variabele, moeilijk te testen en te vervangen. We komen hierop terug aan het einde van het artikel.

Implementatie 1: klasse met privé static veld

Dit is de aanbevolen moderne schrijfwijze. Het veld #instance is privé en static: het hoort bij de klasse, niet bij de objecten, en niemand kan het van buitenaf lezen of overschrijven. getInstance() maakt de instantie aan bij de eerste aanroep, dankzij de operator ??= (“ken toe als null”).

database.js
class Database {
  static #instance;

  constructor(dsn) {
    this.dsn = dsn;
    this.connectedAt = new Date();
  }

  static getInstance(dsn = 'mongodb://localhost') {
    Database.#instance ??= new Database(dsn);
    return Database.#instance;
  }

  query(sql) {
    return `${this.dsn} > ${sql}`;
  }
}

const a = Database.getInstance('mongodb://prod');
const b = Database.getInstance('mysql://autre');

console.log(a === b);   // true  : hetzelfde object
console.log(b.dsn);     // 'mongodb://prod' : de tweede dsn is genegeerd

De laatste regel toont een valkuil van het pattern: de parameters van de tweede aanroep gaan stilzwijgend verloren. Als je singleton parameters accepteert, moeten die ofwel uit één bron komen (de configuratie), ofwel moet getInstance() een fout geven zodra er andere waarden worden doorgegeven.

Niets houdt je nog tegen om direct new Database() te schrijven. JavaScript kent geen privéconstructor, je simuleert er een met een token dat alleen de klasse zelf kent:

javascript
const cle = Symbol('Database');

class Database {
  static #instance;

  constructor(jeton) {
    if (jeton !== cle) {
      throw new Error('Utilisez Database.getInstance()');
    }
  }

  static getInstance() {
    return (Database.#instance ??= new Database(cle));
  }
}

new Database();   // Error: gebruik Database.getInstance()

Implementatie 2: de constructor geeft de bestaande instantie terug

Dit is de historische versie van dit artikel, en ze werkt nog steeds: als een constructor expliciet een object teruggeeft, geeft new dat object terug in plaats van het nieuwe. De instantie wordt bewaard in een static property.

database-constructeur.js
class Database {
  constructor(data) {
    if (Database.instance) {
      return Database.instance;   // new geeft het bestaande object terug
    }
    this._data = data;
    Database.instance = this;
  }

  getData() {
    return this._data;
  }

  setData(data) {
    this._data = data;
  }
}

const mongo = new Database('mongo');
console.log(mongo.getData()); // 'mongo'

const mysql = new Database('mysql');
console.log(mysql.getData()); // 'mongo' : dit is hetzelfde object als mongo
console.log(mongo === mysql); // true

Het voordeel is dat de syntax new Database() voor de aanroepende code intact blijft. Het nadeel is dat het verrast: een new die niets aanmaakt, gaat in tegen de intuïtie van wie de code leest, en Database.instance is publiek, dus aanpasbaar. De versie met het privéveld is explicieter.

Implementatie 3: een ES-module is al een singleton

Een JavaScript-module wordt maar één keer per applicatie geëvalueerd, ongeacht het aantal bestanden dat hem importeert. Een instantie exporteren volstaat dus om een singleton te krijgen, zonder klasse en zonder getInstance().

config.js
const config = Object.freeze({
  env: process.env.NODE_ENV ?? 'development',
  apiUrl: 'https://api.example.com',
});

export default config;
logger.js
class Logger {
  #lines = [];
  log(message) {
    this.#lines.push(`${new Date().toISOString()} ${message}`);
  }
  get count() {
    return this.#lines.length;
  }
}

export const logger = new Logger();   // één instantie voor de hele applicatie
app.js
import { logger } from './logger.js';
import config from './config.js';

logger.log(`Démarrage en ${config.env}`);

Elk bestand dat logger importeert, krijgt hetzelfde object. Dit is de te verkiezen oplossing in de meeste projecten: ze is leesbaar, zonder magie, en het modulesysteem garandeert de uniciteit. Twee beperkingen: de instantie wordt aangemaakt zodra de module wordt geladen, ook als niemand hem gebruikt, en de singleton is alleen uniek per bestandspad (twee kopieën van eenzelfde package in node_modules geven twee instanties).

TypeScript-versie

TypeScript voegt toe wat JavaScript mist: een écht private constructor, die new al bij het compileren verbiedt.

database.ts
class Database {
  private static instance: Database | undefined;

  private constructor(private readonly dsn: string) {}

  static getInstance(dsn = 'mongodb://localhost'): Database {
    Database.instance ??= new Database(dsn);
    return Database.instance;
  }

  query(sql: string): string {
    return `${this.dsn} > ${sql}`;
  }
}

const db = Database.getInstance();
// new Database('x');  // Fout TS2673: de constructor is privé

Het equivalent in PHP

De structuur is identiek: static property, privéconstructor, methode getInstance(). PHP voegt er twee nuttige vergrendelingen aan toe, een privé __clone en __wakeup dat deserialisatie weigert, zodat er geen enkele kopie kan ontstaan.

Database.php
final class Database
{
    private static ?Database $instance = null;

    private function __construct(private readonly string $dsn)
    {
    }

    public static function getInstance(string $dsn = 'mysql:host=localhost'): Database
    {
        return self::$instance ??= new self($dsn);
    }

    public function query(string $sql): string
    {
        return "{$this->dsn} > {$sql}";
    }

    private function __clone()
    {
    }

    public function __wakeup(): void
    {
        throw new LogicException('Un singleton ne se désérialise pas.');
    }
}

$a = Database::getInstance('mysql:host=prod');
$b = Database::getInstance();

var_dump($a === $b);   // bool(true)
echo $a->query('SELECT 1'); // mysql:host=prod > SELECT 1

Wanneer je geen Singleton gebruikt

Het pattern wordt om goede redenen bekritiseerd, en je moet ze kennen voordat je het toepast:

  • Het verbergt afhankelijkheden. Een functie die ergens in zijn code Database.getInstance() aanroept, is afhankelijk van de database zonder dat in zijn signatuur te vermelden. De verbinding als parameter doorgeven (dependency injection) maakt de afhankelijkheid zichtbaar en vervangbaar.
  • Het bemoeilijkt tests. De state overleeft van de ene test naar de andere. Als je een singleton behoudt, voeg dan een methode static reset() toe die alleen voor tests bedoeld is, of test via injectie.
  • Het is globale state. Alles wat geldt voor globale variabelen, geldt ook voor de singleton: koppeling, effecten op afstand, volgorde van initialisatie.

De praktische regel: een module die een instantie exporteert (implementatie 3) voor configuratie en toestandsloze hulpfuncties, dependency injection voor alles wat met een externe resource te maken heeft, en de klasse met getInstance() wanneer een library dat ene toegangspunt oplegt. Het guard clauses-pattern en klassen in JavaScript gaan dieper in op dit onderwerp vanuit codestructuur.

Veelgemaakte fouten

Een instantie die je niet kunt resetten in tests Een singleton behoudt zijn state van de ene test naar de andere. Voorzie een methode static reset() die alleen voor tests bedoeld is, of injecteer de afhankelijkheid in plaats van getInstance() aan te roepen in de bedrijfslogica.
Genegeerde parameters bij de tweede aanroep new Database('mysql') geeft de instantie terug die met “mongo” is aangemaakt: de argumenten van de tweede aanroep gaan zonder waarschuwing verloren. Documenteer dit, of gooi een fout als de parameters verschillen.
Een singleton per kopie van de module Twee versies van hetzelfde package die in node_modules zijn geïnstalleerd, geven twee modules, dus twee “singletons”. De ES-module is alleen uniek voor een gegeven bestandspad.
Code die bij het importeren wordt uitgevoerd Een instantie die wordt aangemaakt bij het laden van de module, wordt gebouwd ook als niemand hem gebruikt. Geef de voorkeur aan lazy instantiëring in getInstance().

JavaScript

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.