当前位置: 首页 > 科技观察

前端设计模式系列-单例模式

时间:2023-03-20 21:22:27 科技观察

代码写了好几年了,设计模式一直处于忘我状态。大多数关于设计模式的文章都使用基于类的静态类型语言,例如Java和C++。作为前端开发人员,js是一门基于原型的动态语言。职能已成为一等公民。模式略有不同,甚至简单得不像使用设计模式,有时会引起一些混淆。下面按照“场景”-“设计模式定义”-“代码实现”-“更多场景”-“通用”的顺序进行总结。如有不妥之处,欢迎交流讨论。如果场景需要实现全局加载遮罩层,正常显示是这样的:但是如果用户连续两次调用loaing,第二个遮罩层会覆盖第一个:看起来像一个bug,因此,我们需要采用单例模式,限制用户一次只能调用一个全局加载。参见维基百科对单例模式的定义:在软件工程中,单例模式是一种将类的实例化限制为一个“单一”实例的软件设计模式。这在恰好需要一个对象来协调整个系统的动作时很有用。”可以说是最简单的设计模式,就是保证类只有一个实例。看看java例子:publicclassSingleton{privatestaticfinalSingletonINSTANCE=newSingleton();privateSingleton(){}publicstaticSingletongetInstance(){returnINSTANCE;}}上面类初始化时,创建对象,设置构造函数asprivate,不允许外部调用,提供getInstance方法获取对象。还有一种Lazy初始化模式,就是延迟创建对象,直到调用getInstance。但是如果多个线程同时调用getInstance,可能会创建多个对象,所以也需要加锁。publicclassSingleton{privatestaticvolatileSingletoninstance=null;privateSingleton(){}publicstaticSingletongetInstance(){if(instance==null){synchronized(Singleton.class){if(instance==null){instance=newSingleton();}}}返回实例;}}但是单例模式也有很多争议,比如可测试性差,对抽象、继承、多态的支持不友好等等,但是我感觉主要是基于class这种语言带来的问题不会在这里讨论。回到js模拟上面的实现:constSingleton=function(){this.instance=null;};Singleton.getInstance=function(name){if(!this.instance){this.instance=newSingleton();}returnthis.instance;};consta=Singleton.getInstance();constb=Singleton.getInstance();console.log(a===b);//没错但是上面的像邯郸学步真的是模仿了java的实现,其实js也不一定要用new来创建对象,下面详细说说。js的单例模式首先,单例模式生成的对象一般都是工具类对象,比如jQuery。它不需要我们通过构造函数传递参数,所以不需要创建一个新的构造函数来生成对象。我们只需要传递字面量对象,vara={},a就可以看成是一个单例对象。一个常见的单例对象可能如下所示,暴露了几个方法供外部使用。varSingleton={method1:function(){//...},method2:function(){//...}};但是如果Singleton有私有属性,可以这样写:varSingleton={privateVar:'我是私有属性',method1:function(){//...},method2:function(){//...}};但此时外界可以通过Singleton修改privateVar的值。为了解决这个问题,我们可以使用闭包,通过IIFE(ImmediatelyInvokedFunctionExpression)将一些属性和方法私有化。varmyInstance=(function(){varprivateVar='';functionprivateMethod(){//...}return{method1:function(){},method2:function(){}};})();但是随着ES6和Webpack的出现,我们很少像上面这样定义一个模块,而是通过单个文件,一个文件就是一个模块,也可以看成是一个单例对象。//singleton.jsconstsomePrivateState=[]functionprivateMethod(){//...}exportdefault{method1(){//...},method2(){//...}}然后在使用时导入它。//main.jsimportSingletonfrom'./singleton.js'//...导入同一个文件,即使有另一个文件。//main2.jsimportSingletonfrom'./singleton.js'但是两个不同文件的Singleton还是同一个对象,这是ESModule的一个特点。那么如果ES6通过Webpack转ES5,这个方法还会是单例对象吗?答案当然是可以的,可以看看Webpack打包出来的产品,其实就是用了IIFE,并且在第一次导入的同时对模块进行了缓存,第二次导入的时候会使用之前的缓存时间。可以看看__webpack_require__的实现,和单例模式的逻辑是一样的。函数__webpack_require__(moduleId){varcachedModule=__webpack_module_cache__[moduleId];//单例应用if(cachedModule!==undefined){returncachedModule.exports;}varmodule=(__webpack_module_cache__[moduleId]={exports:{},});__webpack_modules__[moduleId](module,module.exports,__webpack_require__);returnmodule.exports;}代码实现了我们开头提到的全局加载问题,解决起来也很简单。同样,如果已经有加载实例,我们直接返回即可。这里直接看一下ElementUI对全局加载的处理。//~/packages/loading/src/index.jsletfullscreenLoading;constLoading=(options={})=>{...//如果options不传,默认全屏options=merge({},defaults,options);如果(options.fullscreen&&fullscreenLoading){returnfullscreenLoading;//有直接返回}letparent=options.body?文档正文:选项。目标;letinstance=newLoadingConstructor({el:document.createElement('div'),data:options});...if(options.fullscreen){fullscreenLoading=instance;}返回实例;};这样,在使用Elementloading时,如果同时调用两次,只会出现一个loading遮罩层,第二个不显示。mounted(){constfirst=this.$loading({text:'我是第一个全屏加载',})constsecond=this.$loading({text:'我是第二个'})console.log(第一===第二);//true}如果更多场景使用ES6模块,就不用考虑单例与否的问题了,但是如果我们使用第三方库,它不导出实例对象,而导出函数呢/班级?比如前面介绍的发布订阅模式的Event对象,必须是全局单例。如果我们使用eventemitter3节点包,请看一下它的export:'usestrict';varhas=Object.prototype.hasOwnProperty,prefix='~';/***为我们的EE对象创建存储的构造函数。*`Events`实例是一个普通对象,其属性是事件名称。**@constructor*@private*/functionEvents(){}////我们尽量不继承自`Object.prototype`。在某些引擎中,以这种方式创建//实例比直接调用`Object.create(null)`更快。//如果不支持`Object.create(null)`,我们在事件名称前加上//字符确保内置对象属性没有//被覆盖或用作攻击向量。//如果(Object.create){Events.prototype=Object.create(null);////这个hack是必需的,因为`__proto__`属性在//Android4、iPhone5.1、Opera11和Safari5等旧浏览器中仍然继承。//if(!newEvents().__proto__)prefix=false;}/***单个事件侦听器的表示。**@param{Function}fn监听函数。*@param{*}context调用侦听器的上下文。*@param{Boolean}[once=false]指定侦听器是否为一次性侦听器。*@constructor*@private*/functionEE(fn,context,once){this.fn=fn;this.context=上下文;this.once=一次||false;}/***为给定事件添加监听器。**@param{EventEmitter}emitter对“EventEmitter”实例的引用。*@param{(String|Symbol)}event事件名称。*@param{Function}fn监听函数。*@param{*}context调用侦听器的上下文。*@param{Boolean}once指定监听器是否为一次性列表神经病。*@returns{EventEmitter}*@private*/functionaddListener(emitter,event,fn,context,once){if(typeoffn!=='function'){thrownewTypeError('监听器必须是一个函数');}varlistener=newEE(fn,context||emitter,once),evt=prefix?前缀+事件:事件;如果(!emitter._events[evt])emitter._events[evt]=listener,emitter._eventsCount++;elseif(!emitter._events[evt].fn)emitter._events[evt].push(listener);elseemitter._events[evt]=[emitter._events[evt],listener];returnemitter;}/***按名称清除事件。**@param{EventEmitter}emitter对“EventEmitter”实例的引用。*@param{(String|Symbol)}evt事件名称。*@private*/functionclearEvent(emitter,evt){如果(--emitter._eventsCount===0)emitter._events=newEvents();elsedeleteemitter._events[evt];}/***根据Node.js*`EventEmitter`接口模制的最小`EventEmitter`接口。**@constructor*@public*/functionEventEmitter(){this._events=newEvents();this._eventsCount=0;}.../***为给定事件添加监听器。**@param{(String|Symbol)}event事件名称。*@param{Function}fn监听函数。*@param{*}[context=this]调用侦听器的上下文。*@returns{EventEmitter}`this`。*@public*/EventEmitter.prototype.on=functionon(event,fn,context){returnaddListener(this,event,fn,context,false);};......//别名方法名因为人们是那样滚动的//允许将`EventEmitter`作为模块命名空间导入。//EventEmitter.EventEmitter=EventEmitter;////公开模块。//if('undefined'!==typeofmodule){module.exports=EventEmitter;}可以看到直接用EventEmitter这个函数导出,如果每个页面都导入,那么通过newEventEmitter()来生成对象,然后发布和订阅就会乱七八糟,因为它们不是同一个对象。这时候我们可以新建一个模块,然后导出一个实例化的对象,其他页面就可以使用这个对象来实现单例模式。importEventEmitterfrom'eventemitter3';//全局唯一事件总线constevent=newEventEmitter();导出默认事件;总的单例模式比较简单,主要是保证全局对象的唯一性,但是相对于类模式生成对象的单例,js是非常特殊的。因为在js中我们可以直接生成对象,而这个对象是全局唯一的,所以在js中,单例模式是天生的,我们平时是感知不到的。尤其是现在开发使用ES6模块,每个模块也是一个单例对象,在正常的业务开发中很少用到单例模式。为了举上面的例子,脑细胞真是累坏了,哈哈。