什么是单例模式?
保证一个类仅有一个实例,并提供一个全局访问点来获取该实例 ,这便是广为人知的单例模式。
我们先暂时抛开单例模式的应用场景,单纯聚焦于它的实现过程。
此时,一个关键问题摆在我们面前:怎样才能确保一个类仅有一个实例呢?
this a question !
通常情况下,当我们定义了一个类(本质上是构造函数)后,通过使用new
关键字调用构造函数,能够创建出任意数量的实例对象。以如下代码为例:
js
class SingleDog {
show() {
console.log('我是一个单例对象');
}
}
const s1 = new SingleDog();
const s2 = new SingleDog();
// false
console.log(s1 === s2);
在上述代码中,我们先通过new
创建了s1
,随后又创建了s2
。
显然,s1
和s2
相互独立,各自占据一块内存空间,它们之间不存在任何关联。然而,单例模式的目标是无论尝试创建多少次,始终只返回首次创建的那个唯一实例。
how to get it ?
要达成这一目标,构造函数需要具备判断自身是否已创建过实例的能力 , 即在创建实例的时候 , 能够拦截最后在检验
我们可以将这段判断逻辑编写成一个静态方法(实际上,也可直接将其写入构造函数的函数体中,这里为了展现 static 的用法 , 我选择一下的方法)
js
static getInstance() {
// 判断是否已经new过1个实例
if (!SingleDog.instance) {
// 若这个唯一的实例不存在,那么先创建它
SingleDog.instance = new SingleDog();
}
// 如果这个唯一的实例已经存在,则直接返回
return SingleDog.instance;
}
完整的代码就是下面的代码:
js
class SingleDog {
show() {
console.log('我是一个单例对象');
}
static getInstance() {
// 判断是否已经new过1个实例
if (!SingleDog.instance) {
// 若这个唯一的实例不存在,那么先创建它
SingleDog.instance = new SingleDog();
}
// 如果这个唯一的实例已经存在,则直接返回
return SingleDog.instance;
}
}
const s1 = SingleDog.getInstance();
const s2 = SingleDog.getInstance();
// true
console.log(s1 === s2);
除了上述实现方式,getInstance
的逻辑还能借助闭包来实现:
js
SingleDog.getInstance = (function() {
// 定义自由变量instance,模拟私有变量
let instance = null;
return function() {
// 判断自由变量是否为null
if (!instance) {
// 如果为null则new出唯一实例
instance = new SingleDog();
}
return instance;
};
})();
可以看到,在getInstance
方法的判断与拦截机制下,无论调用多少次,SingleDog
都只会返回同一个实例,此时s1
和s2
均指向这个唯一实例。
到这里不得不提到 Vuex 中单例模式的应用了 ~
基于Flux架构 的状态管理工具,其中Redux和Vuex是应用最为广泛的代表。无论是
- Redux
- 还是Vuex
它们都实现了一个全局的Store,用于存储应用的所有状态。而这个Store的实现,正是单例模式的典型应用场景。
我们再次回顾单例模式的关键定义 : 保证一个类(Store)仅有一个实例 , 并且提供全局访问点 🤡 , 使用过状态管理工具的倔友 , 应该深有体会 ~
在此,我们以Vuex为例,深入探究单例模式在其中的具体应用。
Vuex中单例模式
vuex 的出现
Vuex采用单一状态树的设计理念,通过一个对象来囊括应用层级的全部状态。如此一来,它便作为"唯一数据源 (SSOT)"存在。这意味着每个应用仅包含一个store实例。
单一状态树的优势在于,能够让我们快速定位到任意特定的状态片段,在调试过程中,也能轻松获取整个当前应用状态的快照。
引用Vuex官方文档的描述就是:"Vuex 使用单一状态树,用一个对象就包含了全部的应用层级状态。至此它便作为一个'唯一数据源 (SSOT)'而存在。
这也意味着,每个应用将仅仅包含一个 store 实例。单一状态树让我们能够直接地定位任一特定的状态片段,在调试的过程中也能轻易地取得整个当前应用状态的快照。"
在Vue开发中,组件之间相互独立,组件间通信最常用的方式是props
,但这种方式仅适用于父组件与子组件之间的通信。对于稍微复杂一些的场景,比如兄弟组件间通信,我们可以通过自定义简单的事件监听函数来解决。
然而,当项目中组件数量众多、组件间关系错综复杂且嵌套层级较深时,这种原始的通信方式会使逻辑变得异常复杂,难以维护。
此时,最佳解决方案是将共享数据抽取出来,放置在全局,供组件按照一定规则进行数据的存取操作,确保状态以可预测的方式发生变化。 于是,Vuex应运而生,这个用于存放共享数据的唯一数据源,就是Store。
当然 , 我更喜欢使用 pinia 这个数据状态管理 , vuex 和 pinia 之间的区别
- 在 API 设计上,Pinia 更为简洁直观,摒弃了 Vuex 繁杂的模块嵌套与 mutation 命名空间,让开发者能更轻松地定义和使用状态、方法;
- 在开发体验上,Pinia 支持在 setup 函数中使用,契合 Vue3 组合式 API,代码逻辑更清晰、可维护性更强,且对 TypeScript 的支持更为友好;
- 从实现机制看,Pinia 的 Store 是真正意义上的单例,而 Vuex 的 Store 虽在实践中类似单例,但从构造函数源码层面并未严格实现单例逻辑 。
上面提到 vuex 的类 Store 是"假单例" , 我们一起探讨一下 :
首先,我们需要明确"假单例"的概念。在这里,"假单例"指的是虽然没有严格遵循单例模式的设计原则,但在实际应用中依然能够保证实例的唯一性。Vuex中的Store就是这样一个"假单例"------尽管在实际应用中,Store通常仅有一个全局实例,但从实现层面来看,它并非严格意义上的单例模式。接下来,我们将结合Vuex Store的源码进行详细说明。
以下是Store类构造函数的部分源码
js
class Store {
constructor (options = {}) {
// ...
this._actions = Object.create(null);
this._mutations = Object.create(null);
this._wrappedGetters = Object.create(null);
this._modulesNamespaceMap = Object.create(null);
this._subscribers = [];
this._watcherVM = new Vue();
// 将 this 赋值给 store,这是为了在后续的函数中使用 Store 实例的上下文
const store = this;
// 将 this 中的 dispatch 和 commit 方法解构出来,以便在后续的函数中使用
const { dispatch, commit } = this;
// 分别为 dispatch 和 commit 方法绑定上下文
this.dispatch = function boundDispatch (type, payload) {
return dispatch.call(store, type, payload);
};
this.commit = function boundCommit (type, payload, options) {
return commit.call(store, type, payload, options);
};
// ...
}
}
在Vuex中,我们可通过new Vuex.Store(options)
调用构造函数来创建新的Store实例。从上述贴出的Store构造函数关键源码中可以发现,其中并未包含任何与单例相关的识别或拦截逻辑。
这意味着开发者能够通过new
关键字创建多个Store实例,这显然与我们对单例模式的预期不符。
以下是一个创建多个Store实例的示例代码:
js
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
// 创建一个 store 对象 1 号
const store1 = new Vuex.Store({
state: { count: 0 },
mutations: {
increment(state) {
state.count++;
}
}
});
// 创建一个 store 对象 2 号
const store2 = new Vuex.Store({
state: { count: 0 },
mutations: {
increment(state) {
state.count++;
}
}
});
// false,说明 store1 和 store2 是完全不同的两个 store
console.log(store1 === store2);
由此可见,尽管Store在实际应用中通常表现得如同单例,但从其自身实现来看,并没有真正实现单例相关的逻辑。那么,没有实现单例的Store,究竟是如何展现出单例般的行为的呢?这就需要从Vuex的整体设计层面进行深入分析。
Vuex如何确保Store 单例特征呢 ?
- Vuex工作原理分析 :Store虽未实现标准的单例模式,却能展现出类似单例的行为,这得益于Vuex从整体设计层面保证了Store在同一个Vue应用中的唯一性。具体而言,我们首先要关注Vue.use()方法,该方法允许我们为Vue应用安装诸如Vuex这样的插件。Vuex插件本质上是一个对象,其内部实现了一个install方法,在插件安装时,该方法会被调用,从而将Store注入到Vue应用中。也就是说,每调用一次install,Vuex都会尝试向Vue应用注入一个Store。
在install函数源码中,存在一段与前文提到的getInstance()
方法极为相似的逻辑:
js
let Vue; // 这个Vue的作用和楼上的instance作用一样
...
export function install (_Vue) {
// 判断传入的Vue实例对象是否已经被install过Vuex插件(是否有了唯一的 store)
if (Vue && _Vue === Vue) {
if (process.env.NODE_ENV!== 'production') {
console.error(
'[vuex] already installed. Vue.use(Vuex) should be called only once.'
);
}
return;
}
// 若没有,则为这个Vue实例对象install一个唯一的Vuex
Vue = _Vue;
// 将Vuex的初始化逻辑写进Vue的钩子函数里
applyMixin(Vue);
}
面试
请手写一个单例模式 😏
基于 Static
js
// 定义Storage
class Storage {
constructor() {
this.data = {};
}
static getInstance() {
// 判断是否已经new过1个实例
if (!Storage.instance) {
// 若这个唯一的实例不存在,那么先创建它
Storage.instance = new Storage();
}
// 如果这个唯一的实例已经存在,则直接返回
return Storage.instance;
}
getItem(key) {
return this.data[key];
}
setItem(key, value) {
this.data[key] = value;
return value;
}
}
const storage1 = Storage.getInstance();
const storage2 = Storage.getInstance();
storage1.setItem('name', '李雷');
// 李雷
console.log(storage1.getItem('name'));
// 也是李雷
console.log(storage2.getItem('name'));
// 返回true
console.log(storage1 === storage2);
基于闭包
ES5
js
// 先实现一个基础的StorageBase类,把getItem和setItem方法放在它的原型链上
function StorageBase() {
this.data = {};
}
StorageBase.prototype.getItem = function (key) {
return this.data[key];
};
StorageBase.prototype.setItem = function (key, value) {
this.data[key] = value;
return value;
};
// 以闭包的形式创建一个引用自由变量的构造函数
const Storage = (function () {
let instance = null;
return function () {
// 判断自由变量是否为null
if (!instance) {
// 如果为null则new出唯一实例
instance = new StorageBase();
}
return instance;
};
})();
// 这里其实不用 new Storage 的形式调用,直接 Storage() 也会有一样的效果
const storage1 = new Storage();
const storage2 = new Storage();
storage1.setItem('name', '李雷');
// 李雷
console.log(storage1.getItem('name'));
// 也是李雷
console.log(storage2.getItem('name'));
// 返回true
console.log(storage1 === storage2);
ES6
js
// 实现一个基础的 StorageBase 类
class StorageBase {
constructor() {
this.data = {};
}
getItem(key) {
return this.data[key];
}
setItem(key, value) {
this.data[key] = value;
return value;
}
}
// 以闭包的形式创建一个引用自由变量的构造函数
const Storage = (function () {
let instance = null;
return function () {
// 判断自由变量是否为 null
if (!instance) {
// 如果为 null 则 new 出唯一实例
instance = new StorageBase();
}
return instance;
};
})();
// 这里其实不用 new Storage 的形式调用,直接 Storage() 也会有一样的效果
const storage1 = new Storage();
const storage2 = new Storage();
storage1.setItem('name', '李雷');
// 李雷
console.log(storage1.getItem('name'));
// 也是李雷
console.log(storage2.getItem('name'));
// 返回 true
console.log(storage1 === storage2);