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);
延伸
有限状态机
简单说,它有三个特征:
* 状态总数(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
Redux 来自facebook,是 JavaScript 应用程序的可预测状态容器。用来编写行为一致应用程序。可以理解为有限状态机的升级版本。
Redux把一个业务对象级别的状态机,升级为整个项目级别的状态机。把状态(数据)提炼为核心元素,围绕数据有序的实现业务。