单例模式渐进式学习指南
单例模式渐进式学习指南
面向前端开发者,从“看懂概念”到“能写能辨别”,一步步掌握设计模式中的单例模式。
目录
- 什么是单例模式?
- 为什么前端里需要单例?
- 先从最小例子理解“唯一实例”
- 单例模式的标准结构
- 前端中常见的单例场景
- 几种常见实现方式
- 单例模式的优点与缺点
- 使用单例时的常见误区
- 面试中怎么回答单例模式
- 练习题与思考题
- 学习总结
一、什么是单例模式?
单例模式(Singleton Pattern)是一种创建型设计模式。
它的核心目标只有一句话:
保证一个类、一个对象工厂、或一个功能模块在系统中只有一个实例,并提供一个全局访问点。
你可以把它理解成:
- 系统里这个对象只能创建一次
- 后面再获取时,拿到的都是同一个对象
- 大家共用它,而不是每次都
new一个新的
生活类比
可以把单例想象成:
- 浏览器里的
window - 页面中的全局配置中心
- 整个项目里唯一的消息提示组件管理器
- 唯一的缓存中心
这些东西通常不需要来一个人就建一个新的,否则系统会乱套。
单例的两个关键词
| 关键词 | 含义 |
|---|---|
| 唯一实例 | 无论调用多少次,都只有一个对象 |
| 全局访问 | 任何需要它的地方都能拿到同一个对象 |
二、为什么前端里需要单例?
很多初学者会有个疑问:
前端不就是写页面吗?为什么还要学设计模式?
其实前端项目一旦变大,就会出现很多“全局唯一资源”的问题。
常见需求
- 全局只有一个登录弹窗
- 全局只有一个消息通知容器
- 全局只有一个请求管理器
- 全局只有一个事件总线实例
- 全局只有一个缓存对象
- 全局只有一个状态管理容器入口
如果每次使用都重新创建:
- 会造成资源浪费
- 会引发状态不一致
- 会让调试复杂度上升
- 甚至会出现界面重复渲染、重复请求等问题
一个典型问题
比如你写一个全局弹窗:
function createModal() {
return {
show() {
console.log('弹窗打开')
},
}
}
const modal1 = createModal()
const modal2 = createModal()
console.log(modal1 === modal2) // false
这里 modal1 和 modal2 不是同一个对象。
这意味着:
- 你可能创建了多个弹窗实例
- 每个实例的状态互不相通
- 页面上可能冒出多个重复弹窗
这时候,单例模式就登场了。
三、先从最小例子理解“唯一实例”
普通写法:每次都创建新对象
function createUserStore() {
return {
name: 'frontend-store',
}
}
const store1 = createUserStore()
const store2 = createUserStore()
console.log(store1 === store2) // false
单例写法:始终返回同一个对象
function createSingleUserStore() {
let instance = null
return function () {
if (!instance) {
instance = {
name: 'frontend-store',
}
}
return instance
}
}
const getStore = createSingleUserStore()
const store1 = getStore()
const store2 = getStore()
console.log(store1 === store2) // true
这段代码发生了什么?
核心在这里:
let instance = null
它会把第一次创建出来的对象缓存起来。
后续再调用时:
- 如果
instance不存在,就创建 - 如果
instance已存在,就直接返回
于是无论调用多少次,拿到的都是同一个对象。
一句话:单例不是“不让你调用”,而是“让你重复调用时仍然拿到同一个实例”。
四、单例模式的标准结构
虽然前端里未必真的写“类”,但你最好知道它的标准思想。
结构拆解
一个典型的单例通常包含 3 个部分:
- 私有实例缓存:记录是否已经创建过对象
- 创建逻辑:第一次使用时创建对象
- 访问入口:外部通过统一方法获取实例
用类的方式理解
class Singleton {
constructor() {
if (Singleton.instance) {
return Singleton.instance
}
this.data = '唯一实例'
Singleton.instance = this
}
}
const s1 = new Singleton()
const s2 = new Singleton()
console.log(s1 === s2) // true
更推荐前端中理解成“模块级唯一对象”
在现代前端中,很多单例并不是通过 class 写出来的,而是通过 模块缓存机制 自然形成的。
// config.js
const config = {
apiBaseUrl: 'https://api.example.com',
timeout: 5000,
}
export default config
// a.js
import config from './config.js'
// b.js
import config from './config.js'
因为 ES Module 会缓存模块实例,所以多个文件导入同一个模块时,通常拿到的是同一份模块对象。
这也是前端里最常见、最自然的“单例感”来源。
五、前端中常见的单例场景
这一部分最重要,因为真正写业务时,你不是为了“背定义”而用单例,而是为了解决全局唯一资源管理问题。
1. 全局消息提示(Message / Toast)
很多 UI 库里的全局提示本质就是单例。
class Message {
constructor() {
this.queue = []
}
show(text) {
this.queue.push(text)
console.log('消息:', text)
}
}
let messageInstance = null
export function getMessageInstance() {
if (!messageInstance) {
messageInstance = new Message()
}
return messageInstance
}
使用时:
const message1 = getMessageInstance()
const message2 = getMessageInstance()
message1.show('保存成功')
console.log(message1 === message2) // true
2. 全局弹窗管理器
如果每点击一次按钮都创建一个弹窗管理器,页面就可能出现多个重复节点。
单例的好处是:
- 整个应用只维护一个弹窗容器
- 状态统一管理
- DOM 节点不会重复创建
3. 请求管理器 / API 客户端
比如你封装了一个请求实例:
class RequestService {
constructor(baseURL) {
this.baseURL = baseURL
}
get(url) {
console.log(`GET: ${this.baseURL}${url}`)
}
}
let requestInstance = null
export function getRequestService() {
if (!requestInstance) {
requestInstance = new RequestService('/api')
}
return requestInstance
}
这样做可以统一:
- baseURL
- 请求拦截器
- token 注入
- 错误处理策略
4. 缓存中心
const cache = {
data: new Map(),
set(key, value) {
this.data.set(key, value)
},
get(key) {
return this.data.get(key)
},
}
export default cache
这本质上也是一个单例对象。
5. 状态共享对象
某些轻量项目不用 Pinia / Redux,也会自己写一个全局 store。
const store = {
state: {
userInfo: null,
},
setUser(user) {
this.state.userInfo = user
},
}
export default store
所有页面共享同一份 store,这就是一种模块单例。
六、几种常见实现方式
方式一:闭包实现单例(最适合入门)
function createSingleton() {
let instance = null
return function () {
if (!instance) {
instance = {
id: Date.now(),
}
}
return instance
}
}
const getInstance = createSingleton()
const obj1 = getInstance()
const obj2 = getInstance()
console.log(obj1 === obj2) // true
优点
- 容易理解
- 不依赖 class
- 很适合讲清“缓存实例”的本质
缺点
- 如果逻辑复杂,代码可维护性一般
方式二:类 + 静态属性
class LoginDialog {
static instance = null
constructor() {
if (LoginDialog.instance) {
return LoginDialog.instance
}
this.visible = false
LoginDialog.instance = this
}
open() {
this.visible = true
console.log('登录弹窗打开')
}
}
const dialog1 = new LoginDialog()
const dialog2 = new LoginDialog()
console.log(dialog1 === dialog2) // true
优点
- 结构清晰
- 更贴近传统设计模式写法
- 适合面试表达
缺点
- 对前端业务代码来说有时略显“重”
方式三:模块单例(现代前端最常见)
// auth-store.js
const authStore = {
token: '',
setToken(token) {
this.token = token
},
}
export default authStore
import authStore from './auth-store.js'
为什么它是单例?
因为模块只会初始化一次,后续导入拿到的是同一个模块实例引用。
优点
- 写法最自然
- 非常适合工程化项目
- 不需要显式写
getInstance
缺点
- 初学者可能“用了单例却没意识到自己在用单例”
方式四:惰性单例(Lazy Singleton)
惰性单例指的是:
不在一开始就创建实例,而是在第一次真正需要时才创建。
function getModal() {
if (!getModal.instance) {
getModal.instance = {
createdAt: Date.now(),
show() {
console.log('显示 modal')
},
}
}
return getModal.instance
}
这种方式很常见,因为很多全局对象并不一定在页面加载时就需要。
惰性单例的意义
- 减少初始加载开销
- 按需创建资源
- 更适合弹窗、通知、复杂组件容器
七、单例模式的优点与缺点
任何设计模式都不是“银弹”,单例也一样。
优点
1. 节省资源
只创建一次对象,避免重复初始化。
2. 统一状态管理
所有地方访问的都是同一份实例,状态天然一致。
3. 便于全局协调
适合处理:
- 全局配置
- 全局弹窗
- 全局缓存
- 全局事件中心
4. 减少重复代码
不必每次都手动创建和管理相同对象。
缺点
1. 全局状态过多会让系统变复杂
一旦所有东西都做成单例,项目就会慢慢变成“全局变量乐园”。这可不是什么嘉年华。
2. 测试不友好
单例在测试中容易产生状态污染。
比如:
- 上一个测试改了实例状态
- 下一个测试拿到的还是同一个实例
- 测试之间互相影响
3. 模块耦合增强
很多模块都依赖某个全局单例时,重构会变困难。
4. 容易被滥用
不是“全局都能访问”就该用单例,只有确实应该全局唯一时才适合。
八、使用单例时的常见误区
误区 1:把普通工具函数也做成单例
比如一个纯函数工具库:
function formatDate(date) {
return String(date)
}
这种函数没有状态,不需要单例。
没有状态、没有初始化成本、没有唯一资源约束的对象,通常没必要单例化。
误区 2:把“全局可访问”误认为“必须单例”
全局可访问 ≠ 必须只有一个实例。
比如:
- 表单校验器可能每个表单都应该有独立实例
- 图表对象可能每个图表容器都应该各自创建
误区 3:忽略实例重置能力
在测试或热更新环境中,有些单例需要支持重置,否则状态会残留。
let instance = null
export function getInstance() {
if (!instance) {
instance = { count: 0 }
}
return instance
}
export function resetInstance() {
instance = null
}
误区 4:把单例当作“解决一切共享问题”的万能方案
如果共享状态越来越复杂,应该考虑:
- 状态管理库(Pinia / Redux / Zustand)
- 依赖注入
- 组合式函数(composables)
- 上下文容器
单例是工具,不是宗教。
九、面试中怎么回答单例模式
如果面试官问:
你怎么理解单例模式?前端中有哪些应用?
你可以这样回答:
标准回答模板
单例模式是一种创建型设计模式,核心是保证某个对象在系统中只有一个实例,并提供统一的访问入口。
在前端开发中,它常用于管理全局唯一资源,比如:
- 全局弹窗
- 消息提示组件
- 请求实例
- 缓存对象
- 全局配置对象
实现方式通常有:
- 闭包缓存实例
- 类的静态属性保存实例
- ES Module 天然单例
它的优点是节省资源、统一状态;缺点是容易带来全局耦合、测试困难,因此要谨慎使用,避免滥用。
如果面试官继续追问:ES Module 算不算单例?
你可以回答:
在工程实践里,很多模块导出的对象会因为模块缓存机制而表现出单例特征,所以它是一种非常常见的“模块级单例”实现方式。
如果继续追问:单例和全局变量有什么区别?
你可以回答:
- 全局变量只是“所有地方都能访问”
- 单例模式强调“唯一实例 + 可控访问入口 + 创建时机管理”
所以单例比裸露的全局变量更有结构,也更便于维护。
十、练习题与思考题
练习 1:实现一个单例缓存对象
要求:
- 只能创建一个缓存实例
- 提供
set和get方法
你可以自己先暂停 5 分钟写一下,再参考下面思路:
function createCacheSingleton() {
let instance = null
return function () {
if (!instance) {
instance = {
data: new Map(),
set(key, value) {
this.data.set(key, value)
},
get(key) {
return this.data.get(key)
},
}
}
return instance
}
}
练习 2:实现一个全局登录弹窗管理器
要求:
- 整个应用中只能有一个登录弹窗实例
- 支持
open()和close()
练习 3:思考哪些场景不适合单例
请判断以下对象是否适合单例,并说明理由:
- 每个页面一个轮播图实例
- 全局埋点上报管理器
- 每个表格一个筛选器对象
- 全局请求客户端
- 每个图表一个图表实例
参考答案方向
| 对象 | 是否适合单例 | 原因 |
|---|---|---|
| 每个页面一个轮播图实例 | 不适合 | 每个轮播图通常是独立的 |
| 全局埋点上报管理器 | 适合 | 全局统一上报规则和缓存队列 |
| 每个表格一个筛选器对象 | 不适合 | 每个表格状态独立 |
| 全局请求客户端 | 适合 | 请求配置、拦截器应统一 |
| 每个图表一个图表实例 | 不适合 | 每个容器对应独立实例 |
十一、学习总结
你应该记住的 4 句话
- 单例模式的核心是:一个实例、全局访问。
- 前端中凡是“全局唯一资源”,都值得考虑单例。
- 现代前端里最常见的单例形式,其实是模块单例。
- 不要滥用单例,能局部化的状态就不要硬塞成全局。
一张速记表
| 问题 | 结论 |
|---|---|
| 单例模式是什么? | 保证对象只有一个实例 |
| 适合什么场景? | 全局配置、消息提示、请求实例、缓存中心 |
| 常见实现方式? | 闭包、类静态属性、ES Module |
| 最大风险是什么? | 全局耦合、状态污染、测试困难 |
| 判断标准是什么? | 这个对象是否真的应该全局唯一 |