为什么要封装代码?我们经常听到:“写代码一定要封装好,高内聚,低耦合”。那么什么是好的封装,我们为什么要封装呢?其实封装有几个好处:封装了代码,内部变量不会污染外部。可以作为外部调用的模块。外部调用者不需要知道实现的细节,只需要按照约定的规范使用即可。对扩展开放,对修改关闭,即开闭原则。模块不能被外部修改,既保证了模块的正确性,又留有扩展接口,方便灵活使用。如何打包代码?JS生态中已经有很多模块,有些模块封装的非常好,非常方便我们使用,比如jQuery,Vue等,如果仔细看一下这些模块的源码,我们会发现会发现他们的包装是正规的。这些规律总结为设计模式。代码封装的设计模式主要有四种:工厂模式、创建者模式、单例模式和原型模式。下面结合一些框架源码来看看这四种设计模式:工厂模式工厂模式的名字很直白,封装的模块就像一个工厂,批量生产需要的对象。普通工厂模式的一个特点就是调用的时候不需要用new,传入的参数也比较简单。但是调用的次数可能会比较频繁,经常需要生产不同的对象。经常调用的时候用new就方便多了。工厂模式的代码结构如下:functionfactory(type){switch(type){case'type1':returnnewType1();case'type2':returnnewType2();case'type3':returnnewType3();}}上面的代码中,我们传入了类型,然后工厂根据不同的类型创建不同的对象。示例:弹出组件让我们看一个使用工厂模型的示例。如果我们有如下需求:我们的项目需要一个弹窗。颜色和内容可能不同。对于这些类型的弹窗,我们先创建一个类:functioninfoPopup(content,color){}functionconfirmPopup(content,color){}functioncancelPopup(content,color){}如果我们直接使用这些类,就会像这样:letinfoPopup1=newinfoPopup(content,color);letinfoPopup2=newinfoPopup(content,color);letconfirmPopup1=newconfirmPopup(content,color);...每次使用都要去new对应的popup类,我们用factory模型改造下是这样的://添加一个新的方法popup来包装这些类functionpopup(type,content,color){switch(type){case'infoPopup':returnnewinfoPopup(content,color);case'confirmPopup':returnnewconfirmPopup(content,color);case'cancelPopup':returnnewcancelPopup(content,color);}}那么我们在使用popup的时候就不需要new了,直接调用函数即可:letinfoPopup1=popup('infoPopup',content,颜色);改造为面向对象上面的代码虽然实现了工厂模式,但是开关还是感觉不是很优雅。我们用面向对象的方式改造popup,把它改成一个类,在这个类上挂载不同类型的popup窗口作为工厂方法:functionpopup(type,content,color){//如果是new调用的,返回对应的类型ofpopupif(thisinstanceofpopup){returnnewthis[type](content,color);}else{//如果不是new调用的,用new调用,会转到上面这行代码returnnewpopup(type,content,color);}}//所有类型的弹窗都挂载在原型上,成为实例方法popup.prototype.infoPopup=function(content,color){}popup.prototype.confirmPopup=function(content,color){}popup.prototype.cancelPopup=function(content,color){}被打包成一个模块。这个popup不仅让我们在调用的时候省了一个新的,而且还在里面封装了各种相关的popup窗口。这个popup可以直接作为模块导出给别人调用,也可以挂载在window上作为模块给别人调用。因为popup封装了popup的各种细节,即使popup内部改了,或者增加了popup类型,或者改了popup类的名字,只要外部接口参数不变,就没有效果在外面。作为模块挂载在窗口上,可以使用自执行函数:(function(){functionpopup(type,content,color){if(thisinstanceofpopup){returnnewthis[type](content,color);}else{returnnewpopup(类型,内容,颜色);}}popup.prototype.infoPopup=函数(内容,颜色){}popup.prototype.confirmPopup=函数(内容,颜色){}popup.prototype.cancelPopup=函数(内容,颜色){}window.popup=popup;})()//popup模块可以在外面直接使用letinfoPopup1=popup('infoPopup',content,color);jQuery的工厂模式jQuery也是典型的工厂模式,你给他一个参数,他就会返回给你符合参数的DOM对象。那么jQuery没有new的工厂模式是如何实现的呢?事实上,jQuery内部会为您调用new。jQuery的调用过程简化成这样:;//jQuery.fn是jQuery.prototype的缩写jQuery.fn.init=function(selector){//真正的构造函数在这里}//让init和jQuery的原型指向同一个对象,方便挂载实例方法jQueryjQuery.fn.init.prototype=jQuery.fn;//最后在窗口window上挂载jQuery。$=window.jQuery=jQuery;})();上面的代码结构来自于jQuery的源代码。从中可以看出,你在调用时省略的new是jQuery中为你调用的。目的是为了方便大量的调用。但是这个结构需要一个init方法,最后jQuery的原型和init必须绑在一起。其实这个需求还有更简单的实现方式:varjQuery=function(selector){if(!(thisinstanceofjQuery)){returnnewjQuery(selector);}//下面才是真正构造函数的执行}上面的代码是简洁多了,不用new也可以直接调用。这里用到的特点是new调用函数的时候this指向newnew对象的对象自然是类的实例,这里的thisinstanceofjQuery是成立的。如果是普通电话,就是假的,我们帮他打新。构建器模式构建器模式用于构建比较复杂的大对象,比如Vue。Vue内部包含了一个功能强大且逻辑复杂的对象,在构建时需要传入很多参数。需要这样创建的情况并不多,当创建的对象本身很复杂时,builder模式适用。builder模式的总体结构如下:functionModel1(){}//module1functionModel2(){}//module2//finallyusedclassfunctionFinal(){this.model1=newModel1();this.model2=newModel2();}//当使用varobj=newFinal();在上面的代码中,我们最终使用了Final,但是Final中的结构比较复杂,有很多子模块,而Final就是将这些子模块组合起来完成功能,需要很精细的细节建造者模式适用于结构化结构。例子:编辑器插件假设我们有这样一个需求:写一个编辑器插件,初始化的时候需要配置大量的参数,而且内部函数很多,非常复杂。您可以更改字体颜色和大小,也可以前后移动。一般一个页面只有一个编辑器,里面的功能可能比较复杂,可能需要调整颜色、字体等。也就是说这个插件内部可能会调用其他的类,然后组合起来实现功能,适合builder模式。下面分析一下做这样一个编辑器需要哪些模块:编辑器本身肯定需要一个类,对外调用的接口需要一个控制参数初始化的类,页面渲染需要一个控制字体的类需要一个管理状态的类//Editor本身,暴露了functionEditor(){//编辑器就是组合各个模块实现功能this.initer=newHtmlInit();this.fontController=newFontController();this.stateController=newStateController(this.fontController);}//初始化参数、渲染页面.prototype.changeFontColor=function(){}//改变字体颜色FontController.prototype.changeFontSize=function(){}//改变字体大小//状态控制器functionStateController(fontController){this.states=[];//一个存储所有状态的数组this.currentState=0;//指向当前状态的指针this.fontController=fontController;//注入字体管理器,在改变状态时改变字体}StateController.prototype.saveState=function(){}//保存状态StateController.prototype.backState=function(){}//后退状态StateController.prototype.forwardState=function(){}//前进状态上面的代码其实就是对编辑器插件的架子进行设置,具体功能就是在这些方法中填写具体的内容。其实就是各个模块的相互调用,比如如果我们要实现后退状态功能,可以这样写:StateController.prototype.backState=function(){varstate=this.states[this.currentState-1];//取出前一个状态this.fontController.changeFontColor(state.color);//变回上次的颜色this.fontController.changeFontSize(state.size);//变回上次的大小}单例模式单例模式适用于只能有的场景全局一个实例对象,单例模式总体结构如下:instance;}上面代码中,Singleton类挂载了一个静态方法getInstance。如果要获取实例对象,只能通过该方法获取。此方法将检测是否存在现有的实例对象。如果有,它将返回。如果没有,创建一个新的实例:全局数据存储对象。这样一个需求:我们需要管理一个全局的数据对象,这个对象只能有一个,如果超过一个,数据就会不同步。这种需求要求全局只有一个数据存储对象,是适合单例模式的典型场景。我们可以直接套用上面的代码模板,但是上面的代码模板必须调用getInstance来获取实例。如果用户直接调用Singleton()或newSingleton()的话,就会出现问题。这次我们换一种写法,这样就可以兼容Singleton()和newSingleton(),更傻的是用:functionstore(){if(store.instance){returnstore.instance;}store.instance=this;}上面的代码支持用newstore()调用,我们用一个静态变量实例来记录是否实例化过,如果实例化了就返回这个实例,如果没有实例化说明是第一次调用,然后把this赋值给这个静态变量,因为是用new调用的,此时this指向实例化对象,最后隐式返回this。如果我们还想支持store()的直接调用,我们可以使用之前工厂模式中使用的方法,检查this是否是当前类的实例,如果不是,就用new调用:functionstore(){//增加一个instanceof检测if(!(thisinstanceofstore)){returnnewstore();}//下面和之前一样if(store.instance){returnstore.instance;}store.instance=this;}那么我们使用两种方式调用检测下一个:例子:vue-routervue-router其实是使用单例模式的,因为如果一个页面有多个路由对象,可能会造成状态冲突。vue-router的单例实现有点不同。以下代码来自vue-router源码:let_Vue;functioninstall(Vue){if(install.installed&&_Vue===Vue)return;install.installed=true_Vue=Vue}每次调用vue.use(vueRouter)时,我们实际上会执行vue-router模块的install方法,如果用户不小心多次调用vue.use(vueRouter),会导致多次执行install,导致结果不正确。第一次执行vue-router的install时,把installed属性写成true,记录当前的vue,这样后面如果在同一个vue中再次执行install,直接返回,即也是单例模式。可以看到这里的三个代码都是单例模式。虽然它们的形式不同,但它们的核心思想是相同的。它们都使用一个变量来标记代码是否已经执行。如果已执行,则返回上次。执行结果,保证多次调用得到相同的结果。原型模式最典型的应用就是JS本身,JS的原型链就是原型模式。在JS中,可以使用Object.create指定一个对象为原型来创建对象:constobj={x:1,func:()=>{}}//以obj为原型创建一个新对象constnewObj=Object.create(obj);console.log(newObj.__proto__===obj);//trueconsole.log(newObj.x);//1上面的代码我们以obj为原型,然后创建新的对象通过Object.create会有这个对象的属性和方法,这其实就是一个原型模式。另外,面向对象的JS其实就是这种模式的体现。比如JS继承可以这样写:functionParent(){this.parentAge=50;}functionChild(){}Child.prototype=newParent();ChildChild.prototype.constructor=Child;//注意重置constructorconstobj=newChild();console.log(obj.parentAge);//50这里的继承其实就是让子类Child.prototype.__proto__指向父类的原型,从而获取父类的方法和属性.JS中面向对象的内容很多,这里就不展开了。有一篇文章专门讨论这个问题。总结很多好用的开源库都有很好的封装。包装可以将内部环境与外部环境隔离开来,使其更易于外部使用。不同的场景有不同的封装方案。需要产生大量相似实例的组件可以考虑用工厂模式进行封装。内部逻辑比较复杂,外部需要的实例不多,可以考虑用构建器模式封装。全局只能有一个实例需要用单例模式封装。如果新老对象之间可能存在继承关系,可以考虑用原型方式封装。JS本身就是典型的原型模式。使用设计模式时,不要生搬硬套代码模板。更重要的是,掌握思路。相同的模式在不同的场景下可以有不同的实现。
