查看原文
其他

用英雄联盟的方式讲解JavaScript设计模式

黄梵高 画漫画的程序员 2022-09-09


点击上方“蓝色字体”,选择“设为星标

做积极向上的前端人!



作者:黄梵高 

原文:https://juejin.cn/post/6844904165982879758

构造函数模式

简介

在JavaScript里,构造函数通常是认为用来实现实例的特殊的构造函数。通过new关键字来调用定义的构造函数,你可以告诉JavaScript你要创建一个新对象并且新对象的成员声明都是构造函数里定义的。在构造函数内部,this关键字引用的是新创建的对象。

作为一个老联盟fans,一定要亲手实现一下设计模式也可以融会贯通。

现在打算创建一个英雄联盟对象,需要地图,英雄,士兵,野怪,还有开始游戏的按钮。

img
function LOL(maps, heros, soldier, monster) {
    this.maps = maps
    this.heros = heros
    this.soldier = soldier
    this.monster = monster
    this.start = function() {
        return '地图:' + this.maps + '\n对战英雄:' + this.heros.join() + '\n小兵类型:' + this.soldier + '\n野怪:' + this.monster + '\n'
    }
}


var game1 = new LOL('召唤师峡谷', ['影流之主''诡术妖姬'], '超级兵''红buff')
var game2 = new LOL('大乱斗', ['影流之主''诡术妖姬'], '超级兵''红buff')
console.log(game1.start())
console.log(game2.start())

复制代码

这样写代码,每局游戏需要重新创建一个英雄联盟实例,

这样使用构造器,有多少个game就需要多少个start函数方法,如果共用一个start方法,可以节约很多内存

function LOL(maps, heros, soldier, monster) {
    this.maps = maps
    this.heros = heros
    this.soldier = soldier
    this.monster = monster
}
LOL.prototype.start = function() {
   return '地图:' + this.maps + '\n对战英雄:' + this.heros.join() + '\n小兵类型:' + this.soldier + '\n野怪:' + this.monster + '\n'
}


var game1 = new LOL('召唤师峡谷', ['影流之主''诡术妖姬'], '超级兵''红buff')
var game2 = new LOL('大乱斗', ['影流之主''诡术妖姬'], '超级兵''红buff')

console.log(game1.start())
console.log(game2.start())
复制代码

如果让start方法变成大家通用的就好了,因此把LOL.prototype.start改写,这样所以的LOL实例就可以共用一个方法,从原型链上继承即可

上面的方式可以节省内存,start实例函数可以在所有LOL对象的实例中使用

如果不使用new 也可以有其他方式创建对象

function LOL(maps, heros, soldier, monster) {
    this.maps = maps
    this.heros = heros
    this.soldier = soldier
    this.monster = monster
    this.start = function() {
        return '地图:' + this.maps + '\n对战英雄:' + this.heros.join() + '\n小兵类型:' + this.soldier + '\n野怪:' + this.monster + '\n'
    }
}


var game3 = new Object();
LOL.call(game3, "扭曲丛林", ['影流之主''剑圣'], '远程兵''大龙');
console.log(game3.start())

//也可以不使用new ,通过call方法在game3的作用域调用LOL

复制代码

这种方式虽然可以创建新的构造函数,但却不能继承LOL原型上的函数

如果直接运行LOL()函数(不使用new的情况下),由于this指向的是window对象,因此start方法会变成window.start()

如果强制要求函数使用new 方法也可以如下创建:

function LOL(maps, heros, soldier, monster) {
    if (!(this instanceof LOL)) {
        return new LOL(maps, heros, soldier, monster);
    }
    this.maps = maps
    this.heros = heros
    this.soldier = soldier
    this.monster = monster
}
复制代码

通过判断this的instanceof,就可以知道究竟是来自new方法,还是说是直接调用。如果是直接调用的话,判断条件为true,还是会return一个新的实例。

e.g:

var s = new String("lol");
var n = new Number(101);
var b = new Boolean(true);
// s n b返回的是实例对象 
//String{
// 0: 'l',
// 1: 'o',
// 2: 'l'
// }

//可以直接给变量赋值,或者不加new 关键词
复制代码

外观模式

简介

外观模式最大的体现其实就是入口,比如init()函数,把一些内部的函数都放在这个门面之下,只需要调用这个门面函数,其他乱七八糟的功能都可以实现。

现在有一个英雄,叫做亚索,我希望给他一些配置,比如技能,衣服等

img
function YaSuo() {
}
function Qskill (hero) {
    hero.prototype.Qskill = function() {
        console.log('hasaki!!')
    }
}
function Wskill (hero) {
    hero.prototype.Wskill = function() {
        console.log('风墙')
    }
}
function Eskill (hero) {
    hero.prototype.Eskill = function() {
        console.log('快乐')
    }
}
function Rskill (hero) {
    hero.prototype.Rskill = function() {
        console.log('痛里唉该痛')
    }
}
function Skin (hero, skin) {
    hero.prototype.skin = skin
}

function CreateYasuo () {
    Qskill(YaSuo)
    Wskill(YaSuo)
    Eskill(YaSuo)
    Rskill(YaSuo)
    Skin(YaSuo, 'originSkin')
    return new YaSuo()
}
CreateYasuo()
// 创建成功 外观模式启动
复制代码

通过上面的代码,成功创建了一个美丽的亚索。我们最后只需要了解,外观模式不仅简化类中的接口,而且对接口与调用者也进行了解耦。外观模式经常被认为开发者必备,它可以将一些复杂操作封装起来,并创建一个简单的接口用于调用

说白了就是用一个接口封装其它的接口。

外观模式优点就是易使用。缺点则是,当连续使用外观模式创建的接口时,可能会产生性能问题。

e.g.

var addMyEvent = function (el, ev, fn) {
    if (el.addEventListener) {
        el.addEventListener(ev, fn, false);
    } else if (el.attachEvent) {
        el.attachEvent('on' + ev, fn);
    } else {
        el['on' + ev] = fn;
    }
}; 
复制代码

这是最常见对监听事件的处理,前端必会。其中的addMyEvent就是对其他三个接口的封装,产生了一个门面,也就是外观模式。

代理模式

简介

其实代理模式我们生活中接触的很多了。比如es6中的proxy对象,还有我们平时上网用的VPN。那其实代理模式,就是让一个对象帮助其他的对象来做事。

比如我现在想创建一个英雄,名字叫做卡莉斯塔,俗称滑板鞋。这个英雄有个特点,当她放R技能的时候,会把一个对象拉过来到自己身边几秒,代理这个对象的走路行为,禁止他释放技能等等,那这就要用到代理模式了。

// 声明走路动作
function Walk (hero) { // 代理期间执行的操作
    return function() {console.log(hero + ' is walk')}
}
function Kalisita () { // proxy
    this.walk = Walk('Kalisita')
    this.Rskill = function(hero) { // 传入要拉取的英雄
        this.walk = function() {
            Walk('Kalisita')() // 既需要自己走
            hero.walk() // 还需要带着人一起走
        }
    }
}
function HeroA () { // 被代理走路的英雄
    this.walk = Walk('heroA')
}

var k = new Kalisita()
var a = new HeroA()

k.walk() // Kalisita is walk
a.walk() // heroA is walk

k.Rskill(a) // k把a的walk事件代理了, 现在k触发walk的同时,也会带着a一起walk哦
k.walk()
// Kalisita is walk 
// heroA is walk

复制代码

代理模式主要用于几点,

  • 要获取本来没有的对对象操作的权限
  • 要获取远程文件,需要代理模式作为跳板
  • 根据需要创建开销很大的对象,通过它来存放实例化需要很长时间的真实对象,比如浏览器的渲染的时候先显示问题,而图片可以慢慢显示(就是通过虚拟代理代替了真实的图片,此时虚拟代理保存了真实图片的路径和尺寸。
  • 只当调用真实的对象时,代理处理另外一些事情。例如C#里的垃圾回收,使用对象的时候会有引用次数,如果对象没有引用了,GC就可以回收它了。
详情可以参考《大话设计模式》

而我们前端代码中用的比较多的,应该就是vue.js中对data中数据响应式的代理。vue3中也将使用大量ES6支持的Proxy对象来改写。

e.g.

通过代理,尝试设置私有属性

function getPrivateProps(obj, filterFunc) {
  return new Proxy(obj, {
    get(obj, prop) {
      if (!filterFunc(prop)) {
        let value = Reflect.get(obj, prop);
        // 如果是方法, 将this指向修改原对象
        if (typeof value === 'function') {
          value = value.bind(obj);
        }
        return value;
      }
    },
    set(obj, prop, value) {
      if (filterFunc(prop)) {
        throw new TypeError(`Cant set property ${prop}`);
      }
      return Reflect.set(obj, prop, value);
    },
    has(obj, prop) {
      return filterFunc(prop) ? false : Reflect.has(obj, prop);
    },
    ownKeys(obj) {
      return Reflect.ownKeys(obj).filter(prop => !filterFunc(prop));
    },
    getOwnPropertyDescriptor(obj, prop) {
      return filterFunc(prop) ? undefined : Reflect.getOwnPropertyDescriptor(obj, prop);
    }
  });
}

function propFilter(prop) {
  return prop.indexOf('_') === 0;
}
复制代码

策略模式

img

简介

策略模式定义了算法家族,分别封装起来,让他们之间可以互相替换,此模式让算法的变化不会影响到使用算法的客户。

那听起来云山雾绕,怎么都涉及到 算法 了 ?难道我一个前端是时候进攻算法大军了吗。其实并不是,用一个超级常见的例子就可以解释!

让我们又回到英雄联盟,当我们第一次登陆英雄联盟的时候,需要输入一个新的姓名吧?起名规则起码得有以下这几条:

  • 名字长度
  • 名字是否有非法字符
  • 是否重名
  • 不能为空

其中具体的设定,只有开发者才知道了,身为玩家只能注意到这几点,那策略模式怎么体现在这里的呢?首先我们实现一个显而易见功能的例子:

var validator = {
    validate: function (value, type) {
        switch (type) {
            case 'isNonEmpty ':
                {
                    return true; // 名字不能为空
                }
            case 'isNoNumber ':
                {
                    return true; // 名字 不是 纯数字
                    break;
                }
            case 'isExist ':
                {
                    return true; // 名字已存在
                }
            case 'isLength':
                {
                    return true; // 长度合理
                }
        }
    }
};
复制代码

上述代码可以实现一个表单验证系统,刚创建角色起名字的时候验证那里的功能,只需要传入相应的参数就可以。

validator.validate('测试名字''isNumber') // false

虽然可以得到理想的结果,但这种写法有十分严重的缺点,最重要的,每次增加或修改规则时,需要修改整个validate函数,这不符合开放封闭原则,增加逻辑,让函数更加复杂不可控。

那真正适合的代码应该怎么写呢?

var validator = {
    // 所有验证规则处理函数存放的地方
    types: {},
    validate: function (str, types) {
        this.messages = [];
        var checker, result, msg, i;
        for (i in types) {
            var type = types[i];
            checker = this.types[type]; // 获取验证规则的验证类
            if (!checker) { // 如果验证规则类不存在,抛出异常
                throw {
                    name: "ValidationError",
                    message: "No handler to validate type " + type
                };
            }

            result = checker.validate(str); // 使用查到到的单个验证类进行验证
            if (!result) {
                msg = "Invalid value for *" + type + "*, " + checker.instructions;
                this.messages.push(msg);
            }
        }
        
        return this.hasErrors();
    },

    // 是否有message错误信息
    hasErrors: function () {
        return this.messages.length !== 0;
    }
};
复制代码

上面的代码定义了validator对象以及validate函数,函数内部会对传入的字符串,检测类型数组进行处理。如果存在规则,进行判断,并把错误信息发送到this.message。如果不存在规则,自然的就不需要继续执行,抛出error即可。

// 验证给定的值是否不为空
validator.types.isNonEmpty = {
    validate: function (value) {
        return value !== "";
    },
    instructions: "传入的值不能为空"
};

// 验证给定的值是否 不是 纯数字
validator.types.isNoNumber = {
    validate: function (value) {
        return isNaN(value); // 伪写法,因为isNaN会误判布尔值和空字符串等,因此并不能作为真正判断纯数字的依据
    },
    instructions: "传入的值不能是纯数字"
};

// 验证给定的值是否存在
validator.types.isExist = {
    validate: function (value) {
        // $.ajax() ...
        return true;
    },
    instructions: "给定的值已经存在"
};

// 验证给定的值长度是否合理
validator.types.isLength = {
    validate: function (value) {
        var l = value.toString().length
        if ( l > 2 && l < 10) {
            return true;
        } else {
            return false;
        }
    },
    instructions: "长度不合理,请长度在2-10个字符内"
};
复制代码

上面对types规则进行了补充,定义了几种规则,至此,对于名称校验,简单的设定就敲完了。接下来要准备的就是一个能够在英雄联盟合理的名字进行验证:

var types = ['isExist''isLength''isNoNumber''isNonEmpty']; // 决定想要的规则,无论增加或者减少,原函数都不需要改动
function check (name, types) {
    validator.validate(name, types);
    if (validator.hasErrors()) {
        console.log(validator.messages.join("\n"));
    } else {
        console.log('验证通过!')
    }
}
check('okckokckokck', types) // 长度不合理,请长度在2-10个字符内
check('老faker', types) // true
check('00001', types) // 传入的值不能是纯数字
复制代码

首先设定好想要的规则,用一个types数组囊括进来,之后定义一个check函数,把结果处理封装一下,最后传入参数,无论想要检测什么规则,都不需要修改原函数。现在无论我想检测faker可不可以注册,还是一个空字符串,都可以传入规则,进行使用。如果想添加新的规则,只需要在validator.types上续写对象就可以,方便清晰,结构明朗。

核心思想就是把复杂的算法结构,分别封装起来,让他们之间可以互相替换,上面的代码就很好的体现了 互相替换,因为无论我怎么去修改想要的规则,都不需要改动原本的代码。

桥接模式

img

简介

在系统沿着多个维度变化的同时,又不增加其复杂度并已达到解耦。将抽象部分与它的实现部分分离,使它们都可以独立地变化。简单的说:桥接模式最主要的特点是实现层(如元素绑定的事件)与抽象层(如修饰页面UI逻辑)解耦分离

下面依然是一个例子:

假如我们还在英雄联盟的世界里,每一场游戏最终都会有一个结局,无论胜利还是失败,都会弹出一个窗口,告诉你 —— Victory或者是Defeat

function GameMessage (type) { // 抽象 与 实现 的 桥梁
    this.fn = type ? new Victory() : new Defeat()
}
GameMessage.prototype.show = function() {
    this.fn.show()
}

function Defeat() { // 抽象层
    this.show = function() {
        console.log('im loser')
    }
}

function Victory() { // 抽象层
    this.show = function() {
        console.log('im winner')
    }
}

// 实现层
function getResult() {
    var switchVD = Math.ceil(Math.random()*10) > 5 // 胜利失败一半一半
    return new GameMessage(switchVD)
}
var result1 = getResult()
var result2 = getResult()
var result3 = getResult()
result1.show()
result2.show()
result3.show()

首先我们创建了一个GameMessage的函数,我们都知道胜利失败都有一半的概率,因此定义了switchVD变量,模拟一个随机事件,同时每次结果调用一次getResult函数,获取最新结果。

桥接模式体现在GameMessage函数上,将抽象的 Victory() 以及 Defeat() 与 我们获取结果的 getResult()实现解耦。函数之间不糅合逻辑,但又通过桥梁函数,连接在一起。

这么写的好处就是,两者都可以独立的变化,互不打扰。毕竟如果揉在一起,可能逻辑如下:

function Defeat() { // 抽象层
    this.show = function() {
        console.log('im loser')
    }
}

function Victory() { // 抽象层
    this.show = function() {
        console.log('im winner')
    }
}

var switchVD = Math.ceil(Math.random()*10) > 5
if (switchVD) {
    var result =  new Victory()
else {
    var result =  new Defeat()
}

result.show() // loser or winner

上述代码可以轻松的看到,如果没有桥接模式,直接把实现层,渲染层糅合在一起,会依赖上下文。倘若获取不到上下文的环境,很容易出现问题。

小结

桥接模式在日常开发中,会在不经意间频繁使用,目的也是为了让代码结构清晰,将不同逻辑的代码互相解耦。便于日后维护,开发时也更能区分模块,看的舒服,自然效率也高。

桥接模式关键是要理解抽象部分与实现部分的分离,使得二者可以独立的变化,而不必拘泥于形式。灵活的变化,适用场景的多变就非常适合使用这种模式来实现。桥接模式最重要的是找到代码中不同的变化纬度。

状态模式

简介

状态模式(State)允许一个对象在其内部状态改变的时候改变它的行为,对象看起来似乎修改了它的类。其实就是用一个对象或者数组记录一组状态,每个状态对应一个实现,实现的时候根据状态挨个去运行实现。

优点:

  1. 一个状态对应一个行为,直观清晰,增改方便。
  2. 状态与状态间,行为与行为间彼此独立互不干扰。
  3. 避免对象条件判断语句过多。
  4. 不用执行不必要的判断语句。

缺点:

  1. 需要将事物的不同状态以及对应的行为拆分出来,有时候会过度设计。
  2. 必然会增加事物类和动作类的个数,动作类再根据单一原则,分别拆成几个类,会反而使得代码混乱。

比如下面我们定义一个英雄的状态,名字叫亚索,其中亚索可能同时有好几个状态比如 边走边攻击 —— 我们俗称的“走A”,还有可能释放技能之后接一个“B键回家”的操作,当然最有可能的是eqw闪r行云流水的操作收获一个人头,再接一个ctrl+f6等。

img

如果对这些操作一个个进行处理判断,需要多个if-elseswitch不仅丑陋不说,而且在遇到有组合动作的时候,实现就会更为冗余。那么我们这里的复杂操作,可以使用 状态模式 来实现。

状态模式 的思路是:首先创建一个状态对象或者数组,在对象内部存储需要操作的状态数组或对象,然后状态对象提供一些接口,可以更改状态以及执行动作。

那现在有一个英雄叫做亚索!下面代码,我们就用亚索的状态来实现一下传说中的状态模式:

function YasuoState() {
    //存储当前即将执行动作的状态!
    this.currentstate = [];

    this.Actions = {
        walk : function(){
            console.log('walk');
        },
        attack : function(){
            console.log('attack');
        },
        magic : function(){
            console.log('magic');
        },
        backhome : function(){
            console.log('backhome');
        }
    };
}
  
YasuoState.prototype.changeState = function() {
    //清空当前的动作
    this.currentstate = [];
    Object.keys(arguments).forEach((i) => this.currentstate.push(arguments[i]))
    return this;
}
YasuoState.prototype.YasuoActions = function() {
    //当前动作集合中的动作依次执行
    this.currentstate.forEach((k) => this.Actions[k] && this.Actions[k]())
    return this;
}


  
var yasuoState = new YasuoState();
  
yasuoState.changeState('walk','attack').YasuoActions().changeState('walk').YasuoActions().YasuoActions();

上面代码成功实现了亚索的状态模式,我们假设他有走路、攻击、释放技能、回家几个状态,其中这几个状态其实是可以同时输入指令的,要不然那些职业选手的高光操作就会在 技能衔接 而出现的卡顿 香消玉殒。

状态模式最常见的就是日常的例子 —— 红绿灯,每当切换状态的时候,执行一次动作。

至于英雄联盟中,最常见的就是边走边攻击,在输入命令后,首先改变了我们对象的状态yasuoState.changeState('magic','backhome'),然后因为在代码中有return this;,可以链式调用接下来的行为,于是我们让它依次执行刚才输入的状态。接下来又一次改变了状态changeState('walk'),并且进行执行。可以看到执行了两次,由于状态并没有再次改变,因此只需要重复执行就可以保证我们的英雄一直往前走下去了。

希望状态模式可以帮助你解决绝大多数,需要切换状态的操作。遇到类似的问题时,可以迅速拿出成熟可靠的状态模式解决之。

总结

本次分享了六种设计模式,分别为构造函数模式,外观模式,代理模式,策略模式 ,桥接模式,状态模式;都是日常开发很常用的设计模式。

其中es6proxy可以访问阮老师博客查看详细api,具体使用也可以借鉴vue源码。代理模式虽然很好用,但也不是任何时候都建议使用,如果你的代码需要获得某些对象的权限,不妨可以使用一下代理模式。在比较简单的场景,可能就没有必要了。

外观模式是最常用的,毕竟每一个js文件总是需要一个入口的。无论是main函数还是init函数,都是起到一个外观包装的作用。当然外观模式并不是必须作为一个文件入口存在,只要能把重复的代码提炼出来,就是一个合理的外观模式。

构造函数模式就不多说了,简单好用。

  • 复制代码是危险的。如果有两段相同的代码,几乎可以说一定是有问题的,因为每次改动,要维护两段代码
  • 尽量减少IO操作,如操作数据库,网络发送,甚至printf ,这些操作比直接操作内存,慢很多倍、
  • 修改Bug时,一定要从最简单的基本的地方开始检查,不要检查到最底层没问题,发现是传入的某个参数是错的。先不要怀疑系统的部分。
  • 设计架构,同时了解细节,有些Bug,调起来可能费时费力,甚至花个二三天,其实当时写的时候,只要稍微注意,就可以轻松避免。避免Bug的代价与找出并修改Bug的代价,实在是差太多了。
  • 把一段长代码,分成很多小函数,便于维护,连自己都不愿看,不愿改的代码,百分百有问题。
  • 写程序时,先把流程搞清楚。把各个流程用的函数写清楚,函数可以留空,这样编程就变成了填空题。
  • 做新功能时,把数据结构的设计,放在较重要的位置

设计模式主要可以帮助我们解决,开发中对代码的设计问题,那我们如何找到合适的对象,并应用合适的设计模式呢?

借用书中的几个提示吧:

  • 寻找合适的对象
  • 决定对象的粒度
  • 决定好这个对象设计的接口
  • 把对象需要的具体函数实现
  • 合理的运用代码复用机制
  • 设计的代码应该可以支持变化,要对变化有预见性

大概是这几种,在 javascript 中涉及编译的场景较少,就不叙述了。

设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。

根据上面的几条规则,在开发接口和函数的时候,时刻注意,就可以避免大多数代码设计上的问题,对于以后的维护也会有巨大的帮助。下一个接受代码的人,也会十分感激你的,读代码其实和读书一样,你现在偷懒写的代码可能无所谓,后面接手的人会疯狂吐槽。相反如果你优雅的实现,像我,就会心里由衷的佩服,看到整齐的函数,注释明朗的功能,不得不说,高手确实是高手啊,短短 200 行,让人跪服,就突出一个词 —— 优雅。




希望这次分享能够既有趣,又让你得到收获,谢谢阅读。



点击下方卡片,就能关注我啦👇




互联网人吐槽互撕系列漫画要来啦~
4个未听说过的强大JavaScript操作符
这些JavaScript 里的奇葩知识点,你遇到过吗?
JavaScript 中的“黑话”,你知多少?
漫画 | 前端发展史的江湖恩怨情仇
漫画 | 北上广打工人月薪五万回老家“注意事项
基于 Vue 的前端架构,我做了这 15 点
Vue 首页秒开实践指南
漫画 | 阿姨,我不想努力了
漫画 | 半夜,我差点揍了十年前的自己!

如果觉得这篇文章还不错,来个【分享、点赞、在看】三连吧,让更多的人也看到~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存