普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月15日首页

单例模式渐进式学习指南

作者 Devin_chen
2026年4月15日 16:22

单例模式渐进式学习指南

面向前端开发者,从“看懂概念”到“能写能辨别”,一步步掌握设计模式中的单例模式。


目录

  1. 什么是单例模式?
  2. 为什么前端里需要单例?
  3. 先从最小例子理解“唯一实例”
  4. 单例模式的标准结构
  5. 前端中常见的单例场景
  6. 几种常见实现方式
  7. 单例模式的优点与缺点
  8. 使用单例时的常见误区
  9. 面试中怎么回答单例模式
  10. 练习题与思考题
  11. 学习总结

一、什么是单例模式?

单例模式(Singleton Pattern)是一种创建型设计模式

它的核心目标只有一句话:

保证一个类、一个对象工厂、或一个功能模块在系统中只有一个实例,并提供一个全局访问点。

你可以把它理解成:

  • 系统里这个对象只能创建一次
  • 后面再获取时,拿到的都是同一个对象
  • 大家共用它,而不是每次都 new 一个新的

生活类比

可以把单例想象成:

  • 浏览器里的 window
  • 页面中的全局配置中心
  • 整个项目里唯一的消息提示组件管理器
  • 唯一的缓存中心

这些东西通常不需要来一个人就建一个新的,否则系统会乱套。

单例的两个关键词

关键词 含义
唯一实例 无论调用多少次,都只有一个对象
全局访问 任何需要它的地方都能拿到同一个对象

二、为什么前端里需要单例?

很多初学者会有个疑问:

前端不就是写页面吗?为什么还要学设计模式?

其实前端项目一旦变大,就会出现很多“全局唯一资源”的问题。

常见需求

  • 全局只有一个登录弹窗
  • 全局只有一个消息通知容器
  • 全局只有一个请求管理器
  • 全局只有一个事件总线实例
  • 全局只有一个缓存对象
  • 全局只有一个状态管理容器入口

如果每次使用都重新创建:

  • 会造成资源浪费
  • 会引发状态不一致
  • 会让调试复杂度上升
  • 甚至会出现界面重复渲染、重复请求等问题

一个典型问题

比如你写一个全局弹窗:

function createModal() {
  return {
    show() {
      console.log('弹窗打开')
    },
  }
}

const modal1 = createModal()
const modal2 = createModal()

console.log(modal1 === modal2) // false

这里 modal1modal2 不是同一个对象。

这意味着:

  • 你可能创建了多个弹窗实例
  • 每个实例的状态互不相通
  • 页面上可能冒出多个重复弹窗

这时候,单例模式就登场了。


三、先从最小例子理解“唯一实例”

普通写法:每次都创建新对象

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 个部分:

  1. 私有实例缓存:记录是否已经创建过对象
  2. 创建逻辑:第一次使用时创建对象
  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:实现一个单例缓存对象

要求:

  • 只能创建一个缓存实例
  • 提供 setget 方法

你可以自己先暂停 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 句话

  1. 单例模式的核心是:一个实例、全局访问。
  2. 前端中凡是“全局唯一资源”,都值得考虑单例。
  3. 现代前端里最常见的单例形式,其实是模块单例。
  4. 不要滥用单例,能局部化的状态就不要硬塞成全局。

一张速记表

问题 结论
单例模式是什么? 保证对象只有一个实例
适合什么场景? 全局配置、消息提示、请求实例、缓存中心
常见实现方式? 闭包、类静态属性、ES Module
最大风险是什么? 全局耦合、状态污染、测试困难
判断标准是什么? 这个对象是否真的应该全局唯一
❌
❌