在 Day 3 Module 模式有短暫提及 Singleton 這個詞彙,今天就來看看 Singleton 模式是什麼吧!Singleton 模式也是 GoF 提出的模式之一。
在軟體開發中,有時須確保某些類別在整個應用中只有一個實例。例如:應用程式的配置管理器、資源管理器、全域狀態等。這些物件在整個應用應該是唯一的,且整個應用全局都可以存取。
如何規劃實例的創建與存取,以確保整個應用中只有一個實例?
當我們想確保整個應用只有一個實例,且全局可存取,面臨以下權衡:
Singleton(單例) 可解決此問題,GoF 對 Singleton 的描述是「一個類別只能有一個實例,且必須提供可供存取它的全域存取點」,且唯一的實例可透過子類別來擴展,client 端應能在不修改程式碼的情況下使用擴展的實例。
那 singleton 要如何限制為單一物件? 就是在實例不存在時才會建立新實例,否則會回傳已存在的實例,示意圖如下:
圖 1 Singleton 示意圖(資料來源:自行繪製)
只實例化一次,並透過模組匯出的功能來讓全局都可存取和使用,雖然不能建立新實例,但可以使用模組定義的公有方法來讀取或修改實例,範例程式碼如下:
let instance; // 儲存建立的實例,如果已經建立過就可以從這裡找到
// 定義私人方法和變數
const privateMethod = () => {
console.log('I am private');
};
const privateVariable = 'I am a privateVariable';
const randomNumber = Math.random();
class MySingleton {
constructor() {
// 如果查看 instance 變數內沒有建立好的實例,才建立新的
if(!instance){
this.userName = 'Foo';
instance = this; // 將建立好的 intance 存在變數內
}
// 回傳 instance (如果有建立過,就不會走上面新建立的流程,而是回傳之前建立好的)
return instance;
}
setUserName(value) {
this.userName = value;
}
greetUser() {
console.log(`hello ${this.userName}!`)
}
getRandomNumber() {
return randomNumber;
}
}
// 匯出以供外部存取、使用
export default MySingleton;
外部的使用方式:
import MySingleton from './MySingleton.js';
const singleA = new MySingleton();
const singleB = new MySingleton();
console.log(singleA.getRandomNumber() === singleB.getRandomNumber()); // true,代表 singleA 和 singleB 都是同一個實例,執行函式後的結果也會相同
singleA.greetUser(); // hello Foo!
singleA.setUserName('Tom');
singleB.greetUser(); // hello Tom!,因為是存取同一個實例,singleA 和 singleB 會共用同一個 userName
延續 Module 模式曾提及的,ES6 的模組本身就是 singeleton,我們不需要像上面程式碼一樣,明確建立 singleton 邏輯來實現這種全域的、唯一值的行為。舉例來說,如果我們匯出一個 styleData
物件來描述一些樣式資料,當我們在外部檔案 A 匯入 styleData
,且外部檔案 B 也匯入該模組的 styleData
,則兩個 styleData
都是存取同一個參考,因此若在 A 檔案修改了 styleData
內的屬性 ,當 B 檔案有匯入 A 檔案和styleData
時,就會得到 A 檔案修改後的值,範例程式碼如下:
// style.js 匯出變數
export const styleData = {
color: '#080705',
backgroundColor: '#e7e6f7',
};
// fileA.js
import { styleData } from './style.js';
styleData.color = 'red';
// fileB.js
import './fileA.js'
import { styleData } from './style.js';
console.log(styleData.color); // red,因為被 fileA 修改過了,不過如果沒有匯入 fileA ,就不會執行 fileA 的程式碼,印出的值就會是 '#080705'
從上面可看出,如果我們修改了模組內的值,可能導致其他同樣有存取該模組的程式碼拿到預期外的值,導致一些難以預測、不如預期的狀況發生,且當匯入的檔案變多,程式碼變複雜,也會很難追蹤是誰修改該值。因此使用模組時若要做修改,要謹慎小心~
惰性單例指的是有用到時才建立實例,而不會在一開始就把實例建立好。
Singleton 在實際開發的應用,除了 ES6 模組是使用 singleton,還有前端開發常見的 axios,通常我們的應用程式會共用一個 axios instance,我們可以在專案內先定義好 axios instance 的基本資料和預設的共用方法,例如 baseURL
、headers
或是 interceptors
等,在應用中要發送 API 請求時都可匯入、使用這個共用的 axios instance,整個應用就都能使用這些設定好的共用資料和方法,以此來維持 API 請求的一致性。
以一個我很久以前在六角學院練習的專案為例,我在專案建了一個 api.js
的檔案,裡面分別定義使用者和管理者使用的 axios instance,並在之後以這兩個 instance 來建立請求的函式,雖然檔案內只匯出 API 請求的函式、沒有匯出 axios instance 本身,不過若需要,也是可以 export
創建好的 axios instance 來讓外部使用~當時寫的程式碼如下,也是參考那時的組員分享的:
const baseUrl = "https://livejs-api.hexschool.io";
const api_path = "...myApiPath";
const token = "...myToken";
//將API分為兩種,需要&不需要token
//使用者用的API
const userRequest = axios.create({
baseURL: `${baseUrl}/api/livejs/v1/customer/${api_path}/`,
headers: {
'Content-Type': 'application/json'
}
})
//管理者用的API
const adminRequest = axios.create({
baseURL: `${baseUrl}/api/livejs/v1/admin/${api_path}/`,
headers: {
'Content-Type': 'application/json',
'Authorization': token
}
})
export const apiGetCart = () => userRequest.get('/carts');
export const apiAddCart = data => userRequest.post('/carts',data);
export const apiUpdateCart = data => userRequest.patch('/carts',data);
// ... other userRequest api
export const apiGetOrder = () => adminRequest.get('/orders');
export const apiUpdateOrder = data => adminRequest.put('/orders', data);
export const apiClearOrder = () => adminRequest.delete('/orders');
// ... other adminRequest api
順帶補充,因為是練習的專案,token 是固定的,所以我直接在程式碼內寫了固定值(資安考量一般是不會直接在程式碼寫死 token 的),如果需要依據使用者登入狀況來設定 token,可以 export adminRequest
這個 instance,並在有 token 的時候再設定這個 instance 的 token。上述我的練習專案原始碼連結請點此。
另外,前陣子因為工作需要,研究了一下 Next.js 的 router (page router),為了搞清楚 Next router 的 routeChangeStart
事件會在什麼時候被觸發、什麼時候不會觸發,我去爬了 Next 12 router 的原始碼,關於爬原始碼的發現就先不贅述,重點是爬的過程中看到 Next router 也是 singleton!,可以看我節錄的這段程式碼,在註解和變數命名上都可看出他是 singleton 的:
// INTERNAL APIS
// -------------
// (do not use following exports inside the app)
// Create a router and assign it as the singleton instance.
// This is used in client side when we are initilizing the app.
// This should **not** be used inside the server.
export function createRouter(...args: RouterArgs): Router {
singletonRouter.router = new Router(...args)
singletonRouter.readyCallbacks.forEach((cb) => cb())
singletonRouter.readyCallbacks = []
return singletonRouter.router
}
詳細程式碼請見此連結,但想想也合理,因為整個應用要共用同個 router 才能保持應用程式的一致性,路由的狀態資料、歷史紀錄等也都要是共用的,只是覺得很有趣,看到 singleton 被實際應用在前端中,其實這一切離自己並不遠😃
以 Singleton 作為解決方案優點如下:
以 Singleton 作為解決方案缺點如下:
說到全局都可共用的資料,不知道 React 開發者有沒有想到 React 中的全域狀態管理方式? 在 React 開發中,開發者常常會使用 Redux 或 React Context 來管理全域狀態,而不是使用 Singleton 模式。雖然「全域」和「唯一值」這些特徵與 Singleton 類似,但 Redux 和 React Context 提供的狀態是唯讀狀態(read-only state),而非可變狀態(mutable state)。這樣的設計可確保狀態按照預期變化,避免元件直接更新狀態,能更容易追蹤和偵測狀態的變化過程。