Singleton Pattern:讓全應用程式共用一個遙控器

· ·19 min

此為《Learning JavaScript Design Patterns, 2nd Edition》學習筆記,對應第 7 章(JavaScript Design Patterns - The Singleton Pattern)

模式用途
  • 分類:建立型
  • 解決什麼問題:如何確保某個類別在整個應用程式中只有一個實例,並提供全域存取點

從一個簡單的設定管理開始

在前端開發中,常常需要一個地方來統一管理應用程式的設定,比如 API 端點、主題偏好、語言選擇等。以下是一個典型的 AppConfig 類別實作:

class AppConfig {
  constructor() {
    this.apiBaseUrl = 'https://api.example.com'
    this.theme = 'light'
    this.language = 'zh-tw'
    this.debugMode = false
    this.init()
  }

  init() {
    // 從 localStorage 讀取使用者設定
    const savedConfig = localStorage.getItem('appConfig')
    if (savedConfig) {
      const config = JSON.parse(savedConfig)
      Object.assign(this, config)
    }
  }

  updateConfig(key, value) {
    this[key] = value
    this.saveToStorage()
  }

  saveToStorage() {
    const config = {
      theme: this.theme,
      language: this.language,
      debugMode: this.debugMode
    }
    localStorage.setItem('appConfig', JSON.stringify(config))
  }

  getApiUrl(endpoint) {
    return `${this.apiBaseUrl}/${endpoint}`
  }
}

看起來很合理,但問題來了:如果在不同地方都 new AppConfig(),會發生什麼事?

問題一:設定不一致

// api-service.js
const apiConfig = new AppConfig()
apiConfig.updateConfig('debugMode', true)

// user-service.js
const userConfig = new AppConfig()
console.log(userConfig.debugMode) // false ❌ 不是 true

// auth-service.js
const authConfig = new AppConfig()
authConfig.updateConfig('apiBaseUrl', 'https://staging-api.example.com')

// 結果:三個設定各自為政,設定完全不同步

每個 AppConfig 實例都有自己的設定狀態,彼此完全獨立。api-service 開啟了 debug 模式,但 user-service 和 auth-service 的設定根本不知道,還是停留在預設狀態。

問題二:重複的初始化和儲存操作

// 每次 new AppConfig() 都會:
// 1. 重複讀取 localStorage
// 2. 重複執行 init()
// 3. 重複寫入 localStorage

const config1 = new AppConfig() // localStorage 讀取 +1
const config2 = new AppConfig() // localStorage 讀取 +2
const config3 = new AppConfig() // localStorage 讀取 +3

config1.updateConfig('theme', 'dark') // 寫入 localStorage
config2.updateConfig('language', 'en') // 又寫入 localStorage
config3.updateConfig('debugMode', true) // 再寫入 localStorage

// 結果:重複的讀寫操作,而且各自覆蓋對方的設定

問題三:API 呼叫混亂

// api-service.js
const apiConfig = new AppConfig()
console.log(apiConfig.getApiUrl('users')) // https://api.example.com/users

// 在另一個地方切換到測試環境
const testConfig = new AppConfig()
testConfig.updateConfig('apiBaseUrl', 'https://test-api.example.com')

// auth-service.js
const authConfig = new AppConfig()
console.log(authConfig.getApiUrl('login')) // https://api.example.com/login ❌

// 結果:有些服務打到正式環境,有些打到測試環境

每個服務都有自己的設定實例,當某個地方改變 API 端點時,其他服務完全不知道,導致不同服務可能打到不同的 API 環境。

問題四:記憶體浪費與效能問題

// 假設在一個大型應用程式中
const configs = []
for (let i = 0; i < 100; i++) {
  configs.push(new AppConfig()) // 100 個重複的設定物件
}

// 結果:
// - 100 次重複的 localStorage 讀取
// - 100 個各自獨立的設定狀態
// - 記憶體中存放 100 份相同的設定資料
// - 每次更新都要同步 100 個物件(如果要保持一致的話)

問題的核心:缺乏唯一性

上面的例子顯示了一個問題:應用程式設定這種全域性質的服務,如果允許多個實例存在,會產生什麼後果?

  1. 狀態分裂:每個實例都有自己的狀態,無法保持一致
  2. 資源重複:重複的事件監聽、DOM 操作、記憶體佔用
  3. 通訊失效:實例間無法互相通知,訂閱機制失效
  4. 行為衝突:多個實例同時操作同一個 DOM,產生競爭條件

這類問題的共同特徵是:它們都是「天生就應該只有一個」的服務——無論是全域設定、連線管理、還是資源池,都需要在整個應用程式中保持唯一性和一致性。

這就是 Singleton Pattern 要解決的核心問題:確保某些類別在整個應用程式中只能有一個實例,並提供統一的存取點。

此 Singleton 非彼 Singleton

當我問 AI 關於 JavaScript 全域靜態初始化的問題時,AI 回答說「ESM 是單例化的:同一個模組只會載入一次,之後重複 import 都是同一個 instance」也查到有些文章會說「ESM 是 singleton」。

這讓剛讀完 Module Pattern 的我一度陷入混亂——難道 ESM 同時套用了 Module Pattern 和 Singleton Pattern 嗎?

後來我才理解這裡說的「單例化」與「singleton」其實是指 Singleton-like(類單例)特性,而不是實作了 Singleton Pattern:

  • ESM 的特性:模組載入機制天生確保同一模組只載入一次
  • Singleton Pattern:透過特定的程式設計技巧確保類別只能建立一個實例

ESM 的特性是語言層級的,而 Singleton Pattern 是設計模式,兩者雖然有類似的結果,但背後的原理和目的不同。

為了方便閱讀,接下來我會用「Singleton」來簡稱「Singleton Pattern」。

Singleton 的實作方式

基本實作

// config.js
// 模組內部宣告一個私有變數,用來儲存 Singleton 實例
let instance = null

class AppConfig {
  constructor() {
    // 如果已經有實例,直接回傳
    if (instance) {
      return instance
    }

    // 初始化程式碼...
    this.apiBaseUrl = 'https://api.example.com'
    this.theme = 'light'
    this.language = 'zh-tw'
    this.debugMode = false
    this.init()

    // 將目前實例指定給私有變數
    instance = this
    return this
  }

  init() {
    const savedConfig = localStorage.getItem('appConfig')
    if (savedConfig) {
      const config = JSON.parse(savedConfig)
      Object.assign(this, config)
    }
  }

  updateConfig(key, value) {
    this[key] = value
    this.saveToStorage()
  }

  saveToStorage() {
    const config = {
      theme: this.theme,
      language: this.language,
      debugMode: this.debugMode
    }
    localStorage.setItem('appConfig', JSON.stringify(config))
  }

  getApiUrl(endpoint) {
    return `${this.apiBaseUrl}/${endpoint}`
  }
}
export default AppConfig
// app.js
import AppConfig from './config.js'

// 使用方式
const config1 = new AppConfig()
const config2 = new AppConfig()
const config3 = new AppConfig()

console.log(config1 === config2) // true
console.log(config2 === config3) // true

config1.updateConfig('debugMode', true)
console.log(config2.debugMode) // true ✅ 狀態一致了!
console.log(config3.getApiUrl('users')) // 使用同一份設定

更嚴謹的實作

// config.js
let instance = null

class AppConfig {
  constructor() {
    // 這強制開發者必須使用 getInstance() 方法,無法直接 new
    if (instance) {
      throw new Error('Can\'t create a new instance of AppConfig. Use AppConfig.getInstance() instead.')
    }

    this.apiBaseUrl = 'https://api.example.com'
    this.theme = 'light'
    this.language = 'zh-tw'
    this.debugMode = false
    this.init()

    instance = this

    // 防止外部修改實例
    Object.freeze(this)
    return this
  }

  // 提供一個公開的靜態方法,作為唯一的存取點
  static getInstance() {
    if (!instance) {
      // 如果實例不存在,就呼叫建構子創建它
      return new AppConfig()
    }
    return instance
  }

  // ... 其他方法保持不變
}

export default AppConfig
// app.js
import AppConfig from './config.js'

// 推薦的使用方式
const config = AppConfig.getInstance()

但是等等,Singleton 真的這麼好嗎?

Singleton 雖然解決了「多實例」的問題,但它本身也存在一些限制和缺點:

缺點一:測試困難

// 測試 A
test('debug 模式切換功能', () => {
  const config = AppConfig.getInstance()
  config.updateConfig('debugMode', true)
  expect(config.debugMode).toBe(true)
})

// 測試 B
test('預設 debug 模式應該是 false', () => {
  const config = AppConfig.getInstance() // 拿到的還是測試 A 的實例!
  expect(config.debugMode).toBe(false) // ❌ 失敗,因為還是 true
})

因為 Singleton 會共享狀態,測試之間會互相影響。每次測試前都要手動重置狀態,很麻煩。

缺點二:難以辨識 Singleton 的存在

當我看到書中提到這個缺點時,心中冒出的疑問是「為什麼辨識 Singleton 很重要?」透過以下例子,我才理解辨識困難會帶來什麼問題:

1. 錯誤的使用方式

// 新同事不知道 AppConfig 是 Singleton,可能會這樣寫:
const config1 = new AppConfig()
config1.updateConfig('debugMode', true)

const config2 = new AppConfig() // 以為會拿到新的實例
console.log(config2.debugMode) // 結果是 true,感到困惑

2. 除錯時找錯方向

// 當設定出現問題時
function someFunction() {
  const config = new AppConfig()
  console.log(config.apiBaseUrl) // 發現是錯誤的 URL

  // 不知道是 Singleton,可能會在這個函式裡找問題
  // 實際上問題可能是在別的地方修改了全域設定
}

3. 重構時破壞設計

// 重構時可能會不小心這樣改:
class ApiService {
  constructor(config) { // 以為可以注入不同的 config
    this.config = config
  }
}

// 但 AppConfig 是 Singleton,這樣改就失去意義了
const service = new ApiService(new AppConfig())

4. 測試設計出問題

// 不知道是 Singleton,測試可能會這樣寫:
test('API service with custom config', () => {
  const customConfig = new AppConfig() // 以為會拿到新實例
  customConfig.apiBaseUrl = 'http://test-api.com'

  const service = new ApiService()
  // 結果測試會受到其他測試的影響,因為 config 是全域共享的
})

所以書裡才會說這是個問題:如果團隊成員不知道某個類別是 Singleton,就很容易用錯,導致 bug 或設計混亂。

NOTE

雖然能透過類似更嚴謹的實作的方式幫助我們辨認 Singleton(透過 getInstance() 和錯誤提示),但仍無法完全解決辨識問題,特別是在大型專案中依然需要依賴團隊規範和文件。

缺點三:需要仔細安排執行順序

Singleton 常用來存放全域資料(像使用者資訊、cookie 資料),但這就產生了一個問題:必須確保資料準備好了,其他程式碼才能使用。這個問題的具體表現是什麼?

// 問題情境:使用者資訊的 Singleton
class UserSession {
  constructor() {
    if (UserSession.instance) {
      return UserSession.instance
    }

    this.user = null
    this.isLoggedIn = false
    UserSession.instance = this
  }

  async initFromToken() {
    // 從 localStorage 讀取 token,然後向 API 驗證
    const token = localStorage.getItem('authToken')
    if (token) {
      const userData = await this.validateToken(token)
      this.user = userData
      this.isLoggedIn = true
    }
  }
}

// 其他地方想用使用者資訊
class UserProfile {
  constructor() {
    const session = UserSession.getInstance()
    // ❌ 問題:session.user 可能還是 null,因為 initFromToken 是非同步的
    this.userName = session.user?.name || 'Guest'
  }
}

這也是為什麼書中寫了「data is always consumed after it becomes available and not the other way around」,隨著應用程式變大變複雜,這種執行順序的協調就會變得很困難。

// 必須精心安排初始化順序
async function initApp() {
  // 1. 先初始化設定
  const config = AppConfig.getInstance()
  await config.loadFromServer()

  // 2. 再初始化使用者 session
  const session = UserSession.getInstance()
  await session.initFromToken()

  // 3. 最後才能安全地建立其他元件
  const userProfile = new UserProfile()
  const apiService = new ApiService()
}

這種「必須按順序來」的限制,讓程式碼變得很脆弱,因為只要有一個地方沒有按照順序來,就會出現問題。

React 的另一條路:狀態管理工具

書裡特別提到一段話:

Developers using React for web development can rely on the global state through state management tools such as Redux or React Context instead of Singletons. Unlike Singletons, these tools provide a read-only state rather than the mutable state.

這段話的重點其實在於 React 其實用了完全不同的方式來解決「全域狀態」的問題

React Context:不用 Singleton 的全域狀態

// 用 Context 管理主題
const ThemeContext = createContext()

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')

  const value = {
    theme,
    setTheme,
    toggleTheme: () => setTheme(prev => prev === 'light' ? 'dark' : 'light')
  }

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  )
}

// 任何元件都能存取主題
function Header() {
  const { theme, toggleTheme } = useContext(ThemeContext)

  return (
    <header className={theme}>
      <button onClick={toggleTheme}>
        切換到
        {theme === 'light' ? '深色' : '淺色'}
        主題
      </button>
    </header>
  )
}

function App() {
  return (
    <ThemeProvider>
      <Header />
      <main>...</main>
    </ThemeProvider>
  )
}

React Context 與 Singleton 的差異

1. 明確的依賴關係

// Singleton:隱藏依賴
function HeaderWithSingleton() {
  const manager = ThemeManager.getInstance() // 看不出依賴關係
  return <header className={manager.currentTheme}>...</header>
}

// React Context:明確依賴
function HeaderWithContext() {
  const { theme } = useContext(ThemeContext) // 一眼就知道依賴 ThemeContext
  return <header className={theme}>...</header>
}

2. 更好的測試性

// 測試時可以提供 mock 的 context value
function renderWithTheme(component, themeValue = { theme: 'light' }) {
  return render(
    <ThemeContext.Provider value={themeValue}>
      {component}
    </ThemeContext.Provider>
  )
}

test('Header 在深色主題下的顯示', () => {
  renderWithTheme(<Header />, { theme: 'dark' })
  // 每個測試都是獨立的,不會互相影響
})

3. 不可變狀態(Immutable State)

「read-only state rather than mutable state」這個概念,我透過以下例子來理解:

// Singleton:可變狀態
const manager = ThemeManager.getInstance()
manager.currentTheme = 'dark' // 直接修改,可能造成意外的副作用

// React:不可變狀態
const { theme, setTheme } = useContext(ThemeContext)
// theme 是唯讀的,只能透過 setTheme 來改變
// setTheme('dark') // 會觸發重新渲染,狀態更新是可預測的

4. 作用域控制

// Singleton:全域作用域,整個應用程式都會受影響
const manager = ThemeManager.getInstance()

// React Context:可以控制作用域
function AdminPanel() {
  return (
    <ThemeProvider>
      {/* 只有這個區域有獨立的主題狀態 */}
      <AdminHeader />
      <AdminContent />
    </ThemeProvider>
  )
}

Redux 也是類似的概念

Redux 更進一步,用 reducer 和 action 來管理狀態:

// Redux store 具有 singleton-like 特性(通常只建立一個),但狀態管理方式完全不同
const store = createStore(themeReducer)

// 狀態是不可變的,只能透過 dispatch action 來改變
store.dispatch({ type: 'SET_THEME', payload: 'dark' })

// 元件透過 useSelector 取得狀態
function Header() {
  const theme = useSelector(state => state.theme)
  const dispatch = useDispatch()

  return (
    <header className={theme}>
      <button onClick={() => dispatch({ type: 'TOGGLE_THEME' })}>
        切換主題
      </button>
    </header>
  )
}

重點整理:五步學習卡

1) 問題來源

需要全域唯一實例時:

  • 多個實例會導致狀態不一致
  • 重複的資源浪費(事件監聽、DOM 操作)
  • 全域服務的通訊機制失效

👉 需要確保某些類別只能有一個實例。

2) 白話描述

Singleton Pattern 就像「整個應用程式共用一個遙控器」:

  • 不管哪個功能要控制系統,用的都是同一個遙控器
  • 所有的設定變更、狀態調整都通過這個唯一的遙控器
  • 避免多個遙控器各自操作、造成設定混亂

3) 專業術語

在 JavaScript 裡,Singleton Pattern 透過控制 constructor 的行為,確保一個類別在整個應用程式中只能有一個實例,並提供全域存取點。通常會在 constructor 中檢查是否已有實例,有的話就回傳現有實例。

4) 視覺化

Singleton Pattern:不管在哪裡 new,拿到的都是同一個實例,狀態完全同步。

graph TD
    A[new AppConfig] --> D[AppConfig 實例]
    B[new AppConfig] --> D
    C[new AppConfig] --> D

    D --> E[apiUrl: 'https://api.example.com']
    D --> F[theme: 'light']
    D --> G[updateConfig方法]

    style D fill:#5c4033,stroke:#d0d0d0,stroke-width:2px,color:#ffffff
    style A fill:#4a5a6b,stroke:#d0d0d0,stroke-width:2px,color:#ffffff
    style B fill:#4a5a6b,stroke:#d0d0d0,stroke-width:2px,color:#ffffff
    style C fill:#4a5a6b,stroke:#d0d0d0,stroke-width:2px,color:#ffffff

5) 程式碼與適用情境

適用情境

  • 全站只需要一份:例如主題、語言、Feature Flags 等統一的設定。
  • 要有一個共同入口來協調:像 WebSocket 連線、快取管理、第三方服務整合等都應該集中控管。
  • 建立成本高或容易出錯:如 Intl 格式器、ImageDecoderAudioContext、WebGL/Canvas 相關物件等,做成共用實例避免重複建立。
  • 大家都得走同一條管道:例如日誌/事件/遙測(Telemetry)的收集與上傳。

不適用情境

  • 需要多份並存而且互相隔離:A/B 測試、不同分頁/iframe。
  • 每次都要乾淨的全新狀態:SSR 中每個使用者請求、測試需要獨立環境。
  • 用區域狀態或純函式就能解:不必做成全域共用。
  • 未來要容易替換實作或打假資料:工廠/依賴注入會比 Singleton 更適合。
  • 會在多個執行環境各自載入:微前端、Worker、不同 bundle;Singleton 只在各自環境內唯一。

程式碼

let instance = null

class AppConfig {
  constructor() {
    if (instance) {
      throw new Error('Can\'t create a new instance of AppConfig. Use AppConfig.getInstance() instead.')
    }

    this.apiBaseUrl = 'https://api.example.com'
    this.theme = 'light'
    this.language = 'zh-tw'
    this.debugMode = false
    this.init()

    instance = this

    Object.freeze(this)
    return this
  }

  static getInstance() {
    if (!instance) {
      return new AppConfig()
    }
    return instance
  }

  init() {
    const savedConfig = localStorage.getItem('appConfig')
    if (savedConfig) {
      const config = JSON.parse(savedConfig)
      Object.assign(this, config)
    }
  }
}
const config = AppConfig.getInstance()

後記

寫這一篇的時候,不禁回想起之前與資深工程師討論系統設計時,他提到 Singleton,也說到不好寫測試的問題。當時是我第一次聽到這個詞,當我問他什麼是 Singleton 時,他有些驚訝,然後很耐心地跟我解釋。現在回想起來,當時聽完他的解釋我其實還是矇矇懂懂的。

直到現在透過閱讀和整理筆記,我才真正理解了 Singleton 的概念。也讓我體會到,有時候一個概念需要時間沉澱和多次接觸才能真正消化。

感謝你讀到這裡!如果我的分享讓你有所共鳴或啟發,歡迎請我喝杯珍奶 🧋,給我一點繼續分享的能量: https://anna.bobaboba.me