iT邦幫忙

2024 iThome 鐵人賽

DAY 5
1

https://ithelp.ithome.com.tw/upload/images/20240919/20168201GTgkDBBGlH.png

Day 3 Module 模式有短暫提及 Singleton 這個詞彙,今天就來看看 Singleton 模式是什麼吧!Singleton 模式也是 GoF 提出的模式之一。

情境

在軟體開發中,有時須確保某些類別在整個應用中只有一個實例。例如:應用程式的配置管理器、資源管理器、全域狀態等。這些物件在整個應用應該是唯一的,且整個應用全局都可以存取。

問題

如何規劃實例的創建與存取,以確保整個應用中只有一個實例?

權衡

當我們想確保整個應用只有一個實例,且全局可存取,面臨以下權衡:

  • 延遲載入:有些實例不需要在執行應用的一開始就立即創建,因此此解決方案須考慮延遲載入以提升效能
  • 存取方式:需要一個存取方式讓全局都可存取唯一實例,確保開發者不需要複雜步驟來獲取實例
  • 同步問題:須確保任何時間點都只有一個實例被創建,避免競爭條件

解決方案

Singleton(單例) 可解決此問題,GoF 對 Singleton 的描述是「一個類別只能有一個實例,且必須提供可供存取它的全域存取點」,且唯一的實例可透過子類別來擴展,client 端應能在不修改程式碼的情況下使用擴展的實例。
那 singleton 要如何限制為單一物件? 就是在實例不存在時才會建立新實例,否則會回傳已存在的實例,示意圖如下:
https://ithelp.ithome.com.tw/upload/images/20240920/20168201ulL8MWpx2T.jpg
圖 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

ES6 Module 本身就是單例

延續 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'

從上面可看出,如果我們修改了模組內的值,可能導致其他同樣有存取該模組的程式碼拿到預期外的值,導致一些難以預測、不如預期的狀況發生,且當匯入的檔案變多,程式碼變複雜,也會很難追蹤是誰修改該值。因此使用模組時若要做修改,要謹慎小心~

惰性單例(lazy singletons)

惰性單例指的是有用到時才建立實例,而不會在一開始就把實例建立好。

應用案例

Singleton 在實際開發的應用,除了 ES6 模組是使用 singleton,還有前端開發常見的 axios,通常我們的應用程式會共用一個 axios instance,我們可以在專案內先定義好 axios instance 的基本資料和預設的共用方法,例如 baseURLheaders 或是 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 作為解決方案缺點如下:

  • 難以測試:因為 Singleton 可全局使用,可能會有隱藏的依賴關係,要根除依賴關係會有難度,因此較難進行測試
  • 辨識與排查錯誤較困難:舉例來說,如果我們匯入一個模組,卻不清楚其中的哪些類別是否為 Singleton,當我們修改它時有可能會連帶影響其他也引用此實例的地方,Singleton 其實就類似全域變數,一修改它就會影響其他也引用此全域變數的邏輯,但問題是有時候我們不知道它是全域的、不確定它的影響範圍,就會導致我們的應用出現預期外行為。另一個難以排查錯誤的原因是,因為全局都可存取或修改,因此開發者會很難找到是誰修改了它,而如果有時間順序時,又會更難偵測
  • 需經過設計:Singleton 是儲存在全域作用域的資料,可以只設定一次,之後多個元件都能共用,但我們需要確保執行順序,要在資料設定好後才能提供其他地方存取使用,否則其他地方會拿到沒設定好的資料。以上面的 axios instance 為例,我們要先設定好 axios instance 的基本資料,其他地方才能拿來使用

React 中的狀態管理

說到全局都可共用的資料,不知道 React 開發者有沒有想到 React 中的全域狀態管理方式? 在 React 開發中,開發者常常會使用 Redux 或 React Context 來管理全域狀態,而不是使用 Singleton 模式。雖然「全域」和「唯一值」這些特徵與 Singleton 類似,但 Redux 和 React Context 提供的狀態是唯讀狀態(read-only state),而非可變狀態(mutable state)。這樣的設計可確保狀態按照預期變化,避免元件直接更新狀態,能更容易追蹤和偵測狀態的變化過程。

Reference


上一篇
[Day 04] Revealing Module 模式
下一篇
[Day 06] Prototype 模式
系列文
30天的 JavaScript 設計模式之旅12
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言