跳至主要内容

[GoF] 單例模式 Singleton

Singleton example source code @ pjchender github

Singleton 的定義是「保證一個類別僅有一個實例,並提供一個存取它的全域存取點(JavaScript 設計模式與開發實踐)」。不同檔案匯入時也都拿到的是相同的實例。

class Singleton {
static instance: Singleton | null = null;
constructor(public name: string) {}

getName() {
console.log(this.name);
}

static getInstance(name: string) {
// 如果沒有被初始化過,就初始化一個
if (Singleton.instance === null) {
Singleton.instance = new Singleton(name);
}
return Singleton.instance;
}
}

const a = Singleton.getInstance('aaron1');
a.getName(); // aaron1
const b = Singleton.getInstance('aaron2');
b.getName(); // aaron1

console.log(a === b); // true

適合情境

  • 需要確保整個 App 只會有一個該變數或方式時,例如,DB Connection、Global State

避免污染全域變數

使用 namespace

class MyApp {
static state: Record<string, any> = {};

static createNamespace = (path: string): void => {
const parts = path.split('.');
let current = MyApp.state;

for (const keyName of parts) {
if (current[keyName] === undefined) {
current[keyName] = {};
}

current = current[keyName];
}
};

static getNamespace = (path: string): any => {
const parts = path.split('.');
let current = MyApp.state;

for (const keyName of parts) {
if (current[keyName] === undefined) {
throw new Error(`There is no ${path} in the state`);
}

current = current[keyName];
}

return current;
};

static print = (): void => {
console.log(MyApp.state);
};
}

MyApp.createNamespace('people.name.firstName');
const people = MyApp.getNamespace('people');
console.log(people); // {"name": {"firstName": {}}

使用閉包(closure)

// 取得閉包回傳的內容
const user = (function () {
const _name = 'pjchender';
const _age = 29;
return {
getUserInfo() {
return `${_name} is ${_age}.`;
},
};
})();

const aaron = user.getUserInfo();
console.log(aaron);

ES Module 本身就是單例

// counter.js
export const counter = {
count: 1,
};

// file1.js
import { counter } from './counter.js';
counter.count++;

// file2.js
import { counter } from './counter.js';
counter.count++;

// main.js
import './file1.js';
import './file2.js';
import { counter } from './counter.js';

console.log(counter.count); // 3;

惰性單例(lazy Singletons)

指的是不會在一開始就把 instance 建立好,而是有用到時才建立。

沒有使用 lazy Singletons

一開始就先把 DOM 建立好:

<button id="login-btn">Click me</button>

<script>
// 一開始就把 DOM 建立起來(不管會不會用到)
const loginLayer = (function () {
const el = document.createElement('div');
el.innerHTML = '彈出視窗';
el.style.display = 'none';
document.body.appendChild(el);
return el;
})();

const btn = document.getElementById('login-btn');

const handleClick = () => {
let isShow = true;
return () => {
if (isShow) {
loginLayer.style.display = 'block';
} else {
loginLayer.style.display = 'none';
}
isShow = !isShow;
};
};
btn.addEventListener('click', handleClick());
</script>

使用 lazy Singletons:

  • getSingle 是很好的抽像,可以傳入不同的參數,並透過閉包來達到單例模式的使用
// 建立 loginLayer
const createLoginLayer = () => {
const el = document.createElement('div');
el.setAttribute('id', 'login-root');
el.innerHTML = '彈出視窗';
el.style.display = 'none';
document.body.appendChild(el);
return el;
};

// getSingle 的目的是,如果該 element 已經被建立過就不要再建立一次
interface IGetSingle {
(fn: () => HTMLElement): () => HTMLElement;
}
const getSingle: IGetSingle = (fn) => {
let el: HTMLElement | undefined;

return () => {
// 如果 el 不存在,就呼叫 fn() 建立新的
if (!el) {
el = fn();
}

// 否者直接回傳之前建立好的
return el;
};
};

// 如果 loginLayer 不存在就建立,否則拿舊的
const getSingleLoginRoot = getSingle(createLoginLayer);

const btn = document.getElementById('login-btn');

const handleClick = () => {
const loginRoot = getSingleLoginRoot();

loginRoot.style.display = loginRoot.style.display === 'none' ? 'block' : 'none';
};

btn?.addEventListener('click', handleClick);

使用 Class 建立 Singleton

透過 export 一個 instance 也能搭到單例模式的使用:

// contact.ts

// eslint-disable-next-line no-use-before-define
let instance: Contact | undefined;

class Contact {
constructor(
protected firstName: string,
public lastName: string,
) {
// 確保只有一個 instance
if (instance) {
throw new Error('There can only be only 1 instance!');
}

// eslint-disable-next-line @typescript-eslint/no-this-alias
instance = this;
}

setFirstName(value: string) {
this.firstName = value;
}

setLastName(value: string) {
this.lastName = value;
}

get name() {
return `${this.lastName} ${this.firstName}`;
}
}

export default new Contact('John', 'Smith');
warning

不能針對 new Contact 使用 Object.freeze(),因為這會使得該 instance 完全沒辦法被修改,進而導致 setter 也沒辦法作用。

當其他檔案在使用 contact 時,它們會操作到的是同一個 Contact instance:

  • changeFirstName.ts 中改變 contact 中的 firstName 後,在 getFirstName.ts 中會取得被修改過的物件
// @filename: changeFirstName.ts
import contact from './index';

export const changeFirstName = (): void => {
console.log('[changeFirstName]', { name: contact.name });

console.log('[changeFirstName] change firstName to aaron');
contact.setFirstName('Aaron');

console.log('[changeFirstName]', contact.name);
};

////////////////////////////////////////////////////////
// @filename: getFirstName.ts
import contact from './contact';

export const getName = (): void => {
console.log('[getName]', contact.name);
};

////////////////////////////////////////////////////////
// @filename: main.ts
import { getName } from './getName.js';
import { changeFirstName } from './changeFirstName.js';
getName(); // Smith John
changeFirstName();
getName(); // Smith Aaron

使用 Function 建立 Singleton

// credit: https://javascriptpatterns.vercel.app/patterns/design-patterns/singleton-pattern

let counter = 0;

// 1. Create an object containing the `getCount`, `increment`, and `decrement` method.
const counterObject = {
getCount: () => counter,
increment: () => ++counter,
decrement: () => --counter,
};

// 2. Freeze the object using the `Object.freeze` method, to ensure the object is not modifiable.
const singletonCounter = Object.freeze(counterObject);

// 3. Export the object as the `default` value to make it globally accessible.
export default singletonCounter;

可能的缺點

Singletons 的作法其實就很類似全域變數,使用者的人可能不會預期到在檔案 A 中的操作會連帶影響到檔案 B 對於該資料的讀取,因此當有不預期的行為是會變的不容易除錯,開發者不容易知道是誰對它做了修改,而且如果這個修改是有時間順序的話,將更難被偵測出來。

參考

Giscus