ByteNoteByteNote
两次取到的必须是同一个对象
字

字节笔记本

2026年10月7日 · 约 9 分钟读完

两次取到的必须是同一个对象

API中转
¥120

单例要保证两次取到的是同一个对象。JavaScript 里对话给出了两种写法。一种用立即执行函数把实例关在闭包里,一种在构造函数里看见已经有实例就把它返回。instance1 === instance2 为真,才算这次实现成立。

两次取到的是同一个对象

闭包里的实例外面看不见

第一份代码是一个立即执行函数。函数内部有 let instance。createInstance 返回一个对象,上面有 publicMethod 和 publicProperty。私有的 privateVariable 和 privateMethod 只活在 createInstance 里,返回的对象没有把它们交出去。外面只能通过 getInstance 进来。

getInstance 先看 instance 是否已有值。没有就调用 createInstance 存下来,有就直接返回那一份。所以:

javascript
const instance1 = Singleton.getInstance()
const instance2 = Singleton.getInstance()
console.log(instance1 === instance2)

比较结果是 true。第二次不会再跑 createInstance。publicMethod 内部还能调用 privateMethod,因为方法定义的时候就抓住了那个作用域。外面写 instance1.privateMethod 是拿不到的。

这个实例挂在闭包变量上,不是挂在 Singleton 的属性上。调用方改不了“当前实例是谁”,除非你自己再提供一个重置方法。对话里没有重置方法,测试想换一份新实例时,这个写法会挡路。那是闭包的代价,不是漏写。

类的写法把实例放在构造函数上

第二份用类。构造函数开头如果 Singleton.instance 已存在,就 return Singleton.instance。否则 Singleton.instance = this,再初始化 this.data = []。addItem 往数组里推,getItems 把数组返回。

javascript
const instance1 = new Singleton()
const instance2 = new Singleton()
console.log(instance1 === instance2)

同样是 true。第二次 new 并没有得到新对象,构造函数把旧对象返回了。instance1.addItem('a') 之后,instance2.getItems() 里能看见 'a',因为 data 只有一份。

Singleton.instance 是构造函数上的属性,谁都能读到,也能从外面赋成别的值。赋成别的值之后,下一次 new 会返回那个被换掉的对象,单例约定就被调用方打破。闭包那种写法没有这条缝。类的写法短,封装弱在这里。

类的实例在第一次 new 之前还不存在。模块加载不会自动创建它。闭包那种在函数执行完时,getInstance 已经准备好,实例仍要等到第一次调用才创建。两种都是懒创建。不要在文件顶上再 new 一次又导出,否则类里的判断和顶上的实例会变成两套。

用比较来验收,不要只看调用次数

验收就是上面的 ===。再补一步:从一边改数据,另一边读。闭包版本改 publicProperty,类版本 addItem。读到的是改过的值,才说明不是两份长得像的对象。

getItems 返回的是内部数组本身,不是副本。调用方 push 会直接改到单例里。若不想被外面改,返回前复制一份。对话里的方法是直接 return this.data,所以这份数组是共享的。

两种写法不要在同一个模块里各做一个“全局配置”。后加载的那个会盖住使用方的预期。一个进程里需要唯一的那份状态,只留一种取法,并在模块出口只导出 getInstance 或只导出类,不要两个都导出。

类把实例放在构造函数属性上

调用方只能走公开的那一个入口

闭包版本的使用步骤只有三步。先执行那个立即函数,得到带 getInstance 的对象。第一次 getInstance 时 instance 还是空的,于是 createInstance 跑一遍,把返回的对象存进闭包。这个对象上能点的是 publicMethod 和 publicProperty。publicMethod 被调用时先打一句话,再调用 privateMethod。privateMethod 没有出现在返回值上,所以第二次从外面取到的同一个对象,仍然点不到它。这就是“私有”在这份代码里的含义:不是语言禁止访问,是根本没有把函数交出去。

第二次 getInstance 看见 instance 已有值,直接返回。因此 add 之类的状态如果以后加在返回对象上,两份引用改的是同一块。验收时不要只打印两次调用,要改 publicProperty 再从另一个变量读。读到新值,=== 才有意义。两个对象的属性碰巧相同,=== 仍然是假。

类版本的步骤对应 new。第一次构造把 this 存到 Singleton.instance,并把 data 设成空数组。第二次走进构造函数,第一行判断命中,return 旧实例,函数后半的 this.data = [] 不会再执行。所以第二次 new 不会把已有的数组清空。若有人把 this.data = [] 写到判断之前,每次 new 都会把列表抹掉,单例就只剩同一个对象、数据却没了。顺序必须是先返回已有实例,再初始化。

addItem 和 getItems 用来证明共享。instance1.addItem('a') 之后 instance2.getItems() 的长度是 1。若长度是 0,说明两次 new 得到了两个数组。那时候先看 ===,再看初始化是不是被第二次执行了。

不要再导出第二个入口

模块文件如果既 export 类,又在顶上 new 一次再导出那个值,调用方会有两条路。走 new 的人受构造函数保护,走那个顶上实例的人拿到的也是同一份,看起来没问题。一旦有人把 Singleton.instance 改成 null,下一次 new 会创建新对象,顶上导出的仍是旧的,于是又出现两份。闭包版本没有可写的 instance 属性,这条缝不存在。

类版本想收紧,就不要把 Singleton.instance 写进文档当 API。调用约定只保留 new。闭包版本的约定只保留 getInstance。不要两种都留在同一个名字下。测试需要新实例时,闭包版本做不到,除非改代码加上重置。那是测试上的不方便,不要为此把 instance 改到外面去。

封装和能不能被换掉

立即执行函数把 instance 留在闭包里,外面只有 getInstance。类把实例放在 Singleton.instance,第二次 new 返回旧对象,但这个属性能被外面改掉。两种都能让 === 为真。要藏住私有方法,用闭包那份。要短,用类,并接受实例指针是公开的。返回内部数组之前,先决定调用方能不能改它。

再补一次失败验收。把类的 Singleton.instance 在两次 new 之间改成另一个对象,第三次 new 会返回那个被换上的对象,=== 对不上第一次。这证明属性是公开的。闭包版本没有地方做同样的替换,两次 getInstance 必须始终 ===。getItems 若返回内部数组,调用方 push 之后单例里的长度会变。若这不符合预期,就在返回前复制。不要为了测试方便同时导出 getInstance 和类。一个模块只留一个入口。

把验收写成固定的四步,避免只看控制台有没有打印。第一步取两次,用严格相等比较。第二步从一边写入,从另一边读出,确认改的是同一份数据。第三步确认私有函数没有出现在返回对象上,点名调用应当失败。第四步只对类的写法:在两次构造之间换掉挂在构造函数上的实例指针,下一次构造会跟着走,这说明指针是公开的。闭包那种没有这一步可做,因为实例变量不在返回值上。模块出口只保留一种取法。需要替换实例才能测试时,改测试方式,不要把内部变量改成公有字段。

相关文章

分享: