Singleton Pattern:讓全應用程式共用一個遙控器
此為《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 個物件(如果要保持一致的話)
問題的核心:缺乏唯一性
上面的例子顯示了一個問題:應用程式設定這種全域性質的服務,如果允許多個實例存在,會產生什麼後果?
- 狀態分裂:每個實例都有自己的狀態,無法保持一致
- 資源重複:重複的事件監聽、DOM 操作、記憶體佔用
- 通訊失效:實例間無法互相通知,訂閱機制失效
- 行為衝突:多個實例同時操作同一個 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
格式器、ImageDecoder
、AudioContext
、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 的概念。也讓我體會到,有時候一個概念需要時間沉澱和多次接觸才能真正消化。