处在知识点贼多的前端领域,总想着学习一些“高性价比“的知识,几经搜寻后,找到了小册中修言的JavaScript 设计模式核⼼原理与应⽤实践,想起了这个有点了解但不多的“设计模式”,受益匪浅,将思考总结后的知识和大家分享下,希望共同进步。
在软件工程中,设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。 ——维基百科
设计模式代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。
设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。
在 JavaScript 设计模式中,主要用到的设计模式基本都围绕“单一功能”和“开放封闭”这两个原则来展开。
本篇文章中舍去了一些设计模式,只留下了“前端中好用,面试中常考”的部分。
在面向对象的编程语言中,构造器是一个类中用来初始化新对象的特殊方法,并且可以接受参数用来设定实例对象的属性和方法。
有一天接到个需求,我们需要将三年二班的教职工录入系统中,此时班里只有小明自己,定义学生时,三下五除二就写完了。
// 定义学生 let 小明 = { name:"小明", age:12, gender:'男', identity: '学生' }
又进行一天的招生后,来了小红和小强,于是CV后把他也加入了...
// 定义学生 let 小明 = { name:"小明", age:12, gender:'男', identity: '学生' } let 小红 = { name:"小红", age:13, gender:'女', identity: '学生' } let 小强 = { name:"小强", age:13, gender:'男', identity: '学生' }
又过了两天你老板过来了 说:“三年二班杀疯了,一天之间招进来了80个学生”。此时继续以上写法,代码肯定是重复并且臃肿。
此时构造器就派上了用场,在面向对象的编程语言中,构造器是一个类中用来初始化新对象的特殊方法,并且可以接受参数用来设定实例对象的属性和方法。
基本构造器 在 JS 中,ES6之前是没有类这个概念的,所以一般用函数来表示一个构造器,使用方法是在构造器函数前使用 new 关键字。
所以,基本的构造器模式看起来是这样的:
function Student(name,gender,age){ // 注意,构造函数首字母一般为大写 this.name = name; this.gender =gender; this.age =age; this.sayName = function(){ console.log("我是" + this.name) } }let 小明 = new Student("小明",'男',12)let 小红 = new Student("小红",'女',13)console.log(小明.sayName()) // -> "我是小明"
代码的冗余程度直线减少了,但也有个不理想的地方,就是每次创建一个新对象,都需要重新定义 sayName 这个方法。
为了使 sayName 这个方法在实例之间共享,我们使用原型(prototype)来优化。
原型模式,就是创建一个共享的原型,通过拷贝这个原型来创建新的类,用于创建重复的对象,带来性能上的提升。
ps: 此块使用到原型链知识
继续上面例子:
function Student(name,gender,age){ this.name = name; this.gender =gender; this.age =age;}// 如果在构造函数的原型属性上添加 sayName 方法,那么所有实例化的对象都会共享这个方法。优化代码是这样的:Student.prototype.say = function(){ console.log("我是" + this.name)}let 小明 = new Student("小明",'男',12)console.log(小明.sayName()) // -> "我是小明"
扩展:ES6版本 ES6 支持了类的定义,所以写起来风格更加优雅。
class Student { constructor(name,gender,age) { this.name = name this.gender =gender; this.age =age; this.work = ['学习','玩游戏'] } sayName(){ console.log("我是" + this.name) }}let 小明 = new Student("小明",'男',12)小明.sayName() // -> "我是小明"
特点:
构造函数内不定义属性和方法,把属性和方法都定义在构造函数的原型上。这样所有的对象实例都共享对象原型上的属性和方法
优点:
缺点:
由一个工厂对象决定创建某一种产品对象类的实例。主要用来创建同一类对象。
我们三年二班除了学生还有教师,或许还有专职的助教,此时我们的Student类并不能满足我们的需求,所以此时我们需要再创建“教师”与“助教”
// 教师class Teacher { constructor(name,gender,age) { this.name = name this.gender = gender; this.age = age; this.work = ['教书','偷懒'] }}
// 助教class AssistantTeacher { constructor(name,gender,age) { this.name = name this.gender =gender; this.age =age; this.work = ['协助教师','收钱'] }}
现在我们有三个类了(后面可能还会有更多的类),麻烦的事情来了:难道我每从数据库拿到一条数据,都要人工判断一下这个人的身份,然后手动给它分配构造器吗?可以实现,但不推荐,最好还是交给函数去处理:
function Factory(name, age, gemder,identity) { switch(identity) { case 'stucent': return new Student(name, age, gender) case 'teacher': return new Teacher(name, age, gender) case 'assistantTeacher': return new AssistantTeacher(name, age, gender) } }
看起来是好一些了,至少我们不用操心构造函数的分配问题了。
但如果再来几个身份,例如学生家长,例如寝室阿姨,难道要手写十个类、数十行 switch 吗?
当然不!
我们仔细观察上面的代码,发现每个类都有用name、age、gender、work这四个属性,它们之间的区别,也只在于 work 字段需要随 identity 字段取值的不同而改变,而其他三个不变,这样以来,我们是不是对共性封装的不够彻底呢?
现在我们把相同的逻辑封装回User类里,然后把这个承载了共性的 User 类和个性化的逻辑判断写入同一个函数:
function User(name , age, gender, identity, work) { this.name = name this.age = age this.gender = gender this.identity = identity this.work = work}function Factory(name, age, gender, identity) { let work = [] switch(identity) { case 'student': work = ['学习','玩游戏'] break case 'teacher': work = ['教书','偷懒'] break case 'assistantTeacher': work = ['协助教师','收钱'] case 'xxx': // 其它身份 ... return new User(name, age, gender, identity, work)}
这样一来,是不是爽多了?我们要做的事情可以简单太多,不用时刻想着拿到的这组数据是什么工种,不用想着给他分配什么构造函数,更不用手写无数个构造函数!!Factory函数 已经帮我们做完了一切,而我们只需要像以前一样无脑传参就可以了,舒服了!
简单总结一下,工厂模式其实就是将创建对象的过程单独封装。就像去小卖铺买东西,你不必关心这个东西的制作过程,只用告诉老板你想要的,老板就会把物品return给你。
工厂模式很爽,因为他实现了无脑传参。
在实际的业务中,我们往往面对的复杂度并非数个类、一个工厂可以解决,而是需要动用多个工厂。
我们继续看上个小节举出的例子,简单工厂函数最后长这样:
function Factory(name, age, gender, identity) { let work = [] switch(identity) { case 'student': work = ['学习','玩游戏'] break case 'teacher': work = ['教书','偷懒'] break case 'assistantTeacher': work = ['协助教师','收钱'] case 'xxx': // 教导主任 ... return new User(name, age, gender, identity, work)}
首先映入眼帘的是我们把所有身份塞进了同一个工厂,例如老师和学生,又例如之后可能会添加进来的教导主任,他们每种身份的权限都会存在着很大的差别,有些操作老师可以执行,又有些操作只有学校的管理层可以执行,因此我们需要对这个群体的对象进行单独的逻辑处理。
怎么办?去修改 Factory 的函数体,增加老师、教导主任相关的判断和处理逻辑吗?单从功能实现上来说,可以。但这么做会让代码变成山,因为学校还有校长、外包的食堂阿姨等等,每考虑到一个新的员工群体,就得去修改一次 Factory 的函数体。
这样做的后果是:
因为没有遵守开放封闭原则:对拓展开放,对修改封闭。
楼上这波操作错就错在我们不是在拓展,而是在疯狂地修改。
抽象工厂模式(Abstract Factory Pattern)是围绕一个超级工厂创建其他工厂。该超级工厂又称为其他工厂的工厂。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
作为上帝,我们想要创建一个动物,基本组成是躯体(Body)与灵魂(Soul)组成,我们准备开一个工厂来量产,但是我们又不知道具体生产的是什么类型的动物,只知道由这两部分组成,所以我先来一个抽象类来约定住动物的基本组成:
class AnimalFactory { // 创造躯体 createBody (){ throw new Error('抽象工厂方法不允许直接调用,你需要将我重写!'); } // 创建灵魂 createSoul(){ throw new Error('抽象工厂方法不允许直接调用,你需要将我重写!'); }}
楼上这个类除了约定动物的基本构成外,啥也不干,如果你尝试new一个AnimalFactory实力并调用里面的方法,它都会给你报错。在抽象工厂模式里,楼上这个类就是我们食物链顶端最大的Boss——AbstractFactory(抽象工厂);
抽象工厂不干活,具体工厂(ConcreteFactory)干活!当我们明确了生产方案以后就可以化抽象为具体,比如现在需要生产哺乳动物,那我就可以定制一个具体工厂:
复制代码 //具体工厂继承自抽象工厂 class Mammals extends AnimalFactory { createBody() { // 提供哺乳动物的躯体 return new MammalsBody(); } createSoul() { // 提供哺乳动物的灵魂 return new MammalsSoul() } }
这里我们在提供哺乳动物的时候,调用了两个构造函数:MammalsBody和MammalsSoul,它们分别用于生成哺乳动物的躯体与灵魂。像这种被我们拿来用于 new 出具体对象的类,叫做具体产品类(ConcreteProduct)。具体产品类往往不会孤立存在,不同的具体产品类往往有着共同的功能,比如哺乳动物的躯体和爬行动物的躯体,虽身体中有着不同的构造,带起码都有个壳。因此我们可以用一个抽象产品(AbstractProduct)类来声明这一类产品应该具有的基本功能。
// 定义操作系统这类产品的抽象产品类class Body { walking() { throw new Error('抽象产品方法不允许直接调用,你需要将我重写!'); }}// 定义具体操作系统的具体产品类class MammalsBody extends Body { walking() { console.log('我会用哺乳动物的方式行走') }}class reptilesBody extends Body { walking() { console.log('我会用爬行动物的方式行走') }}
生产'灵魂'也是同理,这里就不重复了。
// 定义灵魂的抽象类class Soul { // 灵性 spiritual() { throw new Error('抽象产品方法不允许直接调用,你需要将我重写!'); }}// 定义具体操作系统的具体产品类class MammalsSoul extends Soul { spiritual() { console.log('我具有哺乳动物的灵性') }}class reptilesSoul extends Soul { spiritual() { console.log('我具有爬行动物的灵性') }}
如此一来,当我们需要生产一个哺乳动物时,我们只需要:
// 哺乳动物const Mammals = new Mammals()const myMammals = {}// 让它拥有躯体myMammals.body = Mammals.createBody()// 让它拥有灵魂myMammals.soul = Mammals.createSoul()
当之后需要写一个新的物种,则不需要对动物工厂AnimalFactory做任何修改,只需要拓展它的种类:
class 火星某动物 extends AnimalFactory { createBody() { // 此种动物躯体 } createSoul() { // 此种动物灵魂 }}
这么个操作,对原有的系统不会造成任何潜在影响所谓的“对拓展开放,对修改封闭”就这么圆满实现了。
抽象工厂和简单工厂有哪些异同?
共同点:在于都尝试去分离一个系统中变与不变的部分。
不同点:场景的复杂度。
抽象工厂模式的定义,是围绕一个超级工厂创建其他工厂,对一些工作经验少的同学来说可能较难理解,但目前来说在JS世界里也应用得并不广泛,所以大家不必拘泥于细节,只需对“开放封闭原则”形成自己的理解,知道它好在哪,知道执行它的必要性。
保证一个类仅有一个实例,并提供一个访问它的全局访问点,这样的模式就叫做单例模式。
意图: 保证一个类仅有一个实例,并提供一个访问它的全局访问点。
主要解决: 一个全局使用的类频繁地创建与销毁。
如何解决: 判断系统是否已经有这个单例,如果有则返回,如果没有则创建。
单例模式是设计模式中相对较为容易理解、容易上手的一种模式,同时因为其具有广泛的应用场景,也是面试题里的常客。
一般情况下,当我们创建了一个类(本质是构造函数)后,可以通过new关键字调用构造函数进而生成任意多的实例对象。像这样:
class SingleDog { show() { console.log('俺是一个单例对象') }}const s1 = new SingleDog()const s2 = new SingleDog()s1 === s2 // false
楼上我们先 new 创建了一个 s1,又 new 创建了一个 s2, s1与s2显然是没有任何联系的,两者各占一块内存空间,单例模式想要做到的是,无论创建多少次,它都只返回第一次所创建的那个实例。
要做到这一点,就需要构造函数具备判断自己是否已经创建过一个实例的能力。我们现在把这段判断逻辑写成一个静态方法(其实也可以直接写入构造函数的函数体里):
class SingleDog { show() { console.log('俺是一个单例对象') } static getInstance() { // 判断是否已经new过1个实例 if (!SingleDog.instance) { // 若这个唯一的实例不存在,那么先创建它 SingleDog.instance = new SingleDog() } // 如果这个唯一的实例已经存在,则直接返回 return SingleDog.instance }}const s1 = SingleDog.getInstance()const s2 = SingleDog.getInstance()s1 === s2 // true
除了楼上这种实现方式之外,getInstance的逻辑还可以用闭包来实现:
SingleDog.getInstance = (function() { // 定义自由变量instance,模拟私有变量 let instance = null return function() { // 判断自由变量是否为null if(!instance) { // 如果为null则new出唯一实例 instance = new SingleDog() } return instance } })()
可以看出,在getInstance方法的判断和拦截下,我们不管调用多少次,SingleDog都只会给我们返回一个实例,s1和s2现在都指向这个唯一的实例。
生产实践:redux、vuex中的Store,或者我们经常使用的Storage都是单例模式。
来实现一下简易Storage:
class Storage{ static getInstance() { if(!Storage.instance) { Storage.instance = new Storage(); } return Storage.instance; } getItem(key) { return localStorage.getItem(key); } setItem(key, value){ return localStorage.setItem(key, value); }}const storage1 = Storage.getInstance()const storage2 = Storage.getInstance()storage1.setItem('name', '小明')storage1.getItem('name') // 小明storage2.getItem('name') // 小明storage1 === storage2 // true
优点
缺点
场景例子
在我们的开发过程中我们会为了一些通用功能在多个不同的组件、接口或者类中使用,这个时候我们这些功能写到每个组件、接口或者类中,但是这样非常不利于维护。
装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。
理解了装饰器的能解决了什么问题,那我们在什么情况下考虑使用装饰器模式呢?我的理解是:
装饰器本质是一个函数,可以分为带参数和不带参数(也叫装饰器工厂),装饰器使用 @expression这种形式,expression求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。
@Test()class Hello {}function Test(target) { console.log("I am decorator.")}
类修饰器
类装饰器一般主要应用于类构造函数,可以监视、修改、替换类的定义,装饰器用来装饰类的时候。装饰器函数的第一个参数,就是所要装饰的目标类本身。
a、添加静态属性或方法
@Test()class Hello {}function Test(target) { target.a = 1;}let o = new Hello();console.log(o.a) ==>1
b、添加实例属性或方法
@Test()class Hello {}function Test(target) { target.prototype.a = 1; target.prototype.f = function(){ console.log("新增加方法") };}let o = new Hello();o.f() ==>"新增加方法"console.log(o.a) ==>1
c、装饰器工厂(函数柯里化)
@Test('hello')class Hello {}function Test(str) { return function(){ target.prototype.a = str; target.prototype.f = function(){ console.log(str) }; }}let o = new Hello();o.f() ==>"hello"console.log(o.a) ==>"hello"
d、重载构造函数
@Test('hello')class Hello { constructor(){ this.a= 1 } f(){ console.log('我是原始方法',this.a) }}function Test(target) { return class extends target{ f(){ console.log('我是装饰器方法',this.a) } }}let o = new Hello();o.f() ==>"我是装饰器方法",1
适配器模式通过把一个类的接口变换成客户端所期待的另一种接口,可以帮我们解决不兼容的问题。
生活中的适配器
代码中的适配器
1.最简单的适配器
适配器模式没有想象中的那么复杂,举个最简单的例子。 客户端调用一个方法进行加法计算:
const result = add(1,2);
但是我们没有提供add这个方法,提供了同样类似功能的sum方法:
function sum(v1,v2){ return v1 + v2;}
为了避免修改客户端和服务端,我们增加一个包装函数:
function add (v1,v2){ reutrn sum(v1,v2);}
这就是一个最简单的适配器模式,我们在两个不兼容的接口之间添加一个包装方法,用这个方法来连接二者使其共同工作。
2.实际应用
如果现有的接口已经能够正常工作,那就永远不会用上适配器模式。适配器模式是一种“亡羊补牢”的模式,没有人会在程序的设计之初就使用它。因为没有人可以完全预料到未来的事情,也许现在好好工作的接口,未来的某天却不再适用于新系统,那么可以用适配器模式把旧接口包装成一个新的接口,使它继续保持生命力。比如在JSON格式流行之前,很多cgi返回的都是XML格式的数据,如果今天仍然想继续使用这些接口,显然可以创造一个XML-JSON的适配器
下面是一个实例,向googleMap和baiduMap都发出“显示”请求时,googleMap和baiduMap分别以各自的方式在页面中展现了地图:
const googleMap = { show: function(){ console.log( '开始渲染谷歌地图' ); }};const baiduMap = { show: function(){ console.log( '开始渲染百度地图' ); }};const gaodeMap = { display: function(){ console.log( '开始渲染高德地图' ); }};const renderMap = function( map ){ if ( map.show instanceof Function ){ map.show(); }};renderMap( googleMap ); // 输出:开始渲染谷歌地图renderMap( baiduMap ); // 输出:开始渲染百度地图renderMap( gaodeMap ); // 输出:开始渲染百度地图
这段程序得以顺利运行的关键是googleMap和baiduMap、gaodeMap提供了一致的show方法,但第三方的接口方法并不在控制范围之内,但如果gaodeMap提供的显示地图的方法名改了,不叫show而改叫display呢?
gaodeMap这个对象来源于第三方,正常情况下都不应该去改动它。此时可以通过增加gaodeMapAdapter来解决问题:
const googleMap = { show: function(){ console.log( '开始渲染谷歌地图' ); }};const baiduMap = { show: function(){ console.log( '开始渲染百度地图' ); }};const gaodeMap = { display: function(){ console.log( '开始渲染高德地图' ); }};const gaodeMapAdapter = { show: function(){ return gaodeMap.display(); }};renderMap( googleMap ); // 输出:开始渲染谷歌地图renderMap( baiduMap ); // 输出:开始渲染百度地图renderMap( gaodeMapAdapter ); // 输出:开始渲染高德地图
又比如vue的computed
原有data 中的数据不满足当前的要求,通过计算属性的规则来适配成我们需要的格式,对原有数据并没有改变,只改变了原有数据的表现形式
<template> <div id="example"> <p>Original message: "{{ message }}"</p> <!-- Hello --> <p>Computed reversed message: "{{ reversedMessage }}"</p> <!-- olleH --> </div></template><script type='text/javascript'> export default { name: 'demo', data() { return { message: 'Hello' } }, computed: { reversedMessage: function() { return this.message.split('').reverse().join('') } } }</script>
总结
适配器模式的原理很简单,就是新增一个包装类,对新的接口进行包装以适应旧代码的调用,避免修改接口和调用代码。
代理模式:为其他对象提供一种代理以控制对这个对象的访问。在某些情况下,一个对象不适合或者不能直接引用另一个对象,而代理对象可以在客户端和目标对象之间起到中介的作用。
在生活中,代理模式的场景是十分常见的,例如我们现在如果有租房、买房的需求,更多的是去找链家等房屋中介机构,而不是直接寻找想卖房或出租房的人谈。此时,链家起到的作用就是代理的作用。链家和他所代理的客户在租房、售房上提供的方法可能都是一致的(收钱,签合同),可是链家作为代理却提供了访问限制,让我们不能直接访问被代理的客户。
事件代理,可能是代理模式最常见的一种应用方式,也是一道实打实的高频面试题。它的场景是一个父元素下有多个子元素,像这样:
<body> <div id="father"> <a href="#">链接1号</a> <a href="#">链接2号</a> <a href="#">链接3号</a> <a href="#">链接4号</a> <a href="#">链接5号</a> <a href="#">链接6号</a> </div></body>
我们现在的需求是,希望鼠标点击每个 a 标签,都可以弹出“我是xxx”这样的提示。比如点击第一个 a 标签,弹出“我是链接1号”这样的提示。这意味着我们至少要安装 6 个监听函数给 6 个不同的的元素(一般我们会用循环,代码如下所示),如果我们的 a 标签进一步增多,那么性能的开销会更大。
// 假如不用代理模式,我们将循环安装监听函数const aNodes = document.getElementById('father').getElementsByTagName('a') const aLength = aNodes.lengthfor(let i=0;i<aLength;i++) { aNodes[i].addEventListener('click', function(e) { e.preventDefault() alert(`我是${aNodes[i].innerText}`) })}
考虑到事件本身具有“冒泡”的特性,当我们点击 a 元素时,点击事件会“冒泡”到父元素 div 上,从而被监听到。如此一来,点击事件的监听函数只需要在 div 元素上被绑定一次即可,而不需要在子元素上被绑定 N 次——这种做法就是事件代理,它可以很大程度上提高我们代码的性能。
事件代理的实现
用代理模式实现多个子元素的事件监听,代码会简单很多:
// 获取父元素const father = document.getElementById('father')// 给父元素安装一次监听函数father.addEventListener('click', function(e) { // 识别是否是目标子元素 if(e.target.tagName === 'A') { // 以下是监听函数的函数体 e.preventDefault() alert(`我是${e.target.innerText}`) }} )
在这种做法下,我们的点击操作并不会直接触及目标子元素,而是由父元素对事件进行处理和分发、间接地将其作用于子元素,因此这种操作从模式上划分属于代理模式。
Proxy
Vue2升级到Vue3的核心
es6增建了 MDN Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等),
总结
当对象间存在一对多关系时,则使用观察者模式。让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使它们能够自动更新自己,当一个对象的改变需要同时改变其它对象,并且它不知道具体有多少对象需要改变的时候,就应该考虑使用观察者模式。
生活中的观察者模式
例子1:过年期间,老板说好在大年30当晚发红包,到了当天晚上,大家都已经做好了抢红包的准备,时刻等待着红包的降临。这个观察红包的过程,就是一个典型的观察者模式。
例子2:女神朋友圈官宣新男友。各位潜藏备胎纷纷失恋。
模式特征
Subject 添加一系列 Observer, Subject 负责维护与这些 Observer 之间的联系,“你对我有兴趣,我更新就会通知你”。
代码实现
// 目标者类class Subject { constructor() { this.observers = []; // 观察者列表 } // 添加 add(observer) { this.observers.push(observer); } // 删除 remove(observer) { let idx = this.observers.findIndex(item => item === observer); idx > -1 && this.observers.splice(idx, 1); } // 通知 notify() { for (let observer of this.observers) { observer.update(); } }}// 观察者类class Observer { constructor(name) { this.name = name; } // 目标对象更新时触发的回调 update() { console.log(`她发消息了,我是:${this.name}`); }}// 实例化目标者let subject = new Subject();// 实例化两个观察者let obs1 = new Observer('男生A');let obs2 = new Observer('男生B');// 向目标者添加观察者subject.add(obs1);subject.add(obs2);// 目标者通知更新subject.notify(); // 输出:// 她发消息了,我是男生A// 她发消息了,我是男生B
优点
缺点
迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。 ——《设计模式:可复用面向对象软件的基础》
迭代器模式其实就是为了让一切皆可遍历。
模式特点
模式实现
遍历作为一种合理、高频的使用需求,几乎没有语言会要求它的开发者手动去实现。在JS中,本身也内置了一个比较简陋的数组迭代器的实现——Array.prototype.forEach,我们这里来实现一个简易的forEach
// 统一遍历接口实现var each = function(arr, callBack) { for (let i = 0, len = arr.length; i < len; i++) { // 将值,索引返回给回调函数callBack处理 if (callBack(i, arr[i]) === false) { break; // 中止迭代器,跳出循环 } }}// 外部调用each([1, 2, 3, 4, 5], function(index, value) { if (value > 3) { return false; // 返回false中止each } console.log([index, value]);})// 输出:[0, 1] [1, 2] [2, 3]
“迭代器模式的核心,就是实现统一遍历接口。”
模式细分
内部迭代器
内部迭代器: 内部定义迭代规则,控制整个迭代过程,外部只需一次初始调用
// jQuery 的 $.each(跟上文each函数实现原理类似)$.each(['小明', '小红', '小蓝'], function(index, value) { console.log([index, value]);});// 输出:[0, 小明] [1, 小红] [2, 小蓝]
优点:调用方式简单,外部仅需一次调用 缺点:迭代规则预先设置,欠缺灵活性。无法实现复杂遍历需求(如: 同时迭代比对两个数组)
外部迭代器
外部迭代器: 外部显示(手动)地控制迭代下一个数据项
借助 ES6 新增的 Generator 函数中的 yield* 表达式来实现外部迭代器。
// ES6 的 yield 实现外部迭代器function* generatorEach(arr) { for (let [index, value] of arr.entries()) { yield console.log([index, value]); }}let each = generatorEach(['Angular', 'React', 'Vue']);each.next();each.next();each.next();// 输出:[0, 'Angular'] [1, 'React'] [2, 'Vue']
优点:灵活性更佳,适用面广,能应对更加复杂的迭代需求
缺点:需显示调用迭代进行(手动控制迭代过程),外部调用方式较复杂
适用场景
不同数据结构类型的 “数据集合”,需要对外提供统一的遍历接口,而又不暴露或修改内部结构时,可应用迭代器模式实现。
特点
总结
对于集合内部结果常常变化各异,不想暴露其内部结构的话,但又想让客户代码透明的访问其中的元素,可以使用迭代器模式
作者:Hyyy
链接:
https://juejin.cn/post/7241114001323819063