单例模式(Singleton)

核心要点

  • 定义:一个类在整个程序里只允许存在一个实例,并且对外提供一个统一的入口来拿到它。
  • 关键能力:类(或者它的工厂函数)要能"记住"自己是否已经造过对象。第一次调用时创建并缓存,之后每次调用都直接把缓存的那一个交出去,调用多少次、谁来调用,拿到的都是同一个引用。
  • 两种经典写法:
    • 静态方法 getInstance,把实例存在类的静态属性上;
    • 闭包(IIFE)里放一个自由变量充当"私有的缓存",返回一个负责判断的函数。
    • 现代 JS 还可以用 static #instance 私有字段、在构造函数里拦截 new,或者干脆利用 ES Module 只执行一次的特性。
  • 前端里的典型应用:Vuex / Redux 的全局 Store、插件的 install 防重复安装、全局弹框、全局的请求实例 / 日志器 / 配置对象。
  • Vuex 的做法:install 里用一个模块级变量记住已经装过的 Vue,同一个 Vue 再装一次就直接 return(开发环境顺便报错提示),保证一个应用只有一套 Store 注入逻辑。这道判断是 Vue.use 自带去重之外的第二层保险;两层都失效时,重复安装会让全局混入叠加、注入逻辑重复执行(常被简化成"Store 重新初始化、状态归零"的说法)。
  • 两道常考题:基于 localStorage 封装一个单例 Storage(静态方法版 + 闭包版都要会写);实现一个全局唯一的 Modal 弹框(按需创建、反复复用同一个 DOM 节点)。
  • 要注意的坑:实例别挂在 this 上(那样每个对象各存一份,不是单例);单例是全局可变状态,会给单元测试和 SSR 带来麻烦。

单例到底解决什么问题

有些对象天然"只该有一个":

场景如果出现多份会怎样
全局状态仓库(Store)组件 A 改了自己那份,组件 B 读的是另一份,数据对不上
本地存储的封装每处 new 一个包装对象,浪费内存,配置(前缀、过期策略)也可能不一致
全局弹框 / 遮罩每点一次按钮就往 body 里塞一个新节点,页面上叠了一堆
日志器、埋点 SDK、WebSocket 连接重复连接、重复上报

单例模式给出的方案很朴素:创建对象这件事交给一个入口统一把关。入口先检查"之前有没有造过",没有才 new,有就把旧的返回。

单例入口的判断流程:首次调用创建并缓存实例,之后的调用全部拿到同一个缓存对象

实现方式

一个常见的错误写法

先看一个看起来像单例、其实不是的版本:

class Printer {
  constructor(model) {
    this.model = model;
    this.cache = null;          // 缓存放在了"实例"上
  }
  acquire(model) {
    if (!this.cache) this.cache = new Printer(model);
    return this.cache;
  }
}

const p1 = new Printer().acquire('HP');
const p2 = new Printer().acquire('Canon');
console.log(p1 === p2);         // false

问题出在 this.cache:它属于某一个具体对象,每 new Printer() 一次就多一份独立的缓存。只有恰好一直复用同一个 Printer 对象去调 acquire 时才"像"单例。而且外面还得先 new 一个对象才能拿单例,这本身就和"只要一个实例"矛盾。

正确的做法是把缓存放在所有调用方共享的位置:类本身(静态属性)或者闭包里。

写法一:静态方法

class AppConfig {
  static getInstance() {
    // 没造过就造一个,造过就直接用
    if (!AppConfig.instance) {
      AppConfig.instance = new AppConfig();
    }
    return AppConfig.instance;
  }

  describe() {
    return `theme=${this.theme ?? 'light'}`;
  }
}

const c1 = AppConfig.getInstance();
const c2 = AppConfig.getInstance();

c1.theme = 'dark';
console.log(c2.describe());     // theme=dark,改的是同一个对象
console.log(c1 === c2);         // true

static 方法挂在类上,不需要先创建对象就能调用;AppConfig.instance 也是类级别的属性,所有调用方共享。不管调用多少次 getInstance,经过这层判断拦截后,c1、c2 指向的都是那唯一一个对象。

判断逻辑写在静态方法里还是写进构造函数里都可以,后面会给出构造函数的版本。

写法二:闭包

把缓存藏进立即执行函数的作用域,外部根本访问不到,相当于一个私有变量:

AppConfig.getInstance = (() => {
  let shared;                   // 自由变量,只有下面这个函数能读写
  return () => {
    if (shared === undefined) shared = new AppConfig();
    return shared;
  };
})();

console.log(AppConfig.getInstance() === AppConfig.getInstance()); // true

和静态属性版相比,闭包版的好处是缓存不会被外部直接改掉(静态属性版里任何人都可以写 AppConfig.instance = null 把它重置)。

写法三:现代 JS 的写法

ES2022 起类支持私有字段,可以把缓存藏在 static # 里,并且在构造函数里拦截,让直接 new 也拿到同一个对象:

class Logger {
  static #only = null;
  #lines = [];

  constructor() {
    // 已经有实例了:返回它,替换掉这次 new 出来的新对象
    if (Logger.#only) return Logger.#only;
    Logger.#only = this;
  }

  static getInstance() {
    return Logger.#only ?? new Logger();
  }

  log(msg) {
    this.#lines.push(msg);
    return this.#lines.length;
  }
}

const l1 = new Logger();
const l2 = new Logger();
l1.log('boot');
console.log(l1 === l2);                       // true
console.log(l2.log('ready'));                 // 2,两条日志记在同一个对象里
console.log(Logger.getInstance() === l1);     // true

这里用到一条语言规则:构造函数如果显式 return 一个对象,new 表达式的结果就是这个对象,而不是新建的 this。后面闭包版 Storage 能用 new 调用,也是同一个原理。

另外,在模块化的工程里,最省事的单例其实是 ES Module 本身:模块只会被求值一次,所有 import 拿到的是同一个导出对象。

// request.js
import axios from 'axios';
export const http = axios.create({ baseURL: '/api', timeout: 8000 });

// 任何文件 import { http } from './request' 拿到的都是同一个实例

饿汉和懒汉

懒汉式(lazy)饿汉式(eager)
创建时机第一次调用 getInstance 时才创建类 / 模块加载时立刻创建
优点用不到就不占资源写法最简单,没有判断分支
例子上面的 getInstance、后面的弹框模块顶层直接 export const store = createStore()

JS 是单线程的,懒汉式不需要像 Java 那样考虑多线程下的"双重检查加锁"。不过如果创建过程是异步的(比如要先 await 建立连接),要缓存的是 Promise 而不是结果,否则两个几乎同时的调用会各自发起一次创建。

生产实践:Vuex 的 Store 为什么只有一个

Redux 和 Vuex 都把整个应用的状态集中放进一个全局 Store,这个 Store 就是单例思想在前端最典型的落地。

Store 为什么要唯一

Vuex 官方文档对这点的解释是:Vuex 采用单一状态树,一个对象装下应用的全部层级状态,作为"唯一数据源(SSOT, Single Source of Truth)"存在,所以每个应用只包含一个 store 实例。好处是任何一块状态都能直接定位到,调试时也能一次性拿到整个应用当前状态的快照。

为什么需要这样一个集中的地方?组件少的时候,父子之间用 props 和事件传数据就够了。可一旦组件很多、关系交错、嵌套又深,层层传递会让逻辑越来越难维护。更好的办法是把多个组件共享的数据抽出来放到全局,让组件按照约定好的规则去读写,状态的变化就变得可预测。Vuex 就是干这件事的,而那个存放共享数据的唯一数据源,就是 Store。

Vuex 怎么保证 Store 唯一

在 Vue 2 项目里接入 Vuex 一般是这样:

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);                         // 安装插件

const store = new Vuex.Store({ state: { count: 0 } });

new Vue({
  el: '#app',
  store,                               // 注入根实例,子组件通过 this.$store 访问
});

Vue.use() 会调用插件对象上的 install 方法。Vuex 的 install 负责把 Store 的注入逻辑挂到 Vue 上(通过全局混入,在每个组件的 beforeCreate 里设置 this.$store)。换句话说,每执行一次 install,就会尝试再给 Vue 注入一次 Store。

Vuex 3 源码里的 install 长这样(节选,注释是我们加的):

let Vue // 模块级变量:记住已经装过 Vuex 的那个 Vue 构造函数

export function install (_Vue) {
  // 同一个 Vue 已经装过了:开发环境报错提示,然后直接退出
  if (Vue && _Vue === Vue) {
    if (__DEV__) {
      console.error(
        '[vuex] already installed. Vue.use(Vuex) should be called only once.'
      )
    }
    return
  }
  // 第一次安装:记下来,再把初始化逻辑混入 Vue 的生命周期钩子
  Vue = _Vue
  applyMixin(Vue)
}

模块顶部的 let Vue 扮演的正是前面 getInstance 里 instance 的角色:第一次进来时记下,之后同一个 Vue 再来就被拦在门外。于是一个 Vue 应用只会被装一次 Vuex,也就只有一个全局 Store。

补充两点:

  1. Vue 自己的 Vue.use 也会记录装过的插件(installedPlugins),重复 use 同一个插件在 Vue 这一层就被挡掉了;Vuex 内部的这道判断是一层额外保险,针对的是绕过 Vue.use、直接调用 install 的情况。
  2. 到了 Vue 3 + Vuex 4 / Pinia,写法变成 app.use(createStore(...)) / app.use(createPinia()),Store 挂在具体的 app 实例上(app.config.globalProperties 和 provide),"每个应用一个 Store"依旧成立,只是作用域从全局 Vue 收窄到了单个 app。

如果 install 不做单例判断会怎样

先把前提说清楚:前面提到 Vue.use 自己就会去重,所以下面讨论的是两层保护都不起作用的情形——要么 Vue 层没有 installedPlugins 去重,要么代码绕过 Vue.use、直接调用了 Vuex.install。在这个前提下,假设 Vuex 也去掉了自己的判断,而项目里又一不小心装了两次:

// main.js
Vuex.install(Vue);   // 首次安装

// ……业务代码往 store 里写了登录信息、购物车等数据……

// 某个后来加的模块绕过 Vue.use,又手动调了一次
Vuex.install(Vue);

这时第二次调用会把 applyMixin(Vue) 再执行一遍,真实的直接后果是全局混入被叠加:每个组件创建时,beforeCreate 里的注入逻辑(Vuex 3 里叫 vuexInit)会跑两遍、三遍……装几次就跑几次。

有人会问:Vue 2.6 起合并生命周期钩子时会做去重(dedupeHooks),重复混入不是会被合并掉吗?这个去重按函数引用比较,只能挡住"同一个函数对象被混入多次"。而 Vuex 3 的 vuexInit 是写在 applyMixin 函数体内部的函数声明,每调用一次 applyMixin 就生成一个新的函数对象,引用各不相同,去重认不出它们是"同一个钩子"。我们用 vue@2.6.14 + vuex@3.6.2 实测过:把 Vuex 自带的 applyMixin 连调两次,Vue.options.beforeCreate 里有 2 个钩子,创建一个组件时 vuexInit 执行 2 次,和 vue@2.5.17 下的结果一样。反过来,如果插件把钩子函数定义在模块顶层、每次混入的是同一个引用,在 2.6+ 里就会被去重,这时重复安装真正的隐患就只剩 install 里那些和钩子无关、只该做一次的副作用(注册全局组件、挂原型属性、发起初始化请求等)。

注意 Store 本身是用户代码 new Vuex.Store(...) 创建的,vuexInit 每次注入的都是同一个 options.store,所以 Vuex 3 里重复 install 并不会把已有状态清空,它带来的是重复执行、性能浪费,以及插件初始化逻辑里任何"只该跑一次"的副作用被放大。

常见的另一种说法是"重复安装会让 Store 重新初始化、之前写入的数据全部归零"。把它当成一种简化的、最坏情况下的概括来理解就行:如果某个插件的 install 里会自己创建全局状态容器,没有单例判断时第二次安装确实会造出一个新容器、把旧数据顶掉。无论具体表现是混入叠加还是状态被覆盖,"全局初始化只能执行一次"这个要求是一样的,这正是单例判断存在的意义。下面这张图把有守卫和没守卫两种情况放在一起对比:

绕过 Vue.use 直接再调一次 install:有单例守卫时第二次直接返回;没有守卫时全局混入被叠加、注入逻辑重复执行

用一段可运行的小代码模拟这道守卫的效果:

let installedOn = null;
let initCount = 0;

function install(App) {
  if (installedOn && installedOn === App) {
    console.warn('[myplugin] 已经安装过,忽略本次 use()');
    return;
  }
  installedOn = App;
  initCount++;            // 真正的初始化只会走到这里一次
}

function FakeVue() {}
install(FakeVue);
install(FakeVue);         // 打出警告,直接返回
console.log(initCount);   // 1

所以 install 里的单例判断不是可有可无的。同样的思路在 Redux(createStore 产出的全局 store)、jQuery(全局只暴露一个 $ / jQuery 对象)等很多前端库里都能看到。

面试题一:实现一个单例的 Storage

题目:基于 localStorage 封装一个 Storage,要求它是单例,并提供 setItem(key, value) 和 getItem(key) 两个方法。

思路:判断逻辑放在静态方法里或者构造函数里都可以;面试时最好静态方法版和闭包版各写一份,展示两种思路都掌握了。

静态方法版

顺手做一点增强:存取时用 JSON 序列化,这样对象也能直接存。

class Storage {
  static getInstance() {
    // ??= :左边是 null/undefined 时才赋值
    Storage.instance ??= new Storage();
    return Storage.instance;
  }

  setItem(key, value) {
    localStorage.setItem(key, JSON.stringify(value));
  }

  getItem(key) {
    const raw = localStorage.getItem(key);
    return raw === null ? null : JSON.parse(raw);
  }
}

const cartA = Storage.getInstance();
const cartB = Storage.getInstance();

cartA.setItem('cart', { sku: 'A-1024', qty: 3 });
console.log(cartB.getItem('cart'));   // { sku: 'A-1024', qty: 3 }
console.log(cartA === cartB);         // true

注意:浏览器全局本来就有一个叫 Storage 的接口(localStorage 就是它的实例),在全局作用域用 class Storage 会遮蔽它。题目要求叫这个名字没问题,真实项目里建议写在模块里或者换个名字(比如 LocalCache)。

闭包版

用 ES5 的写法,把读写方法放在一个基础构造函数的原型上,再用闭包控制"只 new 一次":

// 真正干活的对象:方法挂在原型上
function StorageCore() {}
StorageCore.prototype.setItem = function (key, value) {
  localStorage.setItem(key, value);
};
StorageCore.prototype.getItem = function (key) {
  return localStorage.getItem(key);
};

// 对外暴露的入口:闭包里缓存唯一的 StorageCore 实例
var Storage = (function () {
  var cached = null;
  return function () {
    if (cached === null) cached = new StorageCore();
    return cached;
  };
})();

var s1 = new Storage();   // 用 new 调用
var s2 = Storage();       // 直接调用,效果一样
s1.setItem('lang', 'zh-CN');
console.log(s2.getItem('lang'));   // zh-CN
console.log(s1 === s2);            // true

为什么 new Storage() 和 Storage() 结果一样?因为这个函数显式 return 了一个对象,new 时新建的 this 会被丢弃,表达式的值就是返回的 cached。所以加不加 new 都拿到同一个实例。

(以上两段在 Node 里用一个 Map 模拟 localStorage 跑过,输出与注释一致。)

面试题二:实现一个全局唯一的 Modal 弹框

这几乎是讲单例时必举的例子,也是单例模式在前端早期最集中的应用场景:页面上无论点多少次"打开",弹框节点都只应该创建一个。

思路:套路和前面完全一样。记住三样东西:一个缓存变量(instance)、一个负责判断的入口(getInstance 或闭包返回的函数)、静态方法或闭包二选一。另外要做到懒创建:没点过"打开"就不创建节点,避免白占内存。

全局弹框的生命周期:第一次打开时创建节点,关闭只是隐藏,之后再打开复用同一个节点

闭包版

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>全局唯一弹框</title>
  <style>
    .login-dialog {
      position: fixed;
      inset: 50% auto auto 50%;
      transform: translate(-50%, -50%);
      width: 260px;
      padding: 32px 0;
      text-align: center;
      background: #fff;
      border: 1px solid #333;
      border-radius: 8px;
    }
  </style>
</head>
<body>
  <button id="show">登录</button>
  <button id="hide">取消</button>

  <script>
    // 闭包里缓存唯一的弹框节点
    const getDialog = (function () {
      let dialog = null;
      return function () {
        if (!dialog) {
          dialog = document.createElement('div');
          dialog.className = 'login-dialog';
          dialog.textContent = '请先登录(这个弹框全局只有一个)';
          dialog.hidden = true;
          document.body.appendChild(dialog);
        }
        return dialog;
      };
    })();

    document.getElementById('show').addEventListener('click', () => {
      getDialog().hidden = false;     // 第一次点击时才真正创建节点
    });

    document.getElementById('hide').addEventListener('click', () => {
      getDialog().hidden = true;      // 关闭只是隐藏,节点保留以便复用
    });
  </script>
</body>
</html>

和 Storage 一样,这里的 getDialog 也可以写成 new getDialog(),结果相同,因为函数返回的是一个对象(DOM 节点)。

一个小细节:如果用户还没点过"登录"就先点了"取消",上面的写法会为了隐藏而创建节点。想彻底懒加载,可以单独暴露一个"只查不建"的方法,或者在关闭时先判断节点是否存在。

ES6 class 版

class LoginDialog {
  static #el = null;

  static getInstance() {
    if (!LoginDialog.#el) {
      const el = document.createElement('div');
      el.className = 'login-dialog';
      el.textContent = '请先登录(这个弹框全局只有一个)';
      el.hidden = true;
      document.body.appendChild(el);
      LoginDialog.#el = el;
    }
    return LoginDialog.#el;
  }

  static open()  { LoginDialog.getInstance().hidden = false; }
  static close() { if (LoginDialog.#el) LoginDialog.#el.hidden = true; }
}

document.getElementById('show').addEventListener('click', LoginDialog.open);
document.getElementById('hide').addEventListener('click', LoginDialog.close);

close 里先判断节点是否存在,顺带解决了上面提到的"没打开就关闭也会创建节点"的问题。

可以看到,这类题的特点就是:核心套路记牢了,换个场景几乎可以直接默写。设计模式类的题目普遍如此,抓住每种模式的那一两个关键动作,就能举一反三。

单例的代价

单例好用,但它本质上是全局可变状态,有几件事要心里有数:

  • 测试难隔离:一个用例改了单例里的数据,会影响后面的用例。常见做法是给单例留一个仅测试使用的重置方法,或者通过依赖注入把实例传进去,而不是在代码里到处直接调用 getInstance()。
  • SSR 的跨请求污染:服务端渲染时 Node 进程长期存活,模块级的单例会被所有用户请求共享,A 用户的数据可能泄露给 B 用户。所以 Nuxt、Pinia 等都要求在每个请求里新建 Store,而不是导出一个模块级的 Store。
  • 隐藏依赖:函数内部悄悄调用全局单例,调用方从签名上看不出它依赖了什么,重构时容易出错。
  • 多个 bundle / iframe:同一份代码被打进两个包、或者在不同 iframe 里各执行一次,就会有两个"单例"。需要跨边界共享时,要把实例挂到真正共享的位置(比如 window 上的某个命名空间)。

面试速答模板

单例模式是说一个类只能有一个实例,并且提供一个全局访问点。实现的关键是让创建入口记住自己有没有创建过:第一次调用时 new 一个并缓存起来,以后直接返回缓存。JS 里常见两种写法:一种是静态方法 getInstance,实例存在类的静态属性上;另一种是用闭包里的自由变量做缓存,外部改不到,更安全。现代写法还可以用 static #instance 私有字段,或者在构造函数里判断后 return 已有实例,让直接 new 也拿到同一个对象;工程里 ES Module 只执行一次,导出一个对象本身就是单例。前端最典型的应用是 Vuex / Redux 的全局 Store,Vuex 的 install 用一个模块级变量记住装过的 Vue,重复安装就直接返回,保证一个应用只有一套 Store 注入逻辑;它和 Vue.use 自带的去重是两层保护,如果绕过 Vue.use 直接重复调用 install,没有这道判断就会让全局混入叠加、每个组件的注入逻辑重复执行。常见面试题是封装单例的 localStorage 工具和全局唯一的弹框,弹框要懒创建、关闭时只隐藏不销毁。最后要注意单例是全局可变状态,会给单元测试和 SSR 跨请求共享带来问题,必要时提供重置方法或按请求创建实例。

Last Updated:
Contributors: leeguooooo