中级到高级的前端路-设计模式

175 阅读13分钟

JS设计模式

写在前头,这是给公司组员培训的其中一项内容,老生常谈的东西,但又确实实用,希望我的组员可以融会贯通,也希望给读本文的朋友一点点的启示

  • 读完你会学到什么?

你会学到一些js代码技巧;一些设计原则;一些实用的设计模式

  • 需要什么阅读前提?

你有一定的js基础,知道什么是this以及bind,apply方法使用。清楚js原型链是什么。知道什么是高阶函数和闭包。

  • 有没有阅读顺序?

没有,如果时间有限,推荐优先阅读代理模式,发布订阅模式,装饰器模式

tip:以下代码示例,都可以直接在浏览器控制台打印,建议打印调试学习

UNIX哲学

清晰原则

代码要写得尽量清晰,避免晦涩难懂。清晰的代码不容易崩溃,而且容易理解和维护。重视注释。不为了性能的一丁点提升,而大幅增加技术的复杂性,因为复杂的技术会使得日后的阅读和维护更加艰难。

优化原则

在功能实现之前,不要考虑对它优化。最重要的是让一切先能够运行,其次才是效率。“先求运行,再求正确,最后求快。”(Make it run, then make it right, then make it fast.)90%的功能现在能实现,比100%的功能永远实现不了强。先做出原型,然后找出哪些功能不必实现,那些不用写的代码显然无需优化。

模块原则

每个程序只做一件事,不要试图在单个程序中完成多个任务。在程序的内部,面向用户的界面(前端)应该与运算机制(后端)分离,因为前端的变化往往快于后端。

组合原则

不同的程序之间通过接口相连。接口之间用文本格式进行通信,因为文本格式是最容易处理、最通用的格式。这就意味着尽量不要使用二进制数据进行通信,不要把二进制内容作为输出和输入。

设计原则

单一职责原则(SRP)

针对一个对象,方法,类应该只有一个引起它变化的原因。举个栗子,js里的纯函数就很好的遵守了这个原则。这个原则的目的是将来在项目维护或则迭代开发时候,避免改动一个方法,却引发不可预知的其他影响。

最少知识原则(LKP-迪米特法则)

减少对象和对象的直接交互,特别是n对n的复杂交互。举个栗子,假如一个业务系统需要n对n交互,应该考虑抽象一个第三者对象,来处理这种交互关系,转换成n对1的形式。这个原则的目的是简化业务对象的关系复杂度,避免后期项目维护,阅读代码的成本。

如发布订阅模式

开放封闭原则(OCP)

类,模块,方法应该是可以拓展的,但是不可修改。使用方式,不是说所有代码都简单直接的遵守这个原则,封闭的内容应该是整个业务系统内可变的部分,它的目的是引导我们尽可能把可变的部分抽象分离出来,这部分去遵守开放封闭原则。至于怎么抽离可变,比如使用hook,即把可变的代码封装在一个hook方法里,当代码执行到hook后,根据hook返回结果执行不同逻辑。

如装饰者模式

发布-订阅模式(观察者模式)

定义对象间一对多的关系,当一个对象发生变化,依赖它的对象都收到通知,再进行变化

js场景下最简单常用的案例

document.body.addEventListener( 'click', function(){
  console.log('点击了');
}, false );
document.body.click(); // 模拟用户点击

上述我们的操作就是订阅了body的click操作,当用户点击时候,body就发布消息通知我们

初步的js发布订阅实现代码

let subscribePublish = {
  clientList: [], // 订阅人数组
  listen: function( key, fn ){ // 订阅。通过key-value形式订阅指定的内容
    if ( !this.clientList[ key ] ){ // 判断某个类型的订阅列表是否存在
      // let a = []; a['x'] = [] ;a['x'].push(1); console.log(a) // 可打印观察数据结构
      this.clientList[ key ] = []; // 设置对象数组,**类似**:[ {x:[1]}, {y:[2]} ]
    }
    this.clientList[key].push(fn); // 某个类型的订阅列表存在则将回调函数加进队列
  },
  trigger: function() { // 发布
    let key = Array.prototype.shift.call( arguments ); // 获取第一个入参,这里是订阅的key
    let fns = this.clientList[ key ]; // 获取key对应的回调事件数组
    if ( !fns || fns.length === 0 ){
      return false;
    };
    fns.forEach((item) => {
      // arguments第一个入参已经被shift,所以当前是第二个入参开始的集合
      // 发布消息(trigger),调用这行然后执行回调函数
      item.apply( this, arguments );
    })
  }
};
// 给对象安装订阅-发布方法
let installEvent = function( obj ){
  for ( let i in subscribePublish ){
    obj[ i ] = subscribePublish[ i ];
  }
};
// 栗子。场景:100个用户关注房价,房价变动后中介要一一发送100次短信。使用发布订阅,中介只要点击一次就统一分发
let salesOffices = {}; // 销售服务
installEvent( salesOffices ); // 给销售添加发布订阅功能
// 不管哪个用户,只要是关注89平房子的,都找销售订阅这类房价消息
salesOffices.listen( '89squareMeter', function( time,price ){ // 用户的操作
  console.log( `89平房子现在${time}价格是${price}`);
});
salesOffices.listen( '129squareMeter', function( time,price ){
  console.log( `129平房子现在${time}价格是${price}`);
});
salesOffices.trigger( '89squareMeter', '2022-09-10','200w' ); // 价格变动通知用户 // 中介的操作
salesOffices.trigger( '89squareMeter', '2022-10-10','220w' );

发布订阅模式是常用的方法,例如websocket相关业务,服务器ws就是上述的中介角色,每个客户端(前端服务)就是买房客的角色。客户端在订阅时候告诉服务器,自己是谁,需要订阅什么消息,然后服务器会在订阅消息有更新后推送给订阅的客户端。

通用写法

上述的进阶模式,将发布订阅功能抽象成一个全局方法,并提供取消代理方法

let subscribePublish  = (function() {
  let clientList = [];
  let userKeyList = [];
  let guid = function () {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
        let r = Math.random() * 16 | 0,
            v = c == 'x' ? r : (r & 0x3 | 0x8);
        return v.toString(16);
    });
	}
  let listen = function( key, fn, userKey){ // key:消息类型
    let userId = userKey;
    if (!userId) { // 没有传则自动生成userKey
        userId = guid();
    }
    if ( !clientList[ key ] ){ // 这里clientList是成员变量,不用this
      clientList[ key ] = [];
      userKeyList[key] = [];
    }
    clientList[key].push(fn);
    userKeyList[key].push(userId);
    return userId;
  };
  let trigger = function() {
    let key = Array.prototype.shift.call( arguments );
    let fns = clientList[ key ];
    if ( !fns || fns.length === 0 ){
      return false;
    };
    fns.forEach((item) => {
      item.apply( this, arguments );
    })
  };
  let remove = function( key, userKey ){ // 取消订阅 (可按照具体场景修改,优化)
    let fns = clientList[ key ];
    let userKeys = userKeyList[ key ];
    if ( !fns ){ // 取消订阅的消息类型,本来就没被订阅过,直接返回
			return false;
    }
    if ( !userKey ){ // 如果没有传userKey,那么认为key对应的消息类型都要清空
    	fns && ( fns.length = 0 ); // 消息队列存在 则通过将数组长度设置为0清空数组
    } else if(!userKeys.includes(userKey)) {
      console.log('此id没有订阅:',userKey);
      return false;        
    }else {
      userKeys.forEach((item, index) => {
        console.log('item=',item,'fn=',userKey);
        if (item === userKey) {
          console.log('fns1',fns);
          fns.splice( index, 1 ); // fns 和 userKeys是一对一的数组,所以下标可以互用
          console.log('fns2',fns);
          return true;
        }
      });
    }
  }
  return {
		listen: listen,
    trigger: trigger,
		remove: remove
  }
})();
subscribePublish.listen( '89squareMeter', function( time,price ){
  console.log( `小明:89平房子现在${time}价格是${price}`);
}, 'key1');
let userKey = subscribePublish.listen( '89squareMeter', function( time,price ){
  console.log( `小张:89平房子现在${time}价格是${price}`);
});
console.log( `userKey`, userKey);
subscribePublish.trigger( '89squareMeter', '2022-10-10','220w' );
subscribePublish.remove( '89squareMeter', userKey);
subscribePublish.trigger( '89squareMeter', '2022-11-10','230w' );

tip:这样看来起很完美,模块和模块之间通信只要通过全局的发布订阅服务就可以了。但实际上,也不能过度使用,因为相互的订阅和发布分散在不同模块,有时会导致通知消息后,我们不知道具体会影响多少业务逻辑。

So 也许我们可以增加一个具体的模块,来描述订阅者和发布者的关系

总结:发布订阅模式优点。一,时间角度上解耦,二,对象之间解耦

装饰者模式

装饰者模式可以动态的给一个对象添加一些额外的功能,而不会影响从这个对象派生的其他对象。

js语言的实现示例:

// 要给原函数新增功能,但原函数又特别复杂
let a = function(){
  console.log('原函数功能')
}
// 方案一,直接修改原函数 // 不合适。不符合开闭原则
a = function(){
  console.log('原函数功能')
  console.log('新函数功能')
}
// 方案二,装饰者模式。将原函数保存在一个引用里
// 问题:this劫持,_a外层this是window,在方法内this是指向方法,this变了可能导致报错 Uncaught TypeError等
let _a = a;
a = function(){
  _a();
  console.log('新函数功能')
}
// 方案三,通用装饰模式
Function.prototype.before = function( beforefn ){
  let __self = this; // 保存原函数的this
  return function(){ // 返回一个函数,内容包括新功能和原函数功能
    beforefn.apply( this, arguments ); // 执行新函数,保证this不给劫持
    return __self.apply( this, arguments ); // 执行原函数,并返回原函数的执行结果
  }
}
Function.prototype.after = function( afterfn ){ // 同理只是把新函数功能的执行顺序放在原函数后面
  let __self = this;
  return function(){
    let ret = __self.apply( this, arguments );
    afterfn.apply( this, arguments );
    return ret;
  }
};
a = a.before(() => {
  console.log('before-新函数功能')
})
a();

单例模式

单例模式:只有一个实例,并提供全局访问

var getSingle = function ( fn ) {
	var ret;
  return function () { // 1
  	return ret || ( ret = fn.apply( this, arguments ) ); // 2
  };
};
var getScript = getSingle(function(){
	return document.createElement( 'script' );
});
var script1 = getScript();
var script2 = getScript();
console.log(script1 === script2) // true

上面的getSingle就是JS实现单例的通用方式。

第一次调用 getScript(),执行逻辑1,因为 ret 是 undefined 所以执行2的后半段,第二次调用 getScript(),因为闭包,即 ret 所在的函数有被引用,所以 ret 这个局部变量没有被销毁,导致第二次逻辑走到2时,执行前半段。所以再次调用getScript() 得到的其实和第一次调用是同一个对象。由此符合单例模式的目的,实现单例。

js里var声明的变量,天生带有单例特性,但会有全局命名污染(重复)的问题。vue通过各文件的data和method对象来命名变量和函数,使变量实现单例的同时,也隔绝了命名污染。

策略模式

策略模式:定义一系列算法,把它们一个个封装起来,并且使他们可以相互替换。

实现目的的方式有很多,不同的实现方式有不同的特性,在各种环境下使用最合适的实现方式,使得实现效果达到最佳。

栗子,现在有一个发年终奖的场景,绩效S的可以获得4倍工资,A的3倍工资,B的2倍工资,计算各个人年终奖。

let strategies = {
  "S": ( salary ) => {
      	return salary * 4;
       },
  "A": ( salary ) => {
    		return salary * 3;
  		 },
  "B": ( salary ) => {
      	return salary * 2;
       } 
};
let calculateBonus = function( level, salary ){
  return strategies[ level ]( salary );
};
console.log( calculateBonus( 'S', 20000 ) ); // 80000
console.log( calculateBonus( 'A', 10000 ) ); // 30000

strategies 对象的各个函数就是各个封装的算法,然后用户通过不同策略,实现获取年终奖这个目的。

代理模式

用户 ----- 本体 // 非代理模式 用户 ----- 代理 ----- 本体 // 代理模式

作用:代理对象会做出一些处理,然后把结果内容提供给本体

举栗:耐克公司要投资NBA的姚明,实际操作是耐克公司找姚明的经纪人,经纪人处理完合同,把最终结果报给姚明,姚明同意后完成投资合作。

上述就是经典的代理模式,经纪人在中间处理了问题,给姚明带来安全和利益。

再细分代理的操作内容,有保护代理虚拟代理等等。 假设有皮包公司想利用姚明,假意投资合作,骗取利润,这时候经纪人帮姚明过滤了这些劣质公司,保护了姚明的安全就是保护代理。 假如耐克公司的投资合同,有上亿现金的条款,耐克公司不是一开始就带着这么多现金谈合同,而是和经纪人谈妥合同后,才真的提供,即把开销很大的操作,延时到代理对象上,真正需要时候才操作,就是虚拟代理.

在js中常用案例是缓存代理,如下

let mult = function(){
  console.log('计算乘积');
  let a = 1;
	for ( let i = 0, l = arguments.length; i < l; i++ ){
    a = a* arguments[i];
  }
  return a;
};
// mult(2,3,4) // 24

let proxyMult = (function(){ // 给重复计算设置缓存
	let cache = {};
	return function(){
		let args = Array.prototype.join.call( arguments, ',' );
    console.log('args', args ,'cache', cache)
    if ( args in cache ){ // 如果已存在,取缓存对象的value
			return cache[ args ]; // 把入参当作key
    }
		return cache[ args ] = mult.apply( this, arguments ); // 计算结果当作value
  }
})();
let x = proxyMult( 2, 3, 4 );
let y = proxyMult( 2, 3, 4 );
console.log('x=',x,'y=',y)
console.log('proxyMult',proxyMult);

复杂计算的纯函数,通过设置缓存代理,减轻计算压力

Tip:代理对象和本体对象要提供对外方法,此方法能得到一样的计算结果,如上述的mult和proxyMult。这样是否取消代理就可控了。

迭代器模式

迭代器模式是,提供一种方法顺序访问一个聚合对象中的各个元素,而又不用暴露该聚合对象的内部表示。

常用的是内部迭代器外部迭代器

内部迭代器

具体实现即:es6提供的forEach等迭代方法

外部迭代器

需要显示的调用下一个迭代项,使用复杂但应用灵活,可参考如下应用

// 工具方法,判断是否是数组
let isArrayFn = function (value){
	if (typeof Array.isArray === "function") {
		return Array.isArray(value);
	}else{
		return Object.prototype.toString.call(value) === "[object Array]";
	}
}
// 工具方法,判断是否是对象
let isObjectFn = function (value){
	return Object.prototype.toString.call(value) === "[object Object]";
}

// 外部迭代器
let Iterator = function( data ){
  if (isArrayFn(data)) {
      let current = 0;
      let next = function(){
        current += 1;
      };
      let isDone = function() {
        return current >= data.length
      }
      let getCurrItem = function(){
        return data[ current ];
      };
      return {
        next: next,
        isDone: isDone,
        getCurrItem: getCurrItem,
        length: data.length
      }
  } else if (isObjectFn(data)) {
      let keyArray = Object.keys(data);
    	let current = 0;
      let next = function(){
        current += 1;
      };
      let isDone = function() {
        return current >= keyArray.length
      }
      let getCurrItem = function(){
        return data[ keyArray[current] ];
      };
    	let getCurrKey = function(){
        return keyArray[current];
      };
      return {
        next: next,
        isDone: isDone,
        getCurrItem: getCurrItem,
        getCurrKey: getCurrKey,
        length: keyArray.length
      }
  } else {
    	throw new Error (
        `Iterator内请输入对象或者数组,当前是:${Object.prototype.toString.call(data)}`
      );
  }
  
};
// 外部迭代器的运用,写一个比较数组项是否全相等的方法
let compareArray = function( iterator1, iterator2 ){
  if (iterator1.length !== iterator2.length ) return false;
	while(!iterator1.isDone() && !iterator2.isDone()){
		if ( iterator1.getCurrItem() !== iterator2.getCurrItem() ){
      return false;
		}
    iterator1.next();
    iterator2.next();
	}
	return true;
}
// 外部迭代器的运用,写一个比较对象内容是否全相等的方法
let compareObject = function( iterator1, iterator2 ){
  if (iterator1.length !== iterator2.length ) return false;
	while(!iterator1.isDone() && !iterator2.isDone()){
		if ( iterator1.getCurrItem() !== iterator2.getCurrItem()
        || iterator1.getCurrKey() !== iterator2.getCurrKey()){
      return false;
		}
    iterator1.next();
    iterator2.next();
	}
	return true;
}
let iterator1 = Iterator( [ 1, 2, 3 ] );
let iterator2 = Iterator( [ 1, 2, 3 ] );
let ret = compareArray( iterator1, iterator2 );
let iterator3 = Iterator( {a: 1} );
let iterator4 = Iterator(  {b: 1} );
let ret2 = compareObject( iterator3, iterator4 );
console.log(ret);
console.log(ret2);

延伸

有限状态机

图1

简单说,它有三个特征:

* 状态总数(state)是有限的。
* 任一时刻,只处在一种状态之中。
* 某种条件下,会从一种状态转变(transition)到另一种状态。

它对JavaScript的意义在于,很多对象可以写成有限状态机。

比如:鼠标悬停的时候,菜单显示;鼠标移开的时候,菜单隐藏。转换成有限状态机的思路就是,通过变更状态,来触发事件,而具体事件实现在状态机以外。如下:

let menu = {     
  // 当前状态
  currentState: 'hide',
  // 状态转换
  transition: function(event){
    switch(this.currentState) {
      case "hide":
        this.currentState = 'show';
        doSomething();
        break;
      case "show":
        this.currentState = 'hide';
        doSomething();
        break;
      default:
        console.log('Invalid State!');
        break;
    }
  }
};

可以看到,有限状态机的写法,逻辑清晰,有利于封装事件。一个对象的状态越多、发生的事件越多,就越适合采用有限状态机的写法。

这要比回调函数、事件监听、发布/订阅等解决方案,在逻辑上更合理,更易于降低代码的复杂度。

Redux

图2

Redux 来自facebook,是 JavaScript 应用程序的可预测状态容器。用来编写行为一致应用程序。可以理解为有限状态机的升级版本。

Redux把一个业务对象级别的状态机,升级为整个项目级别的状态机。把状态(数据)提炼为核心元素,围绕数据有序的实现业务。