今天遇到了个有意思的问题,写了个操作 localStorage 的工具函数,在不同的模块里都调用了它,结果发现有时候存进去的数据,换个地方就读不出来了。仔细排查才发现,原来我在每个模块里都重新创建了一个操作实例,虽然都是操作 localStorage,但总感觉哪里不对劲。
翻了翻设计模式的资料,才恍然大悟 ------ 这种全局唯一的资源操作,正适合用单例模式来处理。单例模式其实是最容易理解的设计模式之一,今天就结合 localStorage 的封装,从基础概念到实际实现,跟大家好好说说它。
从问题出发:为什么需要单例模式?
先从那个 localStorage 工具说起。我最初的写法是这样的:
js
class StorageUtil {
setItem(key, value) {
localStorage.setItem(key, value);
}
getItem(key) {
return localStorage.getItem(key);
}
removeItem(key){
localStorage.removeItem(key);
}
clear(){
localStorage.clear();
}
}
然后在不同模块里分别实例化:
js
// 模块A
const storeA = new StorageUtil();
storeA.setItem('name', '张三');
// 模块B
const storeB = new StorageUtil();
console.log(storeB.getItem('name')); // 能读到,看似没问题
表面上看功能正常,但仔细想想:localStorage 本身是全局唯一的存储区域,为什么要为它创建多个操作实例呢?这就像给同一个门配了好几把钥匙,虽然都能开门,但完全没必要,甚至可能在复杂场景下引发问题 ------ 比如如果在实例里维护了缓存状态,多个实例就会导致状态不一致。
这时候就需要单例模式出场了。它的核心作用就是:保证一个类在整个应用中只有一个实例,并且提供一个全局访问点。这样既能避免重复创建实例造成的资源浪费,又能保证全局状态的一致性。
单例模式的核心定义与分类
单例模式是一种创建型 设计模式,它的核心定义可以概括为:确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。简单来说,就是不管你尝试创建多少次,最终拿到的都是同一个对象。
根据实例创建的时机,单例模式主要分为两类:
1. 饿汉式单例------提前创建,随时待命
饿汉式单例会在类加载的时候就创建实例,不管后续是否会用到。这种方式的优点是实现简单,而且天然线程安全(在 Java 等多线程语言中),因为实例在程序启动阶段就已经初始化完成。
用 JavaScript 实现一个饿汉式的存储工具:
js
class HungryStorage {
// 类加载时就创建实例
static instance = new HungryStorage();
// 提供全局访问点
static getInstance() {
return HungryStorage.instance;
}
setItem(key, value) {
localStorage.setItem(key, value);
}
getItem(key) {
return localStorage.getItem(key);
}
removeItem(key){
localStorage.removeItem(key);
}
clear(){
localStorage.clear();
}
}
// 无论调用多少次,拿到的都是同一个实例
const store1 = HungryStorage.getInstance();
const store2 = HungryStorage.getInstance();
console.log(store1 === store2); // true
这种方式的缺点也很明显:如果这个实例从始至终都没被使用,就会造成内存浪费。
2. 懒汉式单例------按需创建,节省资源
懒汉式单例会在第一次被使用的时候才创建实例,完美解决了饿汉式可能浪费资源的问题。但实现相对复杂一些,需要处理好实例创建的时机和线程安全问题(在多线程环境下)。
在前端开发中,我们更常用懒汉式单例,因为 JavaScript 是单线程执行(忽略 Web Worker 等特殊场景),不需要考虑多线程同步问题,实现起来更简单。
接下来看看在 JavaScript 中,懒汉式单例有哪些具体实现方式。
用 ES6 类实现懒汉式单例
ES6 的类语法提供了更清晰的面向对象实现方式,结合静态属性和静态方法,我们可以很方便地实现懒汉式单例。
js
class Storage {
// 静态属性存储唯一实例
static instance;
// 静态方法作为全局访问点
static getInstance() {
// 首次调用时创建实例,后续调用直接返回已有实例
if (!Storage.instance) {
Storage.instance = new Storage();
}
return Storage.instance;
}
// 实例方法封装localStorage操作
setItem(key, value) {
localStorage.setItem(key, value);
}
getItem(key) {
return localStorage.getItem(key);
}
removeItem(key) {
localStorage.removeItem(key);
}
clear() {
localStorage.clear();
}
}
// 测试:多次调用返回同一个实例
const store1 = Storage.getInstance();
const store2 = Storage.getInstance();
console.log(store1 === store2); // true
这里的关键是static
关键字 ------ 静态属性instance
属于类本身,而不是类的实例。getInstance
方法会先检查是否已有实例,有就直接返回,没有才创建。这样不管调用多少次getInstance,拿到的都是同一个对象。
但后来我发现个小问题:如果有人直接new Storage(),还是能创建新实例。所以更严谨的做法是把构造函数私有化,但 JS 没有真正的私有构造函数,只能曲线救国:
js
class Storage {
static instance;
constructor() {
// 如果已有实例,直接返回已有实例
if (Storage.instance) {
return Storage.instance;
}
// 否则将当前实例赋值给静态属性
Storage.instance = this;
}
// ... 其他方法同上
}
// 即使直接new,也只能拿到同一个实例
const store1 = new Storage();
const store2 = new Storage();
console.log(store1 === store2); // true
通过在构造函数中检查并返回已有实例,确保无论通过new还是getInstance,最终拿到的都是同一个对象。
闭包 + 立即执行函数
除了类的写法,利用 JavaScript 的闭包特性也能实现单例模式,这种方式更贴近函数式编程思想,实现也更隐蔽。
js
// 定义基础操作类
function StorageBase() {}
StorageBase.prototype.setItem = function(key, value) {
localStorage.setItem(key, value);
};
StorageBase.prototype.getItem = function(key) {
return localStorage.getItem(key);
};
StorageBase.prototype.removeItem = function(key){
localStorage.removeItem(key);
}
StorageBase.prototype.clear = function(){
localStorage.clear();
}
// 用闭包控制实例创建
const Storage = (function() {
let instance = null;
return function() {
if (!instance) {
instance = new StorageBase();
}
// 始终返回同一个实例
return instance;
};
})();
// 测试
const store1 = new Storage();
const store2 = new Storage();
console.log(store1 === store2); // true
这种实现的精髓在于立即执行函数(IIFE)创建的闭包环境:变量instance被封闭在私有作用域中,外部无法直接访问,只能通过返回的函数来操作。每次调用new Storage()时,都会先检查闭包中的instance是否存在,确保只创建一次实例。
对比类的实现,闭包版本的优势是不需要调用getInstance方法,直接new就能得到单例,使用起来更自然,而且实例的创建逻辑被完全隐藏,避免了被破坏的可能。
单例模式的实际应用场景
理解了实现方式,再看看前端开发中哪些场景适合用单例模式:
-
全局状态管理:Vuex 的 Store、Redux 的 createStore 都是典型的单例模式,整个应用只能有一个状态容器,否则状态就会混乱。
-
弹窗组件:页面中同一类型的弹窗(如提示框、登录框)通常只需要一个实例,重复创建会导致 DOM 冗余和状态冲突。
-
API 请求实例:Axios 实例配置好 baseURL 和拦截器后,全局使用同一个实例更便于管理,避免重复配置。
-
工具类实例:如本文的 localStorage 操作工具、日志工具等,全局唯一实例能保证操作的一致性。
这些场景的共性是:需要全局唯一的访问点,或者重复创建实例会带来副作用。
最后
单例模式看似简单,但其背后体现的是一种资源管理思想:对于全局唯一的资源,应该通过统一的入口进行访问和控制。
在 JavaScript 中,无论是用类的静态属性,还是用闭包,核心都是通过一个 "容器"(静态属性或闭包变量)保存实例,并在创建时检查这个容器是否已有值。两种实现方式没有绝对优劣,类的写法更符合面向对象思维,闭包写法更具 JavaScript 特色,根据实际场景选择即可。 希望本文让你对单例模式有更清晰的了解~