[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');
注意
不能針對 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 對於該資料的讀取,因此當有不預期的行為是會變的不容易除錯,開發者不容易知道是誰對它做了修改,而且如果這個修改是有時間順序的話,將更難被偵測出來。
參考
- JavaScript 設計模式與開發實踐
- Singleton Pattern @ Frontend Master
- Singleton Pattern @ patterns.dev