Singleton w JavaScript: wzorzec projektowy, klasa, moduł

Wzorzec Singleton gwarantuje jedną instancję. Trzy przetestowane implementacje w JavaScript (klasa z prywatnym polem statycznym, konstruktor zwracający instancję, moduł ES), wersja w TypeScript, odpowiednik w PHP oraz sytuacje, w których lepiej z niego zrezygnować.

Wzorzec Singleton w JavaScripcie: jedna instancja klasy
Szybka odpowiedź

Singleton gwarantuje, że klasa ma tylko jedną instancję, i udostępnia do niej globalny punkt dostępu. We współczesnym JavaScript najprostszy sposób to prywatne pole statyczne i metoda getInstance(): static #instance; static getInstance() { return Database.#instance ??= new Database(); }. Moduł ES, który eksportuje obiekt, jest już singletonem, to często najlepsza odpowiedź.

Singleton to prawdopodobnie najbardziej znany i najbardziej dyskutowany wzorzec projektowy. Jego obietnica jest prosta: klasa, z której może istnieć tylko jedna instancja, dostępna z każdego miejsca. Połączenie z bazą danych, logger, konfiguracja aplikacji to klasyczne przykłady. W JavaScript zapisuje się go w kilku liniach, a sam język oferuje nawet darmową alternatywę: moduł. Oto implementacje, które działają dzisiaj, wraz z ich pułapkami.

Singleton w jednym zdaniu

Singleton uniemożliwia utworzenie kilku instancji klasy i zapewnia jeden punkt dostępu do istniejącej instancji. Pierwsze wywołanie tworzy obiekt. Kolejne zwracają ten sam. Używa się go, gdy zasób naprawdę musi być współdzielony: otwieranie dziesięciu połączeń do tej samej bazy, bo dziesięć modułów wywołało new Database(), byłoby marnotrawstwem, a dziennik rozbity na dziesięć plików byłby bezużyteczny.

Odradza się go, gdy służy tylko do uniknięcia przekazania parametru: staje się wtedy zamaskowaną zmienną globalną, trudną do testowania i zastąpienia. Wracamy do tego na końcu artykułu.

Implementacja 1: klasa z prywatnym polem statycznym

To zalecany, nowoczesny zapis. Pole #instance jest prywatne i statyczne: należy do klasy, nie do obiektów, i nikt nie może go odczytać ani nadpisać z zewnątrz. getInstance() tworzy instancję przy pierwszym wywołaniu dzięki operatorowi ??= („przypisz, jeśli 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  : ten sam obiekt
console.log(b.dsn);     // 'mongodb://prod' : drugi dsn został zignorowany

Ostatnia linijka pokazuje pułapkę tego wzorca: parametry drugiego wywołania giną bez żadnego ostrzeżenia. Jeśli twój singleton przyjmuje parametry, powinny one pochodzić z jednego źródła (konfiguracji), albo getInstance() powinno rzucać błąd, gdy dostanie inne wartości.

Nic jeszcze nie przeszkadza w napisaniu new Database() wprost. JavaScript nie ma prywatnego konstruktora. Symuluje się go tokenem znanym tylko samej klasie:

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: Utilisez Database.getInstance()

Implementacja 2: konstruktor zwraca istniejącą instancję

To historyczna wersja z tego artykułu i wciąż działa: jeśli konstruktor jawnie zwraca obiekt, new zwraca ten obiekt zamiast nowego. Instancja jest przechowywana we właściwości statycznej.

database-constructeur.js
class Database {
  constructor(data) {
    if (Database.instance) {
      return Database.instance;   // new zwraca istniejący obiekt
    }
    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' : to ten sam obiekt co mongo
console.log(mongo === mysql); // true

Ma tę zaletę, że zostawia składnię new Database() nietkniętą dla kodu wywołującego. Ma tę wadę, że zaskakuje: new, które niczego nie tworzy, przeczy intuicji osoby czytającej kod, a Database.instance jest publiczne, więc można je nadpisać. Wersja z polem prywatnym jest bardziej jednoznaczna.

Implementacja 3: moduł ES jest już singletonem

Moduł JavaScript jest wykonywany tylko raz na aplikację, niezależnie od liczby plików, które go importują. Wyeksportowanie instancji wystarczy więc, by uzyskać singleton, bez klasy i bez 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();   // jedna instancja na całą aplikację
app.js
import { logger } from './logger.js';
import config from './config.js';

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

Każdy plik, który importuje logger, otrzymuje ten sam obiekt. To rozwiązanie zalecane w większości projektów: jest czytelne, pozbawione magii, a system modułów gwarantuje unikalność. Dwa ograniczenia: instancja jest tworzona przy wczytywaniu modułu, nawet jeśli nikt jej nie używa, a singleton jest unikalny tylko dla danej ścieżki pliku (dwie kopie tego samego pakietu w node_modules dają dwie instancje).

Wersja w TypeScript

TypeScript dodaje to, czego brakuje JavaScriptowi: konstruktor naprawdę private, który zabrania new już na etapie kompilacji.

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');  // Błąd TS2673: konstruktor jest prywatny

Odpowiednik w PHP

Struktura jest identyczna: właściwość statyczna, prywatny konstruktor, metoda getInstance(). PHP dodaje do tego dwie przydatne blokady, prywatne __clone i __wakeup, które odrzuca deserializację, tak aby żadna kopia nie mogła powstać.

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

Kiedy nie używać Singletona

Wzorzec bywa krytykowany z dobrych powodów i warto je poznać, zanim się go przyjmie:

  • Ukrywa zależności. Funkcja, która wywołuje Database.getInstance() w środku swojego kodu, zależy od bazy danych, nie mówiąc o tym w swojej sygnaturze. Przekazanie połączenia jako parametru (wstrzykiwanie zależności) sprawia, że zależność jest widoczna i wymienialna.
  • Utrudnia testy. Stan przetrwa od jednego testu do drugiego. Jeśli zachowujesz singleton, dodaj metodę static reset() zarezerwowaną dla testów, albo testuj poprzez wstrzykiwanie.
  • To stan globalny. Wszystko, co prawdziwe dla zmiennych globalnych, dotyczy też singletona: sprzężenie, efekty na odległość, kolejność inicjalizacji.

Praktyczna reguła: moduł eksportujący instancję (implementacja 3) sprawdza się dla konfiguracji i bezstanowych narzędzi, wstrzykiwanie zależności dla wszystkiego, co dotyka zasobu zewnętrznego, a klasa z getInstance(), gdy jakaś biblioteka narzuca ten jeden punkt dostępu. Wzorzec guard clauses i klasy w JavaScript rozwijają ten temat od strony struktury kodu.

Częste błędy

Niemożliwość zresetowania instancji w testach Singleton zachowuje swój stan między testami. Przygotuj metodę static reset() zarezerwowaną dla testów albo wstrzykuj zależność zamiast wywoływać getInstance() w kodzie biznesowym.
Ignorowane parametry przy drugim wywołaniu new Database('mysql') zwraca instancję utworzoną z „mongo”: argumenty drugiego wywołania giną bez ostrzeżenia. Udokumentuj to zachowanie albo zgłaszaj błąd, gdy parametry się różnią.
Jeden singleton na kopię modułu Dwie wersje tego samego pakietu zainstalowane w node_modules dają dwa moduły, czyli dwa „singletony”. Moduł ES jest unikalny tylko dla danej ścieżki pliku.
Kod wykonywany już przy imporcie Instancja tworzona przy wczytywaniu modułu powstaje, nawet jeśli nikt jej nie używa. Wybieraj leniwą instancjację wewnątrz getInstance().

JavaScript

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.