为什么要封装代码?我们经常听到:“写代码一定要封装好,高内聚,低耦合”。那么什么是好的封装,我们为什么要封装呢?其实封装有几个好处:封装代码,内部变量不会污染外部。可以作为外部调用的模块。外部调用者不需要知道实现的细节,只需要按照约定的规范使用即可。对扩展开放,对修改关闭,即开闭原则。模块不能被外部修改,既保证了模块的正确性,又留有扩展接口,方便灵活使用。如何打包代码?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){}如果我们直接使用这些类的话,是这样的:让infoPopup1=newinfoPopup(内容,颜色);letinfoPopup2=newinfoPopup(内容,颜色);letconfirmPopup1=newconfirmPopup(content,color);...每次用到的时候都要去new对应的弹窗类,我们用工厂模型改造,是这样的://添加一个包装这些类的新方法弹出功能弹出(类型,内容,颜色){开关(类型){案例'infoPopup':返回新的信息弹出(内容,颜色);case'confirmPopup':returnnewconfirmPopup(content,color);case'cancelPopup':returnnewcancelPopup(content,color);然后我们用popup代替new,直接调用函数即可:letinfoPopup1=popup('infoPopup',content,color);改造为面向对象的代码上面的代码虽然实现了工厂模式,但是开关还是感觉不是很优雅。我们用面向对象的方式改造popup,把它改成一个类,在这个类上挂载不同类型的popup窗口作为工厂方法:functionpopup(type,content,color){//如果是new调用的,返回对应类型的弹窗if(thisinstanceofpopup){returnnewthis[type](content,color);}else{//如果不是new调用的,用new调用,会跳到上面这行代码returnnewpopup(type,content,color);}}//所有类型的弹窗都挂载在原型上,成为实例方法=function(content,color){}封装到模块中。这个popup不仅在调用的时候给我们省了一个new,其实里面封装了各种相关的popup,这个popup可以直接导出为模块给别人调用,也可以挂载在window上作为模块给别人用打电话。因为popup封装了popup的各种细节,即使popup内部改了,或者增加了popup类型,或者改了popup类的名字,只要外部接口参数不变,就没有效果在外面。作为模块安装在窗口上可以使用自执行函数:(function(){functionpopup(type,content,color){if(thisinstanceofpopup){returnnewthis[type](content,color);}else{returnnewpopup(type,content,color);}}popup.prototype.infoPopup=function(content,color){}popup.prototype.confirmPopup=function(content,color){}popup.prototype.cancelPopup=function(content,color){}window.popup=popup;})()//popup模块可以直接在外面使用letinfoPopup1=popup('infoPopup',content,color);jQuery的工厂模式jQuery也是典型的工厂模式,你给他一个参数,他会返回给你一个满足参数的DOM对象。那么jQuery没有new的工厂模式是如何实现的呢?事实上,jQuery内部会为您调用new。jQuery的调用过程简化成这样:jQuery.prototype;//jQuery.fn是jQuery.prototype的简写jQuery.fn.init=function(selector){//在这里实现真正的构造函数}//让init和jQuery的原型指向同一个对象,这方便挂载实例方法jQuery.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调用的时候,它指向new出来的对象,new出来的对象自然就是类的实例,这里thisinstanceofjQuery为真。如果是普通电话,就是假的,我们帮他打新。构建器模式构建器模式用于构建比较复杂的大对象,比如Vue。Vue内部包含了一个功能强大且逻辑复杂的对象,在构建时需要传入很多参数。需要这样创建的情况并不多,当创建的对象本身很复杂时,builder模式适用。构建器模式的一般结构如下:functionModel1(){}//模块1functionModel2(){}//模块2//最后一个类使用functionFinal(){this.model1=newModel1();this.model2=newModel2();}//使用时varobj=newFinal();在上面的代码中,我们最终使用了Final,但是Final中的结构比较复杂,有很多子模块,Final就是将这些子模块组合起来完成功能,这种细粒度的构造适合于建设者模式。例子:编辑器插件假设我们有这样一个需求:写一个编辑器插件,初始化的时候需要配置大量的参数,而且内部函数很多,非常复杂。您可以更改字体颜色和大小,也可以前后移动。一般一个页面只有一个编辑器,里面的功能可能比较复杂,可能需要调整颜色、字体等。也就是说这个插件内部可能会调用其他的类,然后组合起来实现功能,适合builder模式。下面分析一下做这样一个编辑器需要哪些模块:编辑器本身肯定需要一个类,对外调用的接口需要一个控制参数初始化的类,页面渲染需要一个控制字体的类需要一个管理状态的类//editor本身暴露了functionEditor(){//editor内部就是组合各个模块实现功能this.initer=newHtmlInit();this.fontController=newFontController();this.stateController=newStateController(this.fontController);}//初始化参数,渲染页面函数HtmlInit(){}HtmlInit.prototype.initStyle=function(){}//初始化样式HtmlInit.prototype.renderDom=function(){}//渲染DOM//字体控制器functionFontController(){}FontController.prototype.changeFontColor=function(){}//改变字体颜色FontController.prototype.changeFontSize=function(){}//改变字体大小//状态控制器函数StateController(fontController){this.states=[];//存储所有状态的数组this.currentState=0;//指向当前状态的指针this.fontController=fontController;//注入字体管理器以促进状态变化orwardState=function(){}//转发状态上面的代码实际上设置了一个编辑器插件架。功能的具体实现就是在这些方法中填写具体的内容。其实就是各个模块的相互调用,比如我们要实现backstate的功能,可以这样写:StateController.prototype.backState=function(){varstate=this.states[this.currentState-1];//取出之前的状态this.fontController.changeFontColor(state.color);//变回上次的颜色this.fontController.changeFontSize(state.size);//变回上次大小}单例模式单例模式适用于全局只能有一个实例对象的场景。实例模式的一般结构如下:functionSingleton(){}Singleton.getInstance=function(){if(this.instance){returnthis.instance;}this.instance=newSingleton();returnthis.instance;}上面的代码中,Singleton类挂载了一个静态方法getInstance。如果要获取实例对象,只能通过该方法获取。此方法将检测是否存在现有的实例对象。如果有则返回,如果没有则创建一个新的实例:全局数据存储对象假设我们现在有这样一个需求:我们需要管理一个全局数据对象,并且这个对象只能有一个,如果多了一个,数据就会不同步。这种需求要求全局只有一个数据存储对象,是适合单例模式的典型场景。我们可以直接套用上面的代码模板,但是上面的代码模板必须调用getInstance来获取实例。如果用户直接调用Singleton()或newSingleton()的话,就会出现问题。这次我们换一种写法,这样就可以兼容Singleton()和newSingleton(),更傻的用:functionstore(){if(store.instance){returnstore.instance;}store.instance=this;}上面的代码支持用newstore()调用,我们用一个静态变量实例来记录是否实例化过,如果实例化了就返回这个实例,如果没有实例化说明是先调用,然后把this赋给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源码: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();Child.prototype.constructor=孩子;//注意重置构造函数constobj=newChild();console.log(obj.parentAge);//50这里的继承其实就是让子类Child.prototype.__proto__指向父类的原型,从而获得父类的方法和属性。JS中面向对象的内容很多,这里就不展开了。有一篇文章专门讨论这个问题。综上所述,很多好用的开源库都有很好的封装。包装可以将内部环境与外部环境隔离开来,使其更易于外部使用。不同的场景有不同的封装方案。需要产生大量相似实例的组件可以考虑用工厂模式进行封装。内部逻辑比较复杂,对外使用需要的例子不多。可以考虑使用builder模式打包。全局只能有一个实例需要用单例模式封装。如果新老对象之间可能存在继承关系,可以考虑用原型方式封装。JS本身就是典型的原型模式。使用设计模式时,不要生搬硬套代码模板。更重要的是,掌握思路。相同的模式在不同的场景下可以有不同的实现。
