
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”).
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ł zignorowanyOstatnia 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:
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.
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); // trueMa 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().
const config = Object.freeze({
env: process.env.NODE_ENV ?? 'development',
apiUrl: 'https://api.example.com',
});
export default config;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ę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.
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 prywatnyOdpowiednik 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ć.
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 1Kiedy 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
static reset() zarezerwowaną dla testów albo wstrzykuj zależność zamiast wywoływać getInstance() w kodzie biznesowym.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ą.node_modules dają dwa moduły, czyli dwa „singletony”. Moduł ES jest unikalny tylko dla danej ścieżki pliku.getInstance().

