前言
本篇分两个部分进行介绍,第一部分为JS的基础知识,主要介绍设计模式中一些相关的周边知识,例如JS的面向对象、this的指向以及闭包和高阶函数的应用。第二部分为正片部分,主要介绍JS的14种设计模式。
第一部分 JS相关基础
1、JS中的面向对象
1.1动态类型语言和鸭子类型
编程语言按照数据类型大体可以分为两类,一类是静态类型语言,一类是动态类型语言。
静态类型语言:静态类型语言在编译时已经明确变量的数据类型,优点是编译器可以在运行之前进行数据类型的检测,避免在程序运行期间发生数据类型的错误,因为明确了数据类型,编译器还可以针对这些信息进行一些优化工作,从而提高执行效率。
动态类型语言:动态类型语言的变量要到程序运行时,待变量被赋值之后才会具有某种类型。优点时代码简洁,虽然不区分数据类型,但是可以把更多精力放到业务的逻辑层上。
JS是一门典型的动态类型语言,它对变量类型的宽容给实际编码带来了很大的灵活性。由于无需进行类型检测,我们可以尝试调用任何对象的任意方法,而无需去考虑它原本是否被设计为拥有该方法。这一切都建立在鸭子类型(duck typing)的概念上,通俗说法是:“如果它走起路来像鸭子,叫起来也是鸭子,那么它就是鸭子。”
从前有一个国王,他觉得世界上最美妙的声音就是鸭子的叫声,于是国王召集大臣,要组建一个1000只鸭子组成的合唱团。大臣们找遍了全国,终于找到999只鸭子,但是始终还差一只,最后大臣发现有一只非常特别的鸡,它的叫声跟鸭子一模一样,于是这只鸡就成为了合唱团的最后一员。
这个故事告诉我们,国王要听的只是鸭子的叫声,这个声音的主人到底是鸡还是鸭并不重要。鸭子类型指导我们只关注对象的行为,而不关注对象本身。
let duck={
call(){
console.log("gagaga")
}
}
let chicken={
call(){
console.log("gagaga")
}
}
duck.call();//输出"gagaga",符合条件
chicken.call();//输出"gagaga",符合条件
1.2多态
多态的概念:同一操作作用于不同的对象上面,可以产生不同的执行结果。换句话说,给不同的对象发送同一个请求,这些对象会根据这个请求给出不同的反馈。
从字面上理解可能不太容易,下面举例说明一下:在电影的拍摄现场,当导演喊出“action”时,主角开始背台词,照明师负责打灯光,跑龙套的负责假装中枪倒地,道具师负责点燃炸药。在得到同一个指令时,每个对象都知道自己该作什么,并且每个对象所做的事情也都不一样,这就是对象所表现出来的多态性。
多态的最根本目的:通过把过程化的条件分支语句转化成对象的多态性,从而消除这些条件分支语句。
下面来举个实际例子说明一下:
function callOut(annimal){
if(annimal instanceof Forg){
console.log("呱呱呱");
}else if(annimal instanceof Cat){
console.log("喵");
}
}
let Forg=function(){};
let Cat=function(){};
callOut(new Forg());//输出“呱呱呱”
callOut(new Cat());//输出“喵”
上述代码确实体现了多态性,当我们发出指令“叫”时,它们会根据指令做出不同的反应,但是如果再增加一只狗,狗是“汪汪汪”叫,此时我们必须改动callOut函数,也就是增加该函数的分支语句。修改代码总是伴随风险的,修改的地方越多,程序出错的概率也就越大,如果当动物越来越多时,callOut的函数体也会变得巨大无比,不利于我们后去维护。我们可以把不变的部分分离出来,把可变的部分封装起来,这个例子中不变的是动物叫,但不同的动物具体怎么叫的这部分是可变的,我们将“做什么”和“谁去做以及怎样去做”进行分离,让程序看起来是可生长的。代码优化结果如下:
let Forg=function(){};
let Cat=function(){};
Forg.prototype.call=function(){
console.log("呱呱呱");
}
Cat.prototype.call=function(){
console.log("喵");
}
function callOut(annimal){
annimal.call();
}
callOut(new Forg());//输出“呱呱呱”
callOut(new Cat());//输出“喵”
这样代码不仅可扩展性增强了,也消除了条件分支。
1.3封装
封装就是将信息隐藏,封装可分为封装数据、封装实现、封装类型、封装变化。
封装数据:许多语言提供了private、public、protected等关键字来提供不同的访问权限,但是JS中没有对这些关键字的支持,我们可以依赖变量的作用域来实现数据的封装,ES6中可以使用let和{}块级作用域来实现数据封装,也可以利用函数的作用域来实现封装:
let obj=(function(){
let _name="张朝阳";
return {
getName(){
return _name;
}
}
})()
console.log(obj.getName());//输出“张朝阳”
console.log(_name);//输出“undefined”
封装实现:将实现细节、设计细节进行封装并对外暴露API接口来通信,这样用户可以不在关心它的内部实现,只要外部的接口没有变化,就不会影响到程序的其它功能。
封装类型:一般封装类型是对静态类型语言来说的,通过抽象类和接口来进行类型封装,但JS是一种模糊类型的动态语言,所以在封装类型方面,JS没有能力,也没必要去做。
封装变化:封装变化就是把系统中稳定不变的部分和容易变化的部分分离出来,然后依次进行封装。当系统中需要修改变化的那部分时,因为这部分之前已经封装好了,替换起来也相对容易,这样做可以保证程序的稳定性和可扩展性。
1.4原型模式和基于原型继承的JS对象系统
原型模式是创建对象的一种模式。如果我们想要创建一个对象,一种方法是先指定它的类型,然后通过类来创建这个对象。另一种就是原型模式,我们不再关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象。原型模式的关键在于语言本身是否提供了clone方法。ES5提供了Object.create方法,可以用来克隆对象。代码如下:
function Person(){
this.name="张朝阳";
}
let p1=new Person();
let p2=Object.create(p1);
console.log(p2.name);//输出“张朝阳”
基于原型继承的JS对象系统:任何对象都有一个原型对象,这个原型对象由对象的内置属性_proto_指向它的构造函数的prototype指向的对象,即任何对象都是由一个构造函数创建的,被创建的对象都可以获得构造函数的prototype属性,对象是没有prototype属性,只有函数才有prototype属性。任何对象都有一个constructor属性,指向创建此对象的构造函数,比如说{}对象,它的构造函数是function Object(){}。
构造函数:用function声明的都是函数,而如果直接调用的话,那么它就是一个普通函数,当用到关键字new调用该函数时,这个函数就被称作是构造函数,也是new出来对象的构造函数。下面用代码演示以上概念:
function Person() {
}
var p = new Person();
//方法才有prototype,普通对象无prototype
console.log(Person.prototype); // Object{}
console.log(p.prototype); // undifined
//任何对象都是有构造函数constructor,由构造函数创建的对象也可以获得构造函数的引用
//此处只是打印下列对象的构造函数是什么。
console.log(p.constructor); //function Person(){}
console.log(Person.constructor); //function Function(){}
console.log({}.constructor); // function Object(){}
console.log(Object.constructor); // function Function() {}
console.log([].constructor); //function Array(){}
JS的原型链继承,先看如下代码:
let A=function(){};
A.prototype={"name":"张朝阳"};
let B=function(){};
B.prototype=new A();
let b=new B();
console.log(b.name);//输出“张朝阳”
先看看这段代码执行的时候,引擎做了什么事情。
- 首先,尝试遍历对象b中的所有属性,但没有找到name这个属性。
- 查找
name属性的请求被委托给对象b的构造函数的原型,它被b._propt_记录着并且指向B.prototype,而B.prototype被设置为一个通过new A()创建出来的对象。 - 在对象中依然没有找到
name属性,于是请求被继续委托给这个对象构造器的原型A.prototype。 - 在
A.prototype中找到了name属性,并返回它的值。 和把B.prototype直接指向一个字面量对象相比,通过B.prototype=new A()形成的原型链比之前多了一层。但二者之间没有本质上的区别,都是将对象构造器的原型指向另外一个对象,继承总是发生在对象和对象之间,原型链并不是无限长的,他的根节点就是Object.prototype。
2、this、call、apply、bind介绍
2.1 this
JS中的this总是指向一个对象,而具体指向哪个对象是在运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境。this的指向大致可以分为4种:
- 作为对象的方法调用
let person={
name:"张朝阳",
getName(){
console.log(this===person);//输出true
console.log(this.name);//输出“张朝阳”
}
}
person.getName();
- 作为普通函数调用
当函数不作为对象的属性被调用时,也就是我们常常说的普通函数方式,此时的
this总是指向全局对象。在JS中,这个全局对象就是window对象。
window.name="张朝阳";
function getName(){
console.log(this.name);
}
getName();//输出“张朝阳”
值得注意的是,当我们给div节点添加事件函数时,函数内部this指向的是这个div对象,当在函数内部定义一个普通函数时(除了箭头函数外,因为箭头函数不存this,它会调用父级的this指向),这个普通函数内部的this是指向window对象的。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<div id="test">点击事件</div>
<script type="text/javascript">
window.id="张朝阳";
document.getElementById("test").onclick=function(){
console.log(this.id);//输出“test”
function callback(){
console.log(this.id);//输出“张朝阳”
}
callback();
let handle=()=>{
console.log(this.id);//输出“test”
}
handle();
}
</script>
</body>
</html>
- 构造器调用
JS中的大部分函数都可以当作构造器使用。构造器的外表跟普通函数一模一样,它们的区别在于被调用的方式。当用
new运算符调用函数时,该函数总会返回一个对象,通常情况下,构造器里的this就指向返回的这个对象。
function Person(){
this.name="张朝阳";
}
let p=new Person();
console.log(p.name);//输出“张朝阳”
但是用new调用构造器时,还要注意一个问题,如果构造器显示地返回了一个object类型的对象,那么此次运算结果最终会返回这个对象,也就是this指向了这个返回的对象。
function Person(){
this.name="张朝阳";
return {
name:"罗永浩"
}
}
let p=new Person();
console.log(p.name);//输出“罗永浩”
如果构造器不显式地返回任何数据,或者是返回一个非对象类型的数据,就不会造成上述的问题。
function Person(){
this.name="张朝阳";
return "罗永浩"
}
let p=new Person();
console.log(p.name);//输出“张朝阳”
- 函数被
call或apply调用时call和apply可以动态的改变函数内部的this指向
let p1={
name:"张朝阳",
getName(){
console.log(this.name);
}
}
let p2={
name:"罗永浩"
}
p1.getName();//输出“张朝阳”
p1.getName.call(p2);//输出“罗永浩”
函数中的this指向是跟执行环境息息相关的,而跟声明环境无关,比如:
let p1={
age:30,
getAge(){
console.log(this.age);
}
}
p1.getAge();//输出30
let p2=p1.getAge;
p2();//输出“undefined”
当调用p1.getAge()时,getAge方法死作为p1对象的属性被调用的,此时的this指向p1对象,当用过另外一个变量p2来引用p1.getAge,并且调用p2时,此时时普通函数的调用方式,this是指向全局window对象的,所以程序执行的结果是undefined。
2.2 call、apply、bind
相同之处:改变函数体内this的指向。
相同之处:call、apply接受参数的方式不一样。bind不立即执行,而apply、call立即执行。
具体分析(call、apply的区别:接受参数的方式不一样):
let arr = [1,5,9,3,5,7];
Math.max.apply(Math, arr);//结果:9
Math.max.call(Math, 1,5,9,3,5,7);//结果:9
具体分析(改变this指向,bind不立即执行,而apply、call立即执行):
let p1={
"name":"张朝阳"
}
let getName=function (){
console.log(this.name)
}.bind(p1)
getName();//输出“张朝阳”
当使用call或者apply的时候,如果我们传入的第一个参数为null,函数体内的this会指向默认的宿主对象,在浏览器中则是window。
call、apply除了可以改变this的指向,也可借用其它对象的方法,比如可以借用构造函数,通过这种方式,可以实现一些类似继承的效果:
function A(name){
this.name=name;
}
function B(){
A.apply(this,arguments);
}
B.prototype.getName=function(){
return this.name;
}
let b=new B("张朝阳");
console.log(b.getName());//输出:"张朝阳"
也可以借助Array.prototype方法向函数参数中(arguments)添加一些新的元素:
(function(){
Array.prototype.push.call(arguments,3);
console.log(arguments);//输出类数组[1,2,3]
})(1,2)
3、闭包和高阶函数
3.1 闭包
闭包的成因:闭包的形成与变量的作用域以及变量的生存周期密切相关。
变量的作用域就是指变量的有效范围,通常都是指在函数中声明变量的作用域,一般情况下需要使用var或者let关键字在函数中声明变量,如果变量名称前没有带上关键字,则这个变量就会成为全局变量。一般在函数内部用关键字var或let声明的变量为局部变量,这个变量只有在函数的内部才可以访问,在函数外面是访问不到的。示例代码如下:
function test(){
let a=1;
console.log(a);//输出1
}
test();
console.log(a);//控制台报错,a is not defined
函数内部访问变量的特点:当在一个函数中要访问某一个变量时,如果该函数并没有声明这个变量,那么它会继续在作用域外层逐层搜索,一只搜索到全局对象为止。变量的搜索是由内到外的。示例代码如下:
let a=1;
function test(){
let b=2;
function test2(){
let c=1;
console.log(a);//输出1
console.log(b);//输出2
}
test2();
console.log(c);//报错 c is not defined
}
test();
变量的生命周期:对于全局变量来说,它的声明周期是永久的,除非我们主动销毁这个全局变量(即赋值null),而对于函数内部声明的局部变量,当退出函数时,它们通常会随着函数调用的结束而被销毁,当然还有一种特殊的情况存在,就是这个局部变量可以被外界访问时,就有了一个不被销毁的理由,这个局部变量的生命周期看起来被延续了,我们把这种环境称之为闭包。示例代码如下:
function test(){
let a=1;
return function(){
a++;
alert(a);
}
}
let fn=test();
fn();//输出2
fn();//输出3
fn();//输出4
当调用fn()时,fn是一个匿名函数,它可以访问到test函数环境中的局部变量a,所以a并没有随着函数test执行结束后被销毁,而是被保存在了一个闭包的环境。
闭包的作用
1.封装变量
闭包可以把一些不需要暴露在全局的变量封装成“私有变量”,如之前示例中的变量a,我们可以通过返回的函数来对这个变量进行修改和访问。
2.延续局部变量的寿命
Image对象可用于进行数据上报,代码如下:
function report(src){
let img=new Image();
img.src=src;
}
report("http://www.xxx.com/xxx");
上述方法进行数据上报在低版本浏览器中会出现问题,会有30%左右的数据上报丢失,原因在于report函数并不是每一次都成功发起了HTTP请求,img为report函数中的局部变量,当report函数的调用结束后,img局部变量随即被销毁,此时或许还没来得及发出HTTP请求,所以此次的数据上报会丢失。我们可以把img变量用闭包封闭起来,延长改变量的寿命,就可以解决数据丢失的问题,修改代码如下:
let report=(function(){
let imgs=[];
return function(src){
let img=new Image();
imgs.push(img);
img.src=src;
console.log(imgs);
}
})()
report("http://www.xxx.com/xxx");
闭包与内存的关系
闭包是一个非常强大的特性,但对其也有很多误解。说是闭包会造成内存泄露,所以要减少闭包的使用。这种观点是不对的。闭包中的局部变量如果没有被设置成null,效果是等同于全局变量的,都会存在于内存当中,这里并不能说成是内存泄露,如果在将来我们不在使用这些变量了,完全可以手动把这些变量设置成null来释放内存空间。跟闭包和内存泄露有关系的地方是,使用闭包的同时比较容易形成循环引用,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,才会造成内存泄露,本质上是循环引用造成的内存泄露,跟闭包关系不大。如果要解决循环引用带来的内存泄露,我们只要把循环引用中的变量设置为null即可。
3.2 高阶函数
高阶函数的特点:1.函数可以作为参数被传递;2.函数可以作为返回值输出;
1.函数作为参数传递
把函数作为参数传递,这样做的目的是为了把容易变化的业务逻辑抽离出来,并把这部分业务逻辑封装成函数,作为参数进行传递,这样一来就可以分离业务代码中变化与不变的部分。其中最重要的场景应用就是回调函数。
比如有一个需求是在页面中创建10个div节点,然后把这些div节点都设置为隐藏,然后我们编写代码如下:
function appendDiv(){
for(let i=0;i<10;i++){
let oDiv=document.createElement('div');
oDiv.innerHTML=i;
document.body.appendChild(oDiv);
oDiv.style.display="none";
}
}
appendDiv();
把oDiv.style.display="none";的逻辑代码写在appendDiv函数内部显然是不合理的,因为它是个性化代码,一旦写入函数中,那么该函数就会成为一个难以复用的函数,并不是每个人创建了节点之后都要做隐藏操作,也有可能进行一些其它的操作,于是我们把oDiv.style.display="none";抽离出来,用回调函数的形式将其传入到appendDiv函数中。代码做如下修改:
function appendDiv(callback){
for(let i=0;i<10;i++){
let oDiv=document.createElement('div');
oDiv.innerHTML=i;
document.body.appendChild(oDiv);
if(typeof callback === 'function'){
callback(oDiv);
}
}
}
appendDiv(function(obj){
obj.style.display="none";
});
可以看到,隐藏节点的请求实际上是由客户发起的,但是客户并不知道节点什么时候会创建好,于是把隐藏节点的逻辑放在回调函数中,委托给appendDiv方法。
2.函数作为返回值输出
相比把函数当作参数传递,函数当作返回值输出的应用场景也许更多,也更能体现函数式编程的巧妙。让函数继续返回一个可执行的函数,意味着运算过程是可延续的。示例如下:
function test(){
let a=1;
return function(){
a++;
alert(a);
}
}
let fn=test();
fn();//输出2
fn();//输出3
fn();//输出4
高阶函数实现AOP
AOP的主要作用是把一些核心业务模块无关的功能抽离出来,如日志统计、安全控制、异常处理等。然后把抽离的部分通过动态植入到业务逻辑中,我们可以借助Function.prototype来做到这一点,示例如下:
Function.prototype.before=function(beforeFn){
let self=this;//这个this为原函数
return function(){
beforeFn.apply(this,arguments);//这个this指向window对象,执行传入的函数
return self.apply(this,arguments);//这个this指向window对象,执行原函数
}
}
Function.prototype.after=function(afterFn){
let self=this;
return function(){
self.apply(this.arguments);
afterFn.apply(this,arguments);
}
}
let func=function(){
console.log(2);
}
func=func.before(function(){
console.log(1);
}).after(function(){
console.log(3);
})
func();//控制台依次输出1,2,3
高阶函数的其它应用
1.currying(柯里化)
因为柯里化从字面上理解并不太容易,下面来举例说明。比如我想计算一下3天的开销一共花了多少钱。代码如下:
let total=0;
function cost(money){
total=total+money;
}
cost(100);
cost(200);
cost(300);
console.log(total);//输出3天的花销为600;
通过上述代码,我们可以知道,每天结束后我都会计算一遍到今天为止一共花了多少钱,但是我们其实并不关心这些, 我只想得到最后一共花了多少钱,也就是我只要在最后一天计算出一共花了多少钱就行了,不用每一天都做计算。这就是currying。我们可以将上述代码进行柯里化改造。代码如下:
let calTotal=(function(){
let arr=[];
return function(){
if(arguments.length===0){
let total=0;
for(let i=0;i<arr.length;i++){
total=total+arr[i];
}
return total;
}else{
arr.push.apply(arr,[...arguments]);
}
}
})();
calTotal(100);
calTotal(200);
calTotal(300);
console.log(calTotal());//输出3天的花销为600;
2.uncurrying(反柯里化)
反柯里化的作用在与扩大函数的适用性,使本来作为特定对象所拥有的功能的函数可以被任意对象所用。即把如下给定的函数签名obj.func(arg1, arg2)转化成一个函数形式,签名如下func(obj, arg1, arg2),这就是反柯里化的形式化描述。
3.函数节流
在某些场景下,函数可能被非常频繁的被调用,而造成大的性能问题。比如window.onresize事件函数里有一些复杂的操作,而大量频繁触发该函数肯定会造成一些卡顿,当浏览器窗口变化的时候,假设1秒钟执行了100次内部的操作逻辑,很显然我们并不需要如此频繁的执行该内部逻辑,而实际上只需要执行一两次,这就需要我们按时间段来忽略一些内部逻辑的执行,比如500ms内我们只执行一次,这是就要用到setTimeout来完成这件事情。示例如下:
function throttle(fn,interval){
let timer=null;
let firstTime=true;
return function(){
let that=this;
let args=arguments;
if(firstTime){
fn.apply(this,args);
return firstTime=false;
}
if(timer){
return false;
}
timer=setTimeout(function(){
clearTimeout(timer);
timer=null;
fn.apply(that,args);
},interval||500)
}
}
window.onresize=throttle(()=>{
console.log(1);
})
4.分时函数
由于用户的某些操作,需要在页面中大量渲染或者操作DOM的时候,势必会造成页面的卡顿,比如需要在页面中一次性创建成千上万的DOM节点时,如果需要保证页面不出现卡顿,这时就需要进行分时分批操作。先看下会造成页面卡顿或假死的示例代码:
let arr=[];
for(let i=0;i<100000;i++){
arr.push(i);
}
function renderList(list){
for(let i=0;i<list.length;i++){
let div=document.createElement('div');
div.innerHTML=i;
document.body.appendChild(div);
}
}
renderList(arr);//渲染很慢
将代码进行优化,示例如下:
首先我们创建一个timeChunk函数,它可以接收3个参数,第一个参数是创建DOM所需要的数据,第二个参数是封装了创建节点的逻辑函数,第三个参数表示每一批创建节点的数量。
let arr=[];
for(let i=0;i<100000;i++){
arr.push(i);
}
function timeChunk(list,fn,count){
let timer=null;
function start(){
let length=list.length;
for(let i=0;i<Math.min(count||1,length);i++){
let item=list.shift();
fn(item);
}
}
return function(){
timer=setInterval(function(){
if(list.length===0){
return clearInterval(timer);
}else{
start();
}
},200)
}
}
let render=timeChunk(arr,function(item){
let div=document.createElement('div');
div.innerHTML=item;
document.body.appendChild(div);
},10)
render();
5.惰性加载函数
在实际开发中,由于浏览器之间存在一些差异,一些嗅探工作是不可避免的。当然现在的主流浏览器之间差异性已经非常小,这里只是举例说明如何在兼容ie8的情况下做一个通用的对象事件绑定。
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title></title>
</head>
<body>
<button id="test">点击我</butt>
<script type="text/javascript">
let addEvent=function(elem,type,handler){
if(window.addEventListener){
return elem.addEventListener(type,handler,false);
}
if(window.attachEvent){
return ele.attachEvent('on'+type,handler);
}
}
let obj=document.getElementById('test');
addEvent(obj,'click',()=>{
alert(1);
})
</script>
</body>
</html>
这种方法的缺点是,每次被调用都要执行里面的if语句,虽然if条件语句开销不大,但还是有办法来避免条件语句中的重复性判断,优化后代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title></title>
</head>
<body>
<button id="test">点击我</butt>
<script type="text/javascript">
let addEvent=(function(){
if(window.addEventListener){
return function(elem,type,handler){
elem.addEventListener(type,handler,false);
}
}
if(window.attachEvent){
return function(elem,type,handler){
ele.attachEvent('on'+type,handler);
}
}
})();
let obj=document.getElementById('test');
addEvent(obj,'click',()=>{
alert(1);
})
</script>
</body>
</html>
这种方式在代码加载的时候就立刻进行一次判断,之后如果再次执行,则不需要继续进行判断,但是这里依旧有一个缺点,就是当我们从始至终没有使用过addEvent函数,也会执行一次嗅探的判断工作,那这个嗅探工作明显有些多余,所以我们可以将代码继续优化,如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title></title>
</head>
<body>
<button id="test">点击我</butt>
<script type="text/javascript">
let addEvent=function(elem,type,handler){
if(window.addEventListener){
addEvent=function(elem,type,handler){
elem.addEventListener(type,handler,false);
}
}else if(window.attachEvent){
addEvent=function(elem,type,handler){
elem.attachEvent('on'+type,handler);
}
}
addEvent(elem,type,handler);
}
let obj=document.getElementById('test');
addEvent(obj,'click',()=>{
alert(1);
})
</script>
</body>
</html>
这种方式就是惰性加载函数,当函数在第一次执行之后,函数内部会重新当前调用的函数,这样在下一次调用时,因为分支逻辑已经被覆盖,所以执行效率就得到了提升。
第二部分 设计模式
这部分内容并没有全部涵盖GoF所提出的23种设计模式,而是选择了在JS开发种更常见的14种设计模式。
4、单例模式
单例模式的定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
4.1实现单例模式
要实现一个标准的单例模式并不复杂,无非是用一个变量来标志当前是否已经为某个类创建过对象,如果是,则在下一次获取该类的实例时,直接返回之前创建的对象。代码如下:
let Singleton=function(name){
this.name=name;
this.instance=null;
}
Singleton.prototype.getName=function(){
alert(this.name);
}
Singleton.getInstance=function(name){
if(!this.instance){
this.instance=new Singleton(name);
}
return this.instance
}
let a=Singleton.getInstance("a");
let b=Singleton.getInstance("b");
alert(a===b);//输出true
或者:
let Singleton=function(name){
this.name=name;
}
Singleton.prototype.getName=function(){
alert(this.name);
}
Singleton.getInstance=(function(){
let instance=null;
return function(name){
if(!instance){
instance=new Singleton(name);
}
return instance;
}
})()
let a=Singleton.getInstance("a");
let b=Singleton.getInstance("b");
alert(a===b);//输出true
上述代码完成了一个简单的单例模式,但是有一个问题,就是增加了这个类的不透明性,Singleton类的使用者必须知道这是一个单例类,跟以往通过new操作符的方式获取的对象不同,这里要使用Singleton.getInstance来获取对象,我们可以进一步优化。
4.2透明的单例模式
用户从类中创建单例对象的时候,可以像使用其他任何普通类型一样,代码如下:
let CreateDiv=(function(){
let instance;
let CreateDiv=function(html){
if(instance){
return instance;
}
this.html=html;
this.init();
return instance=this;
}
CreateDiv.prototype.init=function(){
let div=document.createElement("div");
div.innerHTML=this.html;
document.body.appendChild(div);
}
return CreateDiv;
})()
let a=new CreateDiv('a');
let b=new CreateDiv('b');
alert(a===b);//输出true
上述代码虽然完成了一个透明的单例模式,但是代码看起来有些复杂,阅读起来也不是很舒服。CreateDiv这个构造函数实际上做了两件事,一是创建对象并初始化init方法,二是保证有且仅有一个对象。实际上我们可以把这两件事从构造函数中拆分出来,做进一步优化。因为当前这种结构不利于改造维护,一单我们不在需要单一实例,那就要彻底改变构造函数。
4.3用代理实现单例模式
我们通过代理的模式解决上述问题,首先把CreateDiv构造函数中管理单例的代码取出来,让他变成一个普通的类,代码如下:
let CreateDiv=function(html){
this.html=html;
this.init();
}
CreateDiv.prototype.init=function(){
var div=document.createElement('div');
div.innerHTML=this.html;
document.body.appendChild(div);
}
接下来引入代理类proxySingletonCreateDiv:
let ProxySingletonCreateDiv=(function(){
let instance;
return function(html){
if(!instance){
instance=new CreateDiv(html);
}
return instance;
}
})()
let a=new ProxySingletonCreateDiv('a');
let b=new ProxySingletonCreateDiv('b');
alert(a===b);//输出true
跟之前不同的是,现在我们把管理单例的逻辑委托给了proxySingletonCreateDiv这个类,这样CreateDiv就变成了一个普通的类,代码也变得更加容易复用和维护。
4.4 JavaScript中的单例模式
前面的单例模式的实现,更多的是针对传统的面向对象语言的实现,JS其实是一门无类语言,生搬硬套传统面向对象语言的单例模式毫无意义,也不适用于JS。
单例模式的核心是确保只有一个实例,并提供全局访问。JS的全局变量虽然不是单例模式,但它符合单例模式的两个特性,所以我们经常会把全局变量当成单例来使用,但是全局变量定义多了会造成命名空间污染,一般可以使用命名空间和使用闭包封装私有变量两种方式来降低变量带来的命名污染。
1.命名空间方式
let namespace={
a:function(){
alert(1)
},
b:function(){
alert(2)
}
}
namespace.a();
2.使用闭包封装私有变量
let user=(function(){
let _name='张朝阳';
let _age='32';
return {
getUserInfo(){
return `${_name}-${_age}`
}
}
})()
console.log(user.getUserInfo());//输出 张朝阳-32
4.5惰性单例
惰性单例指的是在需要的时候才创建对象的实例。在实际开发中会非常有用,之前示例中所用的单例模式是在页面加载好之后会立即创建实例,但是我们有可能还用不到这个实例,能不能在我们调用创建实例的时候才创建这个单例呢,这时我们就要使用惰性单例。
举个实际中用到的例子,比如页面中有个登录按钮,点击这个登录按钮之后,就会出现一个登录弹出框,很明显这个弹窗在页面中是唯一的,不可能同时存在两个登录窗口的情况。
解决方案:在页面加载完成的时候便创建好这个div弹窗,这个弹窗一开始是隐藏状态的,当用户点击登录按钮的时候,它才显示,代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<button type="button" id="login">登录按钮</button>
<script type="text/javascript">
let loginLayer = (function() {
let div = document.createElement('div');
div.innerHTML = "我是登录弹窗";
div.style.display = "none";
document.body.appendChild(div);
return div;
})()
document.getElementById("login").onclick = function() {
loginLayer.style.display = "block";
}
</script>
</body>
</html>
这种方案存在一个问题,就是当我们只想要浏览非登录相关的页面的时候,根本不需要进行登录操作,因为登录弹窗总是在一开始就被创建好,白白浪费了资源。我们可以将代码进行优化,让用户只有在点击登录按钮的时候才会创建登录弹窗,代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<button type="button" id="login">登录按钮</button>
<script type="text/javascript">
let createLoginLayer = function() {
let div = document.createElement('div');
div.innerHTML = "我是登录弹窗";
div.style.display = "none";
document.body.appendChild(div);
return div;
}
document.getElementById("login").onclick = function() {
let loginLayer=createLoginLayer();
loginLayer.style.display = "block";
}
</script>
</body>
</html>
上述代码虽然达到了惰性的目的,但是失去了单例的效果。每次我们点击登录按钮的时候,都会创建一个登录弹窗,这显然不是我们希望的,所以我们可以继续优化,代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<button type="button" id="login">登录按钮</button>
<script type="text/javascript">
let createLoginLayer = (function() {
let div;
return function(){
if(!div){
div = document.createElement('div');
div.innerHTML = "我是登录弹窗";
div.style.display = "none";
document.body.appendChild(div);
}
return div;
}
})();
document.getElementById("login").onclick = function() {
let loginLayer=createLoginLayer();
loginLayer.style.display = "block";
}
</script>
</body>
</html>
4.6通用惰性单例
这一节是JS单例模式最为核心和最为重要的内容,之前所有的实例讲解都是为本节做铺垫。
上一节我们完成了一个惰性单例,但是还存在一些问题,上述代码违反了单一职责原则,就是创建对象和管理单例逻辑都放在createLoginLayer函数中,使得代码的复用性和可维护性变得很差,如果我们能将创建对象和管理单例逻辑分开来做,就能解决上述的问题了,代码如下:
我们先处理单例逻辑管理这部分,这部分逻辑无论放在哪思路都是一样的,就是用一个变量来标志是否创建过对象,如果是,则在下次直接返回这个对象本身,抽象出代码就是:
let obj;
if(!obj){
obj="xxx"
}
我们把如何管理单例的逻辑从原来的代码中抽离出来,这些逻辑被封装在getSingle函数内部,创建对象的方法fn被当做参数传入getSingle函数,代码如下:
function getSingle(fn){
let result;
return function(){
return result || (result=fn.apply(this,arguments));
}
优化后完事代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<button type="button" id="login">登录按钮</button>
<script type="text/javascript">
function getSingle(fn){
let result;
return function(){
return result || (result=fn.apply(this,arguments));
}
}
function createLoginLayer() {
div = document.createElement('div');
div.innerHTML = "我是登录弹窗";
div.style.display = "none";
document.body.appendChild(div);
return div;
};
let createSingleLayer=getSingle(createLoginLayer)
document.getElementById("login").onclick = function() {
let loginLayer=createSingleLayer();
loginLayer.style.display = "block";
}
</script>
</body>
</html>
在上述代码中,我们把创建实例对象的职责和管理单例的职责分别放在两个不同的方法中,这两个方法相互独立,互不影响,当他们连接在一起的时候,就完成了一个创建单例对象的功能,而且两个函数也可以单独使用,大幅增加了代码的复用性。
小结
单例模式是我们学习的第一个模式,我们先学习了传统的单例模式实现,也了解到因为语言的差异性,有更适合的方法在JS中创建单例。这一章还提到了代理模式和单一职责原则。
在getSingle函数中,实际上也提到了闭包和高阶函数的概念。单例模式是一种简单但非常实用的模式,特别是惰性单例技术,在合适的时候才创建对象,并且只创建唯一一个。。更奇妙的是,创建对象和管理单例的职责被分布在两个不同的方法中,这两个方法组合起来才具有单例模式的威力。
5、策略模式
策略模式的定义:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。
相互替换:这句话在很大程度上是相对于静态类型语言而言的。因为静态类型语言中有类型检查机制,所以各个策略类需要实现同样的接口。当它们的真正类型被隐藏在接口后面时,它们才能被相互替换。而在JS这种“类型模糊”的语言中没有这种困扰,任何对象都可以被替换使用。因此,JS中的可以相互替换使用表现为它们具有相同的目的和意图。(概念可能一开始不太好理解,看了后面的具体实例之后,回过头来再看一遍就好理解了)
5.1使用策略模式计算奖金
假设绩效为S的人年终奖有4倍工资,绩效为A的人年终奖有3倍工资,而绩效为B的人年终奖是2倍工资,然后我们将编写一段代码用来计算员工的年终奖。代码如下:
function calculateBonus(level,salary){
if(level==='S'){
return salary*4
}
if(level==='A'){
return salary*3
}
if(level==='B'){
return salary*2
}
}
console.log(calculateBonus('B',3000));//输出6000
console.log(calculateBonus('S',6000));//输出24000
上述代码缺点十分明显,就是函数内部if分支过多,函数缺乏弹性,一旦增加绩效规则或者修改绩效规则,就必须深入函数内部去实现,这违反了代码编写的开放封闭原则,而且代码复用性也很差,一旦在另外一个地方使用,就只能复制喝粘贴。
我们可以使用组合函数来重构上述代码:
function levelS(salary){
return salary*4;
}
function levelA(salary){
return salary*3;
}
function levelB(salary){
return salary*2;
}
function calculateBonus(level,salary){
if(level==='S'){
return levelS(salary);
}
if(level==='A'){
return levelA(salary);
}
if(level==='B'){
return levelB(salary);
}
}
console.log(calculateBonus('B',3000));//输出6000
console.log(calculateBonus('S',6000));//输出24000
我们把各种算法封装到一个个的小函数里面,这些小函数有着良好的命名,可以一目了然地知道它对应着哪种算法,它们可以被复用在程序的其它地方,虽然代码得到了改善,但是calculateBonus函数有可能越来越大,而且在系统变化的时候缺乏弹性。
接下来使用策略模式重构代码,整体思路就是将算法的使用跟算法的实现分离开来。一个策略模式至少由两部分组成。第一个部分是一组策略类,策略类封装了具体的算法,并负责具体的计算过程。第二个部分是环境类,环境类接受客户的请求,随后把请求委托给某一个策略类。
//定义策略类
function levelS(){}
levelS.prototype.calculate=function(salary){
return salary*4;
}
function levelA(){}
levelA.prototype.calculate=function(salary){
return salary*3;
}
function levelB(){}
levelB.prototype.calculate=function(salary){
return salary*2;
}
//定义奖金类
function Bonus(){
this.salary=null;//工资
this.strategy=null;//绩效等级对应的策略对象
}
Bonus.prototype.setSalary=function(salary){
this.salary=salary;//设置员工工资
}
Bonus.prototype.setStrategy=function(strategy){
this.strategy=strategy;//设置员工绩效等级对应的测率对象
}
Bonus.prototype.getBonus=function(){//获取奖金金额
return this.strategy.calculate(this.salary);//把计算奖金的操作委托给对应的策略对象
}
let bonus=new Bonus();
bonus.setSalary(1000);
bonus.setStrategy(new levelS());
console.log(bonus.getBonus());//输出4000
bonus.setStrategy(new levelB());
console.log(bonus.getBonus());//输出2000
上述代码的基本思路是:定义一系列的算法,把它们各自封装成策略类,算法被封装在策略类内部的方法里。在客户发起请求时,总是把请求委托给这些策略对象中的某一个进行计算。
5.2JavaScript版本的策略模式
上一节是模拟一些传统的面向对象语言的实现。而实际在JS语言中会有更好的实现方式。
//定义策略对象(在js中函数也是对象)
let strategies={
S(salary){
return salary*4;
},
A(salary){
return salary*3;
},
B(salary){
return salary*2;
}
}
//定义环境对象
function calculateBonus(level,salary){
return strategies[level](salary)
}
console.log(calculateBonus('S',1000));//输出4000
console.log(calculateBonus('B',1000));//输出2000
5.3多态在策略模式中的体现
通过使用策略模式代码重构,我们消除了原程序中大片的条件分支语句。所有跟计算奖金有关的逻辑不再放在环境对象中,而是分布在各个策略对象中。环境对象并没有计算奖金的能力而是把这个职责委托给了某个策略对象。每个策略对象负责的算法已被各自封装在对象内部。当我们对这些策略对象发出计算奖金的请求时,它们会返回各自不同的计算结果,这正是对象多态的体现,也是“它们可以相互替换”的目的。替换环境对象中当前保存的策略对象,便能执行不同的算法来得到我们想要的结果。策略模式的实现并不复杂,关键是如何从策略模式的实现背后,找到封装变化、委托和多态性这些思想的价值。
5.4更广义的“算法”
策略模式指的是定义一系列的算法,并且把他们封装起来。之前的计算奖金例子都是封装了一些算法。从定义上看,策略模式就是用来封装算法的。但如果把策略模式仅仅用来封装算法,未免有一点大材小用。在实际开发中,我们通常会把算法的含义扩散开来,使策略模式也可以用来封装一系列的业务规则。只要这些业务规则指向的目标一致,并且可以被替换使用,我们就可以用策略模式来封装他们。
5.5表单验证
在一个web项目中,注册、登录、修改用户信息等功能的实现都离不开提交表单,但在提交表单之前,前端需要将用户填写的表单内容进行验证,比如是否填写了用户名,密码和长度是否符合要求,手机号码是否符合格式等。这么做的目的是为了避免因为提交不合法数据而带来不必要的网络开销,所以提交之前进行前端验证是非常必要的。而这种验证的实现恰巧也可以使用策略模式,下面我们看一下没有引入策略模式,最为常见的代码编写方式,代码如下:
<html>
<body>
<form action="http:// xxx.com/register" id="registerForm" method="post">
请输入用户名:<input type="text" name="userName" />
请输入密码:<input type="text" name="password" />
请输入手机号码:<input type="text" name="phoneNumber" />
<button>提交</button>
</form>
<script>
var registerForm = document.getElementById('registerForm');
registerForm.onsubmit = function() {
if (registerForm.userName.value === '') {
alert('用户名不能为空');
return false;
}
if (registerForm.password.value.length < 6) {
alert('密码长度不能少于6 位');
return false;
}
if (!/(^1[3|5|8][0-9]{9}$)/.test(registerForm.phoneNumber.value)) {
alert('手机号码格式不正确');
return false;
}
}
</script>
</body>
</html>
这种实现方式跟最开始的计算奖金的缺点一摸一样,registerForm.onsubmit函数体积太大,if-else分支太多,函数缺乏弹性,一旦增加或者修改验证规则,要深入到函数内部进行修改,容易造成错误,违反了开放-封闭原则,方法的复用性差,一旦另外一个页面也存在类似的功能,只能漫天的复制粘贴。
用策略模式重构表单验证,代码如下:
<html>
<body>
<form action="http:// xxx.com/register" id="registerForm" method="post">
请输入用户名:<input type="text" name="userName" />
请输入密码:<input type="text" name="password" />
请输入手机号码:<input type="text" name="phoneNumber" />
<button>提交</button>
</form>
<script>
let strategies = {
isNonEmpty: function(value, errorMsg) { // 不为空
if (value === '') {
return errorMsg;
}
},
minLength: function(value, length, errorMsg) { // 限制最小长度
if (value.length < length) {
return errorMsg;
}
},
isMobile: function(value, errorMsg) { // 手机号码格式
if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
return errorMsg;
}
}
};
let validataFunc = function() {
let validator = new Validator(); // 创建一个validator 对象
/***************添加一些校验规则****************/
validator.add(registerForm.userName, 'isNonEmpty', '用户名不能为空');
validator.add(registerForm.password, 'minLength:6', '密码长度不能少于6 位');
validator.add(registerForm.phoneNumber, 'isMobile', '手机号码格式不正确');
let errorMsg = validator.start(); // 获得校验结果
return errorMsg; // 返回校验结果
}
let registerForm = document.getElementById('registerForm');
registerForm.onsubmit = function() {
let errorMsg = validataFunc(); // 如果errorMsg 有确切的返回值,说明未通过校验
if (errorMsg) {
alert(errorMsg);
return false; // 阻止表单提交
}
};
let Validator = function() {
this.cache = []; // 保存校验规则
};
Validator.prototype.add = function(dom, rule, errorMsg) {
let ary = rule.split(':'); // 把strategy 和参数分开
this.cache.push(function() { // 把校验的步骤用空函数包装起来,并且放入cache
let strategy = ary.shift(); // 用户挑选的strategy
ary.unshift(dom.value); // 把input 的value 添加进参数列表
ary.push(errorMsg); // 把errorMsg 添加进参数列表
return strategies[strategy].apply(dom, ary);
});
};
Validator.prototype.start = function() {
for (let i = 0, validatorFunc; validatorFunc = this.cache[i++];) {
let msg = validatorFunc(); // 开始校验,并取得校验后的返回信息
if (msg) { // 如果有确切的返回值,说明校验没有通过
return msg;
}
}
};
</script>
</body>
</html>
有时候会给某个文本输入框添加多种校验规则,我们可以把上述代码进行优化如下:
<html>
<body>
<form action="http:// xxx.com/register" id="registerForm" method="post">
请输入用户名:<input type="text" name="userName" />
请输入密码:<input type="text" name="password" />
请输入手机号码:<input type="text" name="phoneNumber" />
<button>提交</button>
</form>
<script>
/***********************策略对象**************************/
let strategies = {
isNonEmpty: function(value, errorMsg) {
if (value === '') {
return errorMsg;
}
},
minLength: function(value, length, errorMsg) {
if (value.length < length) {
return errorMsg;
}
},
isMobile: function(value, errorMsg) {
if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
return errorMsg;
}
}
};
/***********************Validator 类**************************/
let Validator = function() {
this.cache = [];
};
Validator.prototype.add = function(dom, rules) {
let self = this;
for (let i = 0, rule; rule = rules[i++];) {
(function(rule) {
let strategyAry = rule.strategy.split(':');
let errorMsg = rule.errorMsg;
self.cache.push(function() {
let strategy = strategyAry.shift();
strategyAry.unshift(dom.value);
strategyAry.push(errorMsg);
return strategies[strategy].apply(dom, strategyAry);
});
})(rule)
}
};
Validator.prototype.start = function() {
for (let i = 0, validatorFunc; validatorFunc = this.cache[i++];) {
let errorMsg = validatorFunc();
if (errorMsg) {
return errorMsg;
}
}
};
/***********************客户调用代码**************************/
let registerForm = document.getElementById('registerForm');
let validataFunc = function() {
let validator = new Validator();
validator.add(registerForm.userName, [{
strategy: 'isNonEmpty',
errorMsg: '用户名不能为空'
}, {
strategy: 'minLength:6',
errorMsg: '用户名长度不能小于10 位'
}]);
validator.add(registerForm.password, [{
strategy: 'minLength:6',
errorMsg: '密码长度不能小于6 位'
}]);
let errorMsg = validator.start();
return errorMsg;
}
registerForm.onsubmit = function() {
let errorMsg = validataFunc();
if (errorMsg) {
alert(errorMsg);
return false;
}
};
</script>
</body>
</html>
5.6在vue中使用策略模式进行表单验证
先创建一个validator.js文件,用来存放各种表单验证的策略函数(对象),然后导出,代码如下:
let strategies={
checkEmpty(value,errorMsg){
if(value??'' !== ''){
return false;
}else{
return errorMsg;
}
},
minLength(value,length,errorMsg){
if(value.length<length){
return errorMsg;
}else{
return false;
}
},
maxLength(value,length,errorMsg){
if(value.length>length){
return errorMsg;
}else{
return false;
}
},
checkMobile(value,errorMsg){
if(!/^(13[0-9]|14[5|7]|15[0|1|2|3|4|5|6|7|8|9]|18[0|1|2|3|5|6|7|8|9])\d{8}$/.test(value)){
return errorMsg;
}else{
return false;
}
},
checkPassword(value,errorMsg){
if(!/^[a-zA-Z]\w{5,17}$/.test(value)){
return errorMsg;
}else{
return false;
}
}
}
export {
strategies
}
然后在vue代码中引入策略函数,并创建环境函数,利用环境函数委托调用策略函数,代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
<script src="https://unpkg.com/vue@next" ></script>
<div id="root">
<div>
<span>账号验证长度,非空验证,12-24位:</span>
<input type="text" v-model="account" />
</div>
<div>
<span>手机号验证:</span>
<input type="text" v-model="phone" />
</div>
<div>
<span>密码(以字母开头,长度在6~18之间,只能包含字母、数字和下划线) :</span>
<input type="text" v-model="password" />
</div>
<div>
<button @click="submit">验证按钮</button>
</div>
</div>
</head>
<body>
<script type="module">
import { strategies } from "./js/validator.js";
const app=Vue.createApp({
data(){
return {
account:"",
phone:"",
password:"",
}
},
computed:{
checkList:function(){
let list=[
{type:"checkEmpty",check:[this.account,"账号不能为空"]},
{type:"minLength",check:[this.account,12,"账号长度不能小于12位"]},
{type:"maxLength",check:[this.account,24,"账号长度不能大于24位"]},
{type:"checkEmpty",check:[this.phone,"手机号不能为空"]},
{type:"checkMobile",check:[this.phone,"手机号格式输入错误"]},
{type:"checkEmpty",check:[this.password,"请输入密码"]},
{type:"checkPassword",check:[this.password,"密码格式错误(以字母开头,长度在6~18之间,只能包含字母、数字和下划线)"]},
];
return list;
}
},
methods:{
doValitate(){//执行验证表单,环境方法
let list=this.checkList;
for(let i=0;i<list.length;i++){
let result=strategies[list[i].type].apply(this,list[i].check)
if(result){
return result
}
}
},
submit(){//提交表单
let checkResult=this.doValitate();
if(checkResult){
alert(checkResult);
}else{
alert("前端验证通过");
}
}
},
mounted(){
}
});
app.mount("#root");
</script>
</body>
</html>
5.7策略模式的优点
策略模式是一种常用且有效的设计模式,本章提供了计算奖金和表单验证两个例子来对策略模式进行理解,我们可以从中总结出策略模式的一些优缺点。
- 策略模式利用组合、委托和多态可以避免许多条件分支。
- 策略模式提供了开放-封闭原则,将算法封装在策略对象中,使得它易于切换,易于理解,易于扩展。
- 策略模式中的算法可以复用在别的地方,从而避免大量复制粘贴代码。
- 策略模式中利用组合和委托来让环境对象拥有执行算法的能力,这也是继承的一种更轻便的替代方案。 当然策略模式也有一些缺点,但是并不严重,总体来说优大于劣。
5.8一等函数对象与策略模式
在JavaScript语言中,函数作为一等对象,有些时候策略模式是隐形的,我们经常用高阶函数来封装不同的行为,并把它传入到另一个函数中。当我们调用这些函数时,会根据传入的参数不同而返回不同的结果,比如下面的代码就是一个典型的隐藏式策略模式:
//定义策略函数
function S(salary){
return salary*4;
}
function A(salary){
return salary*3;
}
function B(salary){
return salary*2;
}
//定义环境对象
function calculateBonus(fn,salary){
return fn(salary);
}
console.log(S(1000));//输出4000
console.log(B(1000));//输出2000
小结
本章我们既提供了接近传统面向对象语言的策略模式实现,已提供了更适合JavaScript语言的策略模式版本。在JavaScript语言的策略模式中,策略类往往被函数所替代,这时策略模式就成为一种隐形的模式。尽管这样,从头到尾了解策略模式,不仅可以让我们对该模式有更加透彻的了解,也可以使我们明白使用函数的好处。
6、发布订阅模式
发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。
6.1现实中的发布订阅模式
比如小明看上了一套房子,但是售楼处告知该楼盘早已售罄。售楼处工作人员告诉小明,不久后还有一些尾盘推出,开发商正在办理相关手续,手续办好后便可以购买。 但到底是什么时候,目前还没有人能够知道。于是小明记下了售楼处的电话,然后每天询问售楼处什么时候可以购买,除了小明可能还有很多其他人也会来询问情况,这样售楼处每天都会回答非常多个相同内容的电话。
当然实际上是不可能这样的,小明离开之前,把电话号码留在了售楼处。售楼处工作人员答应他,新楼盘一推出就马上发信息通知小明。小红、小强和小丽也是一样,他们的电话号码都被记在售楼处的花名册上,新楼盘推出的时候,售楼处工作人员会翻开花名册,遍历上面的电话号码,依次发送一条短信来通知他们。
6.2发布-订阅模式的作用
可以发现,上述例子中使用发布订阅模式有着显而易见的优点:
- 购房者不用再天天给售楼处打电话咨询开售时间,在合适的时间点,售楼处作为发布者 会通知这些消息订阅者。
- 购房者和售楼处之间不再强耦合在一起,当有新的购房者出现时,他只需把手机号码留在售楼处,售楼处不关心购房者的任何情况,不管购房者是男是女还是一只猴子。 而售楼处的任何变动也不会影响购买者,比如售处的某个工作人员离职了,售楼处搬家了,这些改变都跟购房者无关,只要售楼处记得发短信这件事情。
发布订阅模式广泛应用于异步编程中,我们可以订阅
ajax请求的error、succ等事件。或者如果想在动画的每一帧完成之后做一些事情,那我们可以订阅一个事件,然后在动画的每一帧完成之后发布这个事件。发布订阅模式让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响它们之间相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由地改变它们。
6.3DOM 事件
实际上我们常用的click事件就是一个最简单的发布订阅模式,当我们为一个元素绑定click事件时,我们没有办法预测这个click事件何时执行,因为只有当用户自己点击了之后才会执行,当用户点击被绑定事件的元素时,元素就会向订阅者发布这个消息。这很像购房的例子,购房者不知道房子什么时候开售,于是他在订阅消息后等待售楼处发布消息。
6.4自定义事件
在js中,除了使用DOM事件,还会依靠自定义事件来完成发布订阅模式,以售楼处的例子来说明如何在js中实现发布订阅模式。
- 首先要指定好谁充当发布者(比如售楼处);
- 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者(售楼处的花名册);
- 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数(遍历花名册,挨个发短信)。 此外,我们还可以往回调函数里填入一些参数,订阅者可以接收这些参数。这是很有必要的, 比如售楼处可以在发给订阅者的短信里加上房子的单价、面积等信息,订阅者接收到这些信息之后可以进行各自的处理。代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<script type="text/javascript">
let Publisher={};//定义发布者(售楼处)
Publisher.list=[];// 缓存列表,存放订阅者的回调函数
Publisher.listen=function(fn){// 增加订阅者
this.list.push(fn);// 订阅的消息添加进缓存列表
}
Publisher.trigger=function(){// 发布消息
for(let i=0;i<this.list.length;i++){
this.list[i].apply(this,arguments); // arguments 是发布消息时带上的参数
}
}
Publisher.listen((area,price)=>{//小明订阅的消息
console.log(`平米数${area},单价:${price}`);
})
Publisher.listen((area,price)=>{//小红订阅的消息
console.log(`平米数${area},单价:${price}`);
})
Publisher.trigger("100","7k");//输出 平米数100,单价:7k 平米数100,单价:7k
Publisher.trigger("50","8k");//输出 平米数50,单价:8k 平米数50,单价:8k
</script>
</body>
</html>
我们已经实现了一个最简单的发布—订阅模式,但这里还存在一些问题。我们看到订阅者接收到了发布者发布的每个消息,虽然小明只想买50平方米的房子,但是发布者把100平方米的信息也推送给了小明,这对小明来说是不必要的困扰。所以我们有必要增加一个标示key, 让订阅者只订阅自己感兴趣的消息。改写后的代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<script type="text/javascript">
let Publisher={};//定义发布者(售楼处)
Publisher.list={};// 缓存列表,存放订阅者的回调函数
Publisher.listen=function(key,fn){// 增加订阅者
if(!this.list[ key ]){// 如果还没有订阅过此类消息,给该类消息创建一个缓存列表
this.list[key] = [];
}
this.list[key].push( fn );// 订阅的消息添加进缓存列表
}
Publisher.trigger=function(){// 发布消息
let key = Array.prototype.shift.call( arguments ); // 取出消息类型
let list = this.list[ key ]; // 取出该消息对应的回调函数集合
if (!list||list.length===0 ){// 如果没有订阅该消息,则返回
return false;
}
for(let i=0;i<list.length;i++){
list[i].apply(this,arguments); // arguments 是发布消息时带上的参数
}
}
Publisher.listen('squareMeter50',(area,price)=>{//小明订阅的消息
console.log(`平米数${area},单价:${price}`);
})
Publisher.listen('squareMeter100',(area,price)=>{//小红订阅的消息
console.log(`平米数${area},单价:${price}`);
})
Publisher.trigger('squareMeter100',"100","7k");//输出 平米数100,单价:7k
Publisher.trigger('squareMeter50',"50","8k");//输出 平米数50,单价:8k
</script>
</body>
</html>
6.5发布订阅模式的通用实现
前面我们已经让售楼处拥有接受订阅和发布事件的功能,如果小明又去了另外一处售楼处买房子,那上述段代码须在另一个售楼处对象上重写一次就显得非常麻烦了,所以我们要写一个通用的发布订阅模式,将它放在一个单独的对象内,代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<script type="text/javascript">
let event = {
list: {},
listen(key, fn) {
if (!this.list[key]) {
this.list[key] = [];
}
this.list[key].push(fn);
},
trigger() {
let key = Array.prototype.shift.call(arguments);
let list = this.list[key];
if (!list || list.length === 0) {
return false;
}
for (let i = 0; i < list.length; i++) {
list[i].apply(this, arguments);
}
},
};
//定义一个 installEvent 函数,这个函数可以给所有的对象都动态安装发布—订阅功能
function installEvent(obj) {
for (var i in event) {
obj[i] = event[i];
}
}
let Publisher = {};
installEvent(Publisher);
Publisher.listen('squareMeter50', (area, price) => {
console.log(`平米数${area},单价:${price}`);
})
Publisher.listen('squareMeter100', (area, price) => {
console.log(`平米数${area},单价:${price}`);
})
Publisher.trigger('squareMeter100', "100", "7k"); //输出 平米数100,单价:7k
Publisher.trigger('squareMeter50', "50", "8k"); //输出 平米数50,单价:8k
</script>
</body>
</html>
6.6取消订阅的事件
有时候,我们也许需要取消订阅事件的功能。比如小明突然不想买房子了,为了避免继续接收到售楼处推送过来的短信,小明需要取消之前订阅的事件。现在我们给event对象增加remove方法:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<script type="text/javascript">
let event = {
list: {},
listen(key, fn) {
if (!this.list[key]) {
this.list[key] = [];
}
this.list[key].push(fn);
},
remove(key, fn) {
let list = this.list[key];
if (!list) { // 如果 key 对应的消息没有被人订阅,则直接返回
return false;
}
if (!fn) { // 如果没有传入具体的回调函数,表示需要取消 key 对应消息的所有订阅
list && (list.length = 0);
} else {
for (let i = 0; i < list.length; i++) { // 反向遍历订阅的回调函数列表
let _fn = list[i];
if (_fn === fn) {
list.splice(i, 1); // 删除订阅者的回调函数
}
}
}
},
trigger() {
let key = Array.prototype.shift.call(arguments);
let list = this.list[key];
if (!list || list.length === 0) {
return false;
}
for (let i = 0; i < list.length; i++) {
list[i].apply(this, arguments);
}
},
};
//定义一个 installEvent 函数,这个函数可以给所有的对象都动态安装发布—订阅功能
function installEvent(obj) {
for (var i in event) {
obj[i] = event[i];
}
}
let Publisher = {};
installEvent(Publisher);
function fn1(area, price){
console.log(`平米数${area},单价:${price}`);
}
function fn2(area){
console.log(`平米数${area}`);
}
function fn3(price){
console.log(`单价:${price}`);
}
Publisher.listen('squareMeter50',fn1);
Publisher.listen('squareMeter50',fn2);
Publisher.listen('squareMeter50',fn3)
Publisher.remove('squareMeter50',fn3);
Publisher.trigger('squareMeter50', "50", "8k"); //输出 平米数50,单价:8k 平米数50
</script>
</body>
</html>
6.7真实的例子——网站登录
我们为什么需要使用发布订阅模式?假如我们正在开发一个商城网站,网站里有header头部、nav导航、消息列表、购物车等模块。这几个模块的渲染有一个共同的前提条件,就是必须先用ajax异步请求获取用户的登录信息。但现在还不足以说服我们在此使用发布订阅模式,因为异步的问题通常也可以用回调函数来解决。比如我们可以再登录成功之后来依次渲染header头部、nav导航、消息列表、购物车等模块,伪代码如下:
login.succ(function(data) {
header.setAvatar(data.avatar); // 设置 header 模块的头像
nav.setAvatar(data.avatar); // 设置导航模块的头像
message.refresh(); // 刷新消息列表
cart.refresh(); // 刷新购物车列表
});
如果登录模块是我负责编写的,其它模块是由别人编写的,我需要了解其它所有模块是如何将用户信息导入到其中的,或者是当用户登录成功后,我们如何刷新其它模块的信息,这种耦合性会使程序变得僵硬,一旦其它模块导入信息的方法名或者刷新方法名变了,我们就要深入到登录方法内部进行修改;或者当有新的模块增加时,并且这个模块也需要登录信息,那还得在登录模块中增加执行这个模块的渲染方法才行。这样一来程序的扩展性和可维护性就会变得越来越差,所以我们可以尝试用发布订阅模式将上述代码进行改善。
$.ajax('http:// xxx.com?login', function(data) { // 登录成功
login.trigger('loginSucc', data); // 发布登录成功的消息
});
//各模块监听登录成功的消息:
let header = (function() { // header 模块
login.listen('loginSucc', function(data) {
header.setAvatar(data.avatar);
});
return {
setAvatar: function(data) {
console.log('设置 header 模块的头像');
}
}
})();
let nav = (function() { // nav 模块
login.listen('loginSucc', function(data) {
nav.setAvatar(data.avatar);
});
return {
setAvatar: function(avatar) {
console.log('设置 nav 模块的头像');
}
}
})();
这样一来,无论我们时修改模块的渲染方法还是新增其它关联模块,登录模块并不关心业务方究竟要做什么,也不想去了解它们的内部细节,当登录成功时,登录模块只需要发布登录成功的消息,而业务方接受到消息之后,就会开始进行 各自的业务处理。
6.8全局的发布订阅对象
之前我们给售楼处对象和登录对象都添加了订阅和发布的功能,这里还存在两个小问题,如下:
- 我们给每个发布者对象都添加了
listen和trigger方法,以及一个缓存列表list, 这其实是一种资源浪费。 - 小明跟售楼处对象还是存在一定的耦合性,小明至少要知道售楼处对象的名字是
Publisher,才能顺利的订阅到事件。 如果小明关注了两套房子,而这两套房子分别来自不同开发商的售楼处,这就意味着小明要分别订阅Publisher和Publisher2两个对象,代码如下:
Publisher.listen('squareMeter50',fn1);
Publisher2.listen('squareMeter50',fn1);
其实在现实中,买房子未必要亲自去售楼处,我们只要把订阅的请求交给中介公司,而各大房产公司也只需要通过中介公司来发布房子信息。这样一来,我们不用关心消息是来自哪个房产公司,我们在意的是能否顺利收到消息。当然,为了保证订阅者和发布者能顺利通信,订阅者和发布者都必须知道这个中介公司。
同样在程序中,发布订阅模式可以用一个全局的Event对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event作为一个类似“中介者” 的角色,把订阅者和发布者联系起来。代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<script type="text/javascript">
let Event = {
list: {},
listen(key, fn) {
if (!this.list[key]) {
this.list[key] = [];
}
this.list[key].push(fn);
},
remove(key, fn) {
let list = this.list[key];
if (!list) { // 如果 key 对应的消息没有被人订阅,则直接返回
return false;
}
if (!fn) { // 如果没有传入具体的回调函数,表示需要取消 key 对应消息的所有订阅
list && (list.length = 0);
} else {
for (let i = 0; i < list.length; i++) { // 反向遍历订阅的回调函数列表
let _fn = list[i];
if (_fn === fn) {
list.splice(i, 1); // 删除订阅者的回调函数
}
}
}
},
trigger() {
let key = Array.prototype.shift.call(arguments);
let list = this.list[key];
if (!list || list.length === 0) {
return false;
}
for (let i = 0; i < list.length; i++) {
list[i].apply(this, arguments);
}
},
};
function fn1(area, price){
console.log(`平米数${area},单价:${price}`);
}
function fn2(area){
console.log(`平米数${area}`);
}
function fn3(price){
console.log(`单价:${price}`);
}
Event.listen('squareMeter50',fn1);
Event.listen('squareMeter50',fn2);
Event.listen('squareMeter50',fn3)
Event.remove('squareMeter50',fn3);
Event.trigger('squareMeter50', "50", "8k"); //输出 平米数50,单价:8k 平米数50
</script>
</body>
</html>
这里我们删除了installEvent函数,取消了给所有的对象动态安装发布订阅功能,而是直接使用Event作为中介者来发布消息。
6.9模块间通信
上一节中实现的发布订阅模式是基于一个全局的Event对象,我们利用它可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。就如同有了中介公司之后,我们不再需要知道房子开售的消息来自哪个售楼处。 比如现在有两个模块,a模块里面有一个按钮,每次点击按钮之后,b模块里的div中会显示按钮的总点击次数,我们用全局发布—订阅模式完成下面的代码,使得a模块和b模块可以在保持封装性的前提下进行通信,代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<button id="count">点我</button>
<div id="show"></div>
<script type="text/javascript">
let Event = {
list: {},
listen(key, fn) {
if (!this.list[key]) {
this.list[key] = [];
}
this.list[key].push(fn);
},
remove(key, fn) {
let list = this.list[key];
if (!list) { // 如果 key 对应的消息没有被人订阅,则直接返回
return false;
}
if (!fn) { // 如果没有传入具体的回调函数,表示需要取消 key 对应消息的所有订阅
list && (list.length = 0);
} else {
for (let i = 0; i < list.length; i++) { // 反向遍历订阅的回调函数列表
let _fn = list[i];
if (_fn === fn) {
list.splice(i, 1); // 删除订阅者的回调函数
}
}
}
},
trigger() {
let key = Array.prototype.shift.call(arguments);
let list = this.list[key];
if (!list || list.length === 0) {
return false;
}
for (let i = 0; i < list.length; i++) {
list[i].apply(this, arguments);
}
},
};
let a = (function() {
let count = 0;
let button = document.getElementById('count');
button.onclick = function() {
Event.trigger('add', count++);
}
})();
let b = (function() {
let div = document.getElementById('show');
Event.listen('add', function(count) {
div.innerHTML = count;
});
})();
</script>
</body>
</html>
这里需要注意一个问题,模块之间如果用了太多的全局发布订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,会给调试带来很多麻烦。
6.10必须先订阅再发布吗
我们所了解到的发布订阅模式,都是订阅者必须先订阅一个消息,随后才能接收到发布者发布的消息。如果把顺序反过来,发布者先发布一条消息,而在此之前并没有对象来订阅它,将无法收到这条信息。 在某些情况下,我们需要先将这条消息保存下来,等到有对象来订阅它的时候,再重新把消息发布给订阅者。比如当ajax请求成功返回之后会发布一个事件,在此之前订阅了此事件的用户导航模块可以接收到这些用户信息。 但是这只是理想的状况,因为异步的原因,我们不能保证ajax请求返回的时间,有时候它返回得比较快,而此时用户导航模块的代码还没有加载好(还没有订阅相应事件),特别是在用了一些模块化惰性加载的技术后,这是很可能发生的事情。也许我们还需要一个方案,使得我们的发布订阅对象拥有先发布后订阅的能力。 为了满足这个需求,我们要建立一个存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,我们暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,我们将遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。
6.11JavaScript实现发布订阅模式的便利性
这里要提出的是,我们一直讨论的发布—订阅模式,跟一些别的语言(比如 Java)中的实现还是有区别的。在Java中实现一个自己的发布订阅模式,通常会把订阅者对象自身当成引用传入发布者对象中,同时订阅者对象还需提供一个名为诸如update的方法,供发布者对象在适合的时候调用。而在JavaScript中,我们用注册回调函数的形式来代替传统的发布订阅模式,显得更加优雅和简单。
另外,在JavaScript中,我们无需去选择使用推模型还是拉模型。推模型是指在事件发生时, 发布者一次性把所有更改的状态和数据都推送给订阅者。拉模型不同的地方是,发布者仅仅通知订阅者事件已经发生了,此外发布者要提供一些公开的接口供订阅者来主动拉取数据。拉模型的好处是可以让订阅者“按需获取”,但同时有可能让发布者变成一个“门户大开”的对象,同时增加了代码量和复杂度。
刚好在JavaScript中,arguments可以很方便地表示参数列表,所以我们一般都会选择推模型, 使用 Function.prototype.apply方法把所有参数都推送给订阅者。
小结
本章我们学习了发布—订阅模式,也就是常说的观察者模式。发布—订阅模式在实际开发中非常有用。
发布订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。发布订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是MVC还是MVVM, 都少不了发布—订阅模式的参与,而且JavaScript本身也是一门基于事件驱动的语言。
当然,发布订阅模式也不是完全没有缺点。创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外, 发布—订阅模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。特别是有多个发布者和订阅者嵌套到一起的时候,要跟踪一个bug不是件轻松的事情。
7、代理模式
代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问。生活中可以找到很多代理模式的场景,比如,明星都有经纪人作为代理。如果想请明星来办一场商业演出,只能联系他的经纪人。经纪人会把商业演出的细节和报酬都谈好之后,再把合同交给明星签。
代理模式的关键是,当客户不方便直接访问一个对象或者不满足需要的时候,提供一个替身 对象来控制对这个对象的访问,客户实际上访问的是替身对象。替身对象对请求做出一些处理之 后,再把请求转交给本体对象。
7.1一个简单的代理例子
小明想要追女神A,明决定给A送一束花来表白。刚好小明打听到A和他有一个共同的朋B,于是内向的小明决定让B来代替自己完成送花这件事情。先用代码来描述一下小明追女神的过程,先看看不用代理模式的情况:
let Flower = function() {};
let xiaoming = {
sendFlower: function(target) {
let flower = new Flower();
target.receiveFlower(flower);
}
};
let A = {
receiveFlower: function(flower) {
console.log('收到花 ' + flower);
}
};
xiaoming.sendFlower(A);
上述为小明直接送花,接下来引入代理,也就是小明通过B来给A送花。
let Flower = function() {};
let xiaoming = {
sendFlower: function(target) {
let flower = new Flower();
target.receiveFlower(flower);
}
};
let B = {
receiveFlower: function(flower) {
A.receiveFlower(flower);
}
};
let A = {
receiveFlower: function(flower) {
console.log('收到花 ' + flower);
}
};
xiaoming.sendFlower(B);
很显然两端代码执行的结果是一致的,貌似完全没有必要引入代理。现在我们改变故事的背景设定,假设当A在心情好的时候收到花,小明可以表白成功,而当A在心情差的时候收到花,小明表白则表白失败,但是小明无法知道A什么时候心情好,刚好B对A非常了解,知道A什么时候心情好什么时候心情差,所以小明就可以委托B为他送花,B会监听A的心情变化,然后选择A心情好的时候把花转交给A。代码如下:
let Flower = function() {};
let xiaoming = {
sendFlower: function(target) {
let flower = new Flower();
target.receiveFlower(flower);
}
};
let B = {
receiveFlower: function(flower) {
A.listenGoodMood(function() { // 监听 A 的好心情
A.receiveFlower(flower);
});
}
};
let A = {
receiveFlower: function(flower) {
console.log('收到花 ' + flower);
},
listenGoodMood:function(fn){
setTimeout(function(){
fn();
},1000)
}
};
xiaoming.sendFlower(B);
7.2保护代理和虚拟代理
上面送花的例子就是一个保护代理,代理B可以帮助A过滤掉一些请求,A并不直接接受请求,只有满足一定的条件时,B才会把请求转交给A。
另外,假设现实中的花价格不菲,导致在程序世界里new Flower也是一个代价昂贵的操作, 那么我们可以把new Flower的操作交给代理B去执行,代理B会选择在A心情好时再执行new Flower,这是代理模式的另一种形式,叫作虚拟代理。虚拟代理把一些开销很大的对象,延迟到真正需要它的时候才去创建。代码如下:
var B = {
receiveFlower: function(flower) {
A.listenGoodMood(function() { // 监听 A 的好心情
var flower = new Flower(); // 延迟创建 flower 对象
A.receiveFlower(flower);
});
}
};
7.3虚拟代理实现图片预加载
在Web开发中,图片预加载是一种常用的技术,如果直接给某个img标签节点设置src属性, 由于图片过大或者网络不佳,图片的位置往往有段时间会是一片空白。常见的做法是先用一张loading图片占位,然后用异步的方式加载图片,等图片加载好了再把它填充到img节点里,这种场景就很适合使用虚拟代理。
首先创建一个图片加载对象,这个对象负责创建一个img标签,并且提供一个对外的setSrc接口,外界调用这个接口可以给该img标签设置图片路径,代码如下:
let myImage = (function() {
let imgNode = document.createElement('img');
document.body.appendChild(imgNode);
return {
setSrc: function(src) {
imgNode.src = src;
}
}
})();
myImage.setSrc('http://imgcache.qq.com/music/photo/k/000GGDys0yA0Nk.jpg');
如果图片非常大,在被加载好之前,会有很长一段页面空白时间,现在开始引入代理对象proxyImage,通过这个代理对象,在图片被真正加载好之前,页面中将出现一张占位的菊花图loading.gif, 来提示用户图片正在加载。代码如下:
let myImage = (function() {
let imgNode = document.createElement('img');
document.body.appendChild(imgNode);
return {
setSrc: function(src) {
imgNode.src = src;
}
}
})();
let proxyImage=(function() {
let img=new Image();
img.onload=function(){
myImage.setSrc(img.src);
}
return {
setSrc: function(src) {
img.src = src;
myImage.setSrc('file://C:/Users/svenzeng/Desktop/loading.gif');
}
}
})();
proxyImage.setSrc('http://imgcache.qq.com/music/photo/k/000GGDys0yA0Nk.jpg');
我们通过proxyImage间接地访问MyImage。proxyImage控制了客户对MyImage的访问,并且在此过程中加入一些额外的操作,比如在真正的图片加载好之前,先把img节点的src设置为一张本地的loading图片。
7.4代理的意义
上述例子可能会有疑问,比如我们可以完全不适用代理来实现图片的预加载公共,代码如下:
let MyImage = (function() {
let imgNode = document.createElement('img');
document.body.appendChild(imgNode);
let img = new Image;
img.onload = function() {
imgNode.src = img.src;
};
return {
setSrc: function(src) {
imgNode.src = 'file:// /C:/Users/svenzeng/Desktop/loading.gif';
img.src = src;
}
}
})();
MyImage.setSrc('http:// imgcache.qq.com/music/photo/k/000GGDys0yA0Nk.jpg');
这种实现方式有两个明显的缺点,就是对象内部承担了多项职责,一旦职责越来越多,代码就会变得非常庞大;另外一个缺点就是里面的各种功能耦合度太强,一旦代码需要进行改动,就会牵连很多地方跟着改动,给维护带来很多不便之处。使用代理功能可以给img节点设置src和图片预加载这两个功能,被隔离在两个对象里,它们可以各自变化而不影响对方。何况就算有一天我们不再需要预加载,那么只需要改成请求本体而不是请求代理对象即可。
7.5代理和本体接口的一致性
代理对象和本体都对外提供了setSrc方法,在客户看来,代理对象和本体是一致的,用户并不清楚代理和本体的区别,这样做有两个好处:
- 用户可以放心地请求代理,他只关心是否能得到想要的结果。
- 在任何使用本体的地方都可以替换成使用代理。
7.6虚拟代理合并 HTTP 请求
在Web开发中,网络请求一般对服务器开销比较大,假设我们在做一个文件同步的功能,当我们选中一个checkbox 的时候,它对应的文件就会被同步到另外一台备用服务器上面,页面上的checkbox节点如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<input type="checkbox" id="1" />文件1
<input type="checkbox" id="2" />文件2
<input type="checkbox" id="3" />文件3
<input type="checkbox" id="4" />文件4
<input type="checkbox" id="5" />文件5
<input type="checkbox" id="6" />文件6
<input type="checkbox" id="7" />文件7
<input type="checkbox" id="8" />文件8
<input type="checkbox" id="9" />文件9
</body>
</html>
接下来,给这些checkbox绑定点击事件,并且在点击的同时往另一台服务器同步文件:
let synchronousFile = function(id) {
console.log('开始同步文件,id 为: ' + id);
};
let checkbox = document.getElementsByTagName('input');
for (let i = 0, c; c = checkbox[i++];) {
c.onclick = function() {
if (this.checked === true) {
synchronousFile(this.id);
}
}
};
我们点击一次复选框,就会向服务器发送一次请求,如果有的人手速非常快,一秒钟能点4、5次,那就会给服务器带来很大的开销。如果我们可以通过一个代理函数proxySynchronousFile来收集一段时间之内的请求, 最后一次性发送给服务器。比如我们等待2秒之后才把这2秒之内需要同步的文件id打包发给服务器,如果不是对实时性要求非常高的系统,2秒的延迟不会带来太大副作用,却能大大减轻服务器的压力。
let synchronousFile = function(id) {
console.log('开始同步文件,id 为: ' + id);
};
let proxySynchronousFile = (function() {
let cache = [], // 保存一段时间内需要同步的 ID
timer; // 定时器
return function(id) {
cache.push(id);
if (timer) { // 保证不会覆盖已经启动的定时器
return;
}
timer = setTimeout(function() {
synchronousFile(cache.join(',')); // 2 秒后向本体发送需要同步的 ID 集合
clearTimeout(timer); // 清空定时器
timer = null;
cache.length = 0; // 清空 ID 集合
}, 2000);
}
})();
let checkbox = document.getElementsByTagName('input');
for (let i = 0, c; c = checkbox[i++];) {
c.onclick = function() {
if (this.checked === true) {
proxySynchronousFile(this.id);
}
}
};
7.7缓存代理
缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参 数跟之前一致,则可以直接返回前面存储的运算结果。
比如我们要实现一个加法求和运算,假设这个求和运算极其复杂,需要消耗大量的性能,通常我们会这样来实现,代码如下:
let sum = function() {
console.log('开始计算乘积');
let a = 1;
for (let i = 0, l = arguments.length; i < l; i++) {
a = a + arguments[i];
}
return a;
};
sum(2, 3); // 输出:5
sum(2, 3, 4); // 输出:9
我们对代码进行优化,加入缓存代理:
let sumProxy = (function() {
let cache = {};
return function() {
let args = Array.prototype.join.call(arguments, ',');
if (args in cache) {
return cache[args];
}
return cache[args] = sum.apply(this, arguments);
}
})();
sumProxy(1, 2, 3, 4); // 输出:10
sumProxy(1, 2, 3, 4); // 输出:10
当我们第二次调用sumProxy( 1, 2, 3 )的时候,本体sum函数并没有被计算,sumProxy直接返回了之前缓存好的计算结果。这样就不用重复计算了,提高了程序的性能。
缓存代理也可用于ajax异步请求数据,讲请求到的数据存储到某个位置,当某些地方需要这些数据时,我们可以直接拉去缓存而不用重新加载ajax,当然这种情况要保证ajax返回的数据会长期保持不便。
小结
代理模式包括许多小分类,在JS开发中最常用的是虚拟代理和缓存代理。虽然代理模式非常有用,但我们在编写业务代码的时候,往往不需要去预先猜测是否需要使用代理模式。 当真正发现不方便直接访问某个对象的时候,再编写代理也不迟。