单例模式渐进式学习指南

5 阅读12分钟

单例模式渐进式学习指南

写给前端开发者的一篇入门笔记:先弄明白单例到底是什么,再看它在项目里什么时候该用、什么时候别乱用。


目录

  1. 什么是单例模式?
  2. 为什么前端里会遇到单例?
  3. 先用一个最小例子看懂它
  4. 单例模式的基本结构
  5. 前端里常见的单例场景
  6. 几种常见实现方式
  7. 单例模式的优点和问题
  8. 几个很容易踩的坑
  9. 如果放到面试里,怎么讲比较自然
  10. 练习题
  11. 最后做个收束

一、什么是单例模式?

单例模式(Singleton Pattern)属于创建型设计模式。它讲的不是“怎么把代码写复杂”,而是一个很朴素的问题:

某个对象在系统里是不是只需要有一份?

如果答案是“对,它就该只有一个”,那就很可能会用到单例模式。

单例模式的核心可以概括成两点:

  • 只有一个实例
  • 外部可以稳定地拿到这同一个实例

前端里比较好理解的例子有:

  • 浏览器环境下的 window
  • 项目里的全局配置对象
  • 全局消息提示组件的管理器
  • 全局缓存中心

这些对象如果每次用都重新创建,通常不是更灵活,而是更容易把状态搞乱。


二、为什么前端里会遇到单例?

很多人第一次学设计模式时会觉得它离业务有点远,但单例其实离前端非常近。项目一旦稍微复杂一点,“全局只该有一份”的东西就会自然出现。

比如下面这些场景:

  • 一个应用里通常只会有一个登录弹窗容器
  • 消息提示组件一般只需要一个统一出口
  • 请求客户端通常希望共用同一套拦截器和配置
  • 缓存对象希望各个页面拿到的是同一份数据源
  • 某些轻量级全局状态对象本身就只有一份

如果这些东西每次用都重新创建,后果通常不难想象:

  • 资源被重复占用
  • 状态互相不一致
  • 调试越来越费劲
  • 页面上甚至会出现重复挂载、重复请求、重复渲染

看个最简单的例子。假设你写了一个全局弹窗工厂:

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

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

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

这意味着你每调用一次 createModal(),都在得到一个全新的对象。写 demo 的时候问题不大,真到了项目里,这种“各管各的”通常会演变成状态混乱现场。


三、先用一个最小例子看懂它

先别急着背定义,看代码更直接。

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

function createUserStore() {
  return {
    name: 'frontend-store',
  }
}

const store1 = createUserStore()
const store2 = createUserStore()

console.log(store1 === store2) // false

这里的 store1store2 只是内容一样,本质上并不是同一个对象。

单例写法:始终返回同一个对象

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 还没有值,于是创建对象并保存起来;后面再调用,就不重新创建了,直接把之前那份返回出去。

所以单例真正做的事不是“限制你调用”,而是“无论你调用多少次,最后拿到的都是那一份”。


四、单例模式的基本结构

虽然前端里很多时候并不会专门写一个“标准教科书式单例类”,但它的思路其实一直都差不多:

  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

不过在现代前端项目里,更常见的其实是模块级单例。也就是说,你没有刻意去写“单例模式”四个字,但因为模块本身只会初始化一次,它天然就带着一点单例特征。

// 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'

a.jsb.js 拿到的通常都是同一份模块对象。这也是为什么很多前端项目虽然没刻意“实现单例”,但实际上早就在用它了。


五、前端里常见的单例场景

学设计模式最怕的一件事,就是只记住定义,却不知道在业务里什么时候能碰到它。单例如果脱离场景去讲,确实会显得有点空;一旦放到项目里,就会立刻具体起来。

1. 全局消息提示

很多 UI 库里的全局 Message / Toast,本质上就是单例思路。它们通常只保留一个消息管理器,不会每调用一次就重新造一套系统。

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 客户端

请求实例经常会共享一套配置,比如 baseURL、token 注入、请求拦截器、响应拦截器、统一错误处理。既然逻辑是统一的,保留一份实例往往更合适。

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
}

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. 轻量级全局状态对象

不是每个项目都需要上状态管理库。有些小项目会手写一个简单的全局 store,这种写法本身就带有模块单例的特点。

const store = {
  state: {
    userInfo: null,
  },
  setUser(user) {
    this.state.userInfo = user
  },
}

export default 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 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'

之所以说它是单例,是因为模块通常只会初始化一次,后续导入拿到的是同一份引用。

这种方式的好处是自然、简单、几乎没有额外心智负担。很多前端工程里的“单例”其实都是这么来的。

方式四:惰性单例

有些对象不是一上来就要创建,只有真的用到的时候才值得初始化。这时候可以用惰性单例。

function getModal() {
  if (!getModal.instance) {
    getModal.instance = {
      createdAt: Date.now(),
      show() {
        console.log('显示 modal')
      },
    }
  }

  return getModal.instance
}

这种写法的意义在于按需创建:

  • 首屏少做一点事
  • 真正用到时再初始化
  • 很适合弹窗、通知、复杂容器这种场景

七、单例模式的优点和问题

单例模式有它好用的地方,但也很容易被用过头。

它的好处

1. 节省资源 对象只创建一次,避免重复初始化。

2. 状态统一 所有地方访问的都是同一个实例,状态天然一致。

3. 便于集中管理 像全局配置、全局弹窗、缓存中心、事件中心这类东西,集中管理通常比到处散落更好维护。

4. 减少重复代码 不用每个模块都各自 new 一遍。

它的问题

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 算不算单例”,可以补一句:

严格说它不是所有场景下都等于经典 GoF 单例,但在前端工程实践里,模块只初始化一次、后续拿到同一份引用,这种行为很接近模块级单例。

如果被追问 “单例和全局变量的区别”,可以这样区分:

  • 全局变量强调的是“哪里都能访问”
  • 单例强调的是“只有一份实例,而且访问入口可控”

所以单例通常比直接裸放一个全局变量更有约束,也更容易维护一点。


十、练习题

练习 1:实现一个单例缓存对象

要求:

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

可以先自己写一版,再对照下面这个思路:

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:判断下面这些对象适不适合做成单例

  • 每个页面一个轮播图实例
  • 全局埋点上报管理器
  • 每个表格一个筛选器对象
  • 全局请求客户端
  • 每个图表一个图表实例

可以先自己判断,再参考下面这个方向:

对象是否适合单例原因
每个页面一个轮播图实例不适合每个轮播图通常是独立的
全局埋点上报管理器适合统一上报规则和缓存队列
每个表格一个筛选器对象不适合每个表格状态独立
全局请求客户端适合请求配置和拦截器适合统一管理
每个图表一个图表实例不适合每个容器应对应自己的实例

十一、最后做个收束

如果只记一句话,那就是:

单例模式适合那些在系统里本来就应该只有一份的对象。

学单例最重要的不是记住几种写法,而是先判断“它到底该不该只有一个”。这个问题判断对了,代码实现往往反而不难。

最后留一个很实用的判断标准:

  • 如果这个对象需要统一配置、统一状态、统一出口,它往往适合单例
  • 如果这个对象天然应该跟页面、组件、容器一一对应,那大概率就不适合单例

理解到这里,再去看前端框架里的全局消息、全局弹窗、请求客户端、模块状态对象,很多设计就不会显得那么抽象了。