今天我们来聊聊JS的一种设计模式——单例模式。
它有些什么应用场景呢?假如我们要写一个翻译app,我们引入一个大模型到代码中,这个大模型大概有1个G,然后我们输入一个‘hello’让大模型帮我们翻译,我们可能去调用了这个大模型得到一个实例对象,这个实例对象为我们翻译‘hello’。然后下一次我们输入‘world’,我们又调用了大模型得到一个对象,那这样设计是不是太浪费性能了,每输入一次都生成一个实例对象。我们希望调用这个大模型只生成一个对象,之后不管输入几次都用这个对象来操作,这就要用到单例模式了。
所以,所谓的单例模式,就是保证一个类只有一个实例。
1. 类 class
我们先来介绍一下JS中的类,它比构造函数更强大,它包括了构造函数,它也能当作构造函数来使用。
我们先来回顾一下构造函数的一些特性,如果我们想把一个函数当作构造函数来使用,我们会这样写:
function Person() {
this.name = '梁总'
this.age = 18
}
函数名大写,然后里面挂this。构造函数可以为我们批量化的生产实例对象。当我们想得到一个实例对象时,我们用new去调用它。
function Person() {
this.name = '梁总'
this.age = 18
}
let p = new Person()
console.log(p);
我们得到的对象p是不是就显示拥有了两个属性:name和age。
如果我们想让对象p隐式拥有属性,那我们就会在Person的原型上定义属性,它就会继承到实例对象的隐式原型上,我们就能调用它。
Person.prototype.like = '泡脚'
function Person() {
this.name = '梁总'
this.age = 18
}
let p = new Person()
console.log(p.like);
那如果我这样写呢?
Person.prototype.like = '泡脚'
Person.say = function () {
return 'Hello'
}
function Person() {
this.name = '梁总'
this.age = 18
}
let p = new Person()
console.log(p.say());
我往函数体上挂了一个函数say,实例对象p能访问到吗?
我们知道是访问不到的,因为它没有写在函数原型上,它是写在Person体的,它不会继承到实例对象中,想要调用这个函数,只能用Person去调用。
这就是构造函数的一些用法,我们一看,感觉代码写的很散,明明是构造函数的功能。但一些代码却写在构造函数外面。我们说类就能顶替掉构造函数,我们来看看类是怎么写的。
定义一个类用关键字class。
class Person {
constructor() { // 构造函数
this.name = '梁总'
this.age = 18
}
like() {
return `${this.name}泡脚`
}
static say() {
return 'Hello'
}
}
let p = new Person()
constructor就相当于构造函数,让实例对象显示拥有属性。我们想让实例对象隐式拥有一个方法直接写在类里即可,我们不想让实例对象拥有一个方法就用关键字static声明它,然后我们用new去调用Person得到的是和构造函数一样的效果,这样一看是不是清晰明了多了,所有的东西都在类里。
这里问一个小问题:请问like里面的this指向谁?
我们知道constructor里面的this是指向实例对象的,因为new会将构造函数里面的this指向实例对象,那like方法里面的this指向谁呢?其实也是实例对象p吧。因为我们写的这个方法like在将来就是给p去调用的呀,会写成p.like()
,这样触发的不就是隐式绑定嘛,like不是独立调用的,like里面的this就会指向p。
这是一个小细节。这样我们就简单了解了一下类,接下来我们就来介绍单例模式。
2. 单例模式
关于单例模式,最关键的一点就是不管调用多少次类得到的就是一个实例对象,所谓的一个实例对象,就是在堆里只有一个引用地址。
那怎么实现它呢?我们一步一步来。
我们先定义一个类SingleDog,在这个类中我们定义一个函数show:
class SingleDog {
show() {
console.log('我是一个单身狗');
}
}
这个方法会被实例对象隐式拥有,不会出现在实例对象里面。
然后我们new两个实例对象s1和s2。s1和s2会相等吗?
class SingleDog {
show() {
console.log('我是一个单身狗');
}
}
const s1 = new SingleDog()
const s2 = new SingleDog()
console.log(s1 === s2);
这一看就是不相等的吧,我们new了两次SingleDog得到的是两个实例对象,它们在堆中的引用地址肯定不一样。
那单例模式就是希望我new多次得到一个实例对象,那得到的就是我们第一次new的对象,那怎么才能让我们之后new出来的对象为第一个呢?
想要实现这个功能,我们定义的类就必须拥有一个能力,去判断自己是否被new过。如果它有这个能力,当它知道自己被new过后,就给我们返回第一次new出来的实例对象,不再创建新的实例对象,不就实现了我们想要的效果了吗?
怎么让类知道自己是否被new过呢?我们这样写来试一下:
我们在SingleDog静态声明一个函数getInstance,当我们调用这个函数时,就能始终给我们返回第一次new出来的对象。因为是静态static声明的,它只能被SingleDog自己调用,不能被实例对象调用,所以创建实例对象的时候这样写:
class SingleDog {
show() {
console.log('我是一个单身狗');
}
static getInstance() {
}
}
const s1 = SingleDog.getInstance()
const s2 = SingleDog.getInstance()
console.log(s1 === s2);
那怎么去写这个getInstance方法呢?我们想让这个方法只返回第一次new出来的对象。那我们得将第一次new出来的对象保存起来,当你第二次调用我的时候,我就不给你创建新的对象,直接给你返回被保存起来的对象。
那保存在哪里比较合适呢?为了符合封装的理念,我们可以直接在类上面挂一个属性,让它保存我们第一次new出来的对象,等到第二次调用的时候,我们先去判断这个属性有没有值,如果有值,就代表已经被new过一次了,直接返回保存在这个属性中的对象即可。
class SingleDog {
show() {
console.log('我是一个单身狗');
}
static getInstance() {
if (!SingleDog.instance) {
SingleDog.instance = new SingleDog();
}
return SingleDog.instance;
}
}
const s1 = SingleDog.getInstance()
const s2 = SingleDog.getInstance()
console.log(s1 === s2);
先做判断,当SingleDog.instance不存在时,就去new 一下SingleDog得到一个实例对象然后保存在SingleDog.instance中,然后返回这个实例对象。当第二次调用这个方法时,if中的语句就不走,就不会去new一个新的实例对象,直接给我们返回第一次new出来的对象。这样不就实现了单例模式吗?
现在我们再来判断一下s1和s2是否相等。
那就相等了,自始至终都是同一个对象,在堆中的引用地址是同一个。
这就是单例模式的一个思想,保证只创建一个实例对象,之后再调用就将第一次的实例对象返回。
那还有没有什么其它方法能实现单例模式?
闭包行不行?当我们在一个函数里面返回一个函数到全局使用,这个函数就会形成一个闭包供被返回的函数访问。如果我们将第一次new出来的对象保存在闭包中,这个被返回出来的函数是不是不管调用多少次访问的都是闭包中的值。理论成立,实践开始。
class SingleDog {
show() {
console.log('我是一个单身狗');
}
}
SingleDog.getInstance = (function () {
let instance = null
return function () {
})()
const s1 = SingleDog.getInstance()
const s2 = SingleDog.getInstance()
console.log(s1 === s2);
我们就不直接在类里面静态声明函数了,在类的外面,我们往SingleDog身上挂一个方法getInstance,这样写其实相当于在类里面静态声明一个函数,只能被类自己调用。然后我们将getInstance写成一个自执行函数,在里面返回一个函数出来,这样就形成了一个闭包。
怎么使用呢?当我们写SingleDog.getInstance,得到的是不是就是getInstance函数的返回结果,因为getInstance是自执行函数。所以SingleDog.getInstance就相当于getInstance体内返回出来的那个函数,那我们再这样写SingleDog.getInstance(),不就是在调用getInstance体内返回出来的那个函数了吗。
我们还在getInstance里面声明一个变量instance,让它保存我们第一次new出来的对象,再让返回出来的那个函数访问它,因为我们是在全局调用这个函数,变量instance就会形成一个闭包供它访问。
接下来就和第一种方法差不多了,先去判断instance有没有值,没有值就将第一次new出来的对象保存在它身上。第二次调用的时候就返回它。
class SingleDog {
show() {
console.log('我是一个单身狗');
}
}
SingleDog.getInstance = (function () {
let instance = null
return function () {
if (!instance) {
instance = new SingleDog()
}
return instance
}
})()
const s1 = SingleDog.getInstance()
const s2 = SingleDog.getInstance()
console.log(s1 === s2);
这样s1也会等于s2。
3. 单例模式的应用场景
我们来看一个单例模式的应用场景。
我们有这样一个需求,当用户点击‘打开弹框’时,页面上生成一个弹出框;当用户点击‘关闭弹框’时,这个弹出框就要消失。
这是一个很典型的利用单例模式的例子。因为我们不能让用户每点击一次‘打开弹框’都在页面中生成一个弹出框,而是用户点击第一次生成一个弹出框,之后不管点击多少次用到还是第一次生成的弹出框。
我们先在html中放两个button,给它们取两个id方便js获取。html就写的随意一点,主要是js。
<body>
<button id="open">打开弹框</button>
<button id="close">关闭弹框</button>
<script>
</script>
</body>
我们定义一个model函数来实现这个功能。要生成一个弹出框,我们就写的简单一点,就往页面上生成一个 div 容器吧。
<body>
<button id="open">打开弹框</button>
<button id="close">关闭弹框</button>
<script>
const model = function () {
let div = document.createElement('div')
}
</script>
</body>
给它添加文本'我是一个全局弹框',加一个id= 'model'。怎么让它可以实现在页面中出现又消失呢?我们可以动它的display属性。我们可以让model函数接收一个形参flag。当传进来block或者none时,就把它赋给display属性,这样就能实现div的出现和消失了。然后就往body上添加这个div。
<body>
<button id="open">打开弹框</button>
<button id="close">关闭弹框</button>
<script>
const model = function (flag) {
let div = document.createElement('div')
iv.innerHTML = '我是一个全局弹框'
div.id = 'model'
div.style.display = flag
document.body.appendChild(div)
}
</script>
</body>
当我们调用model这个函数,就能生成一个弹框。然后我们给这个弹框随便写点样式。让它在页面居中。
<style>
#model {
width: 200px;
height: 200px;
line-height: 200px;
text-align: center;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 1px solid #000;
}
</style>
接下来我们给两个button绑定点击事件。当点击‘打开弹框’时,就调用model函数,传block进去。那‘关闭弹框’同理。
<body>
<button id="open">打开弹框</button>
<button id="close">关闭弹框</button>
<script>
const model = function (flag) {
let div = document.createElement('div')
div.innerHTML = '我是一个全局弹框'
div.id = 'model'
div.style.display = flag
document.body.appendChild(div)
}
document.getElementById('open').addEventListener('click', () => {
model('block')
})
document.getElementById('close').addEventListener('click', () => {
model('none')
})
</script>
</body>
这样写完了,能行吗?我们来试一下。
我们点击‘打开弹框’打开弹框,确实在页面上生成了一个弹框,并且我们检查一看,页面上多了一个div盒子。
再点击‘打开弹框’。
我们发现就关不掉了,并且页面上多了很多div盒子。因为第一个盒子的display还是block,所以它还在页面上显示。
这样导致了我们每点一次button,都会生成一个盒子,不仅显得臃肿,还没有达到我们想要的效果。
所以在这里就得用上单例模式了。我们希望每次调用函数时只给我们生成一个盒子,我们只对这一个盒子进行操作。
我们重新定义一个函数Model,这次我们使用闭包来完成单例模式的效果。我们将函数Model写成一个自执行函数,在它里面返回一个函数出来。然后在函数Model里面定义一个变量model用来保存我们的盒子并且当作闭包使用。
<body>
<button id="open">打开弹框</button>
<button id="close">关闭弹框</button>
<script>
const Model = (function () {
let model = null
return function () {
}
})()
document.getElementById('open').addEventListener('click', () => {
Model('block')
})
document.getElementById('close').addEventListener('click', () => {
Model('none')
})
</script>
和我们当时的思路一样。我们去判断model是否为空,如果为空就往它身上添加一个盒子;如果不为空,说明不是第一次调用,直接返回这个被保存的盒子。
<body>
<button id="open">打开弹框</button>
<button id="close">关闭弹框</button>
<script>
const Model = (function () {
let model = null
return function (flag) {
if (!model) {
model = document.createElement('div')
model.innerHTML = '我是一个全局弹框'
model.id = 'model'
document.body.appendChild(model)
}
model.style.display = flag
return model
}
})()
document.getElementById('open').addEventListener('click', () => {
Model('block')
})
document.getElementById('close').addEventListener('click', () => {
Model('none')
})
</script>
这样当我们在外面调用这个被返回出来的函数时,model就会被保存在闭包中供被返回出来的函数使用。
这样就完成了单例模式的设计了。我们再来试一下。
点击一下‘打开弹框’,出现弹框,生成一个div。
点击‘关闭弹框’,弹框消失,并且还是只有一个div,只不过它的display属性变成了none,所以在页面中看不见。
这就是一个很典型的利用单例模式的例子,我们只需要一个实例对象并对其进行操作。在我们的实际开发中,单例模式是一种很实用的设计模式,它不仅可以缓解性能消耗,还能让我们的代码变得很优雅。