前端JS高频面试题---5.迭代器模式

747 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第14天,点击查看活动详情

前言

距离上一次更新设计模式的文章已经差不多一年了,最近捡起来接着更新,今天给大家介绍的是迭代器模式

简介

迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。 ——《设计模式:可复用面向对象软件的基础》

迭代器模式是设计模式中少有的目的性极强的模式。所谓目的性极强就是说它不操心别的,它就解决这一个问题——遍历。

迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器之后,即使不关心对象的内部构造,也可以按顺序访问其中的每一个元素。

模式的细分

  1. 内部迭代器
  2. 外部迭代器

内部迭代器

  • 优点:调用的时候非常的方便,外界不用关心迭代器内部的实现,跟迭代器的交互也仅仅是一次初始化调用
  • 缺点:由于内部迭代器的迭代规则已经被提前规定,欠缺灵活性。无法实现复杂遍历需求

模拟实现:

const each = function(arg, callback) {
   for(var i = 0; i < arg.length; i++) {
        callback(i, arg[i])
    }
}
const compare = function(arg1, arg2) {
     if(arg1.length !== arg2.length) {
         throw new Error('两个数组不相等')
     }
     each(arg1, function(i, n) {
         if(n !== arg2[i]) {
             throw new Error('两个数组不相等')
         }
     })
     console.log('两数组相等')
 }

 compare([1, 2, 3], [1, 2, 3]) // 两数组相等
 compare([1, 2, 4], [1, 2, 3]) // 两个数组不相等

外部迭代器

  • 优点:灵活性更佳,适用面广,能应对更加复杂的迭代需求
  • 缺点:需显示调用迭代进行(手动控制迭代过程),外部调用方式较复杂 模拟实现:
// 使用外部迭代器进行封装
const Iterator = function(obj) {
    let current = 0

    const next = function() {
        current += 1
    }

    const isDone = function() {
        return current >= obj.length
    }

    const getCurrentItem = function() {
        return obj[current]
    }

    return {
        next,
        isDone,
        getCurrentItem
    }
}

const compare1 = function(iterator1, iterator2) {
    while(!iterator1.isDone() && !iterator2.isDone()) {
        if(iterator1.getCurrentItem() !== iterator2.getCurrentItem()) {
            throw new Error('两个数组不相等')
        }
        iterator1.next()
        iterator2.next()
    }
    console.log('两数组相等')
}

const iterator1 = Iterator([1, 2, 3])
const iterator2 = Iterator([1, 2, 3])

compare1(iterator1, iterator2) // 两数组相等

远古时期的迭代器模式

遍历作为一个高频使用的需求,几乎没有语言要求开发者自己去实现。在JS中本身也内置了数组的迭代器----- Array.prototype.forEach。

通过调用forEach方法,我们可以轻松地遍历一个数组:

const arr = [1, 2, 3] arr.forEach((item, index)=>{ 
    console.log(`索引为${index}的元素是${item}`) 
})

但是用过forEach的小伙伴,一定知道,forEach方法并不是万能的。比如下面这种场景:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>迭代器模式demo</title>
</head>
<body>
    <p>p1</p>
    <p>p2</p>
    <p>p3</p>
    <p>p4</p>
    <p>p5</p>
    <p>p6</p>
</body>
</html>
//  我想拿到所有的p标签,我可以这样做:
const pNodes = document.getElementsByTagName('p');
console.log('pNodes are', pNodes)

// 我想取其中一个p标签,可以这样做:
const pNode = pNode[i]


// 在这个操作的映衬下,pNodes看上去多么像一个数组啊!但当你尝试用数组的原型方法去遍历它时:
pNodes.forEach((pNode, index) => {
    console.log(pNode, index)
})

你发现报错了:

image.png aNodes是个假数组!准确地说,它是一个类数组对象,并没有为你实现好用的forEach方法。也就是说,要想实现类数组的遍历,你得另请高明。

现在问题就出现了:普通数组是不是集合?是!aNodes是不是集合?是!同样是集合,同样有遍历需求,我们却要针对不同的数据结构执行不同的遍历手段,好累!再回头看看迭代器的定义是什么——遍历集合的同时,我们不需要关心集合的内部结构。而forEach只能做到允许我们不关心数组这一种集合的内部结构,看来想要一套统一的遍历方案,我们非得请出一个更强的通用迭代器不可了。

因为在几年前,还没有那么多轮子,ES标准也没有内置迭代器,为了解决上边的问题,可以借助jQuery的each方法:

// 引入jQuery
<script src="https://cdn.bootcss.com/jquery/3.3.0/jquery.min.js" type="text/javascript"></script>

const arr = [1, 2, 3] 
const pNodes = document.getElementsByTagName('p') 
$.each(arr, function (index, item) { 
    console.log(`数组的第${index}个元素是${item}`) 
}) 
$.each(pNodes, function (index, pNode) { 
    console.log(`DOM类数组的第${index}个元素是${pNode.innerText}`) 
})

image.png

可以看出,jQuery的迭代器为我们统一了不同类型集合的遍历方式,使我们在访问集合内每一个成员时不用去关心集合本身的内部结构以及集合与集合间的差异,这就是迭代器存在的价值~

ES6对迭代器的实现

在“远古时期”,JS原生的集合类型数据结构,只有Array(数组)和Object(对象);而ES6中,又新增了Map和Set。四种数据结构各自有着自己特别的内部实现,但我们仍期待以同样的一套规则去遍历它们,所以ES6在推出新数据结构的同时也推出了一套统一的接口机制——迭代器(Iterator)。

ES6约定,任何数据结构只要具备Symbol.iterator属性(这个属性就是Iterator的具体实现,它本质上是当前数据结构默认的迭代器生成函数),就可以被遍历——准确地说,是被for...of...循环和迭代器的next方法遍历。 事实上,for...of...的背后正是对next方法的反复调用。

在ES6中,针对Array、Map、Set、String、TypedArray、函数的 arguments 对象、NodeList 对象这些原生的数据结构都可以通过for...of...进行遍历。原理都是一样的,此处我们拿最简单的数组进行举例,当我们用for...of...遍历数组时:

const arr = [1, 2, 3] 
for(item of arr) { 
    console.log(`当前元素是${item}`) 
}

之所以能够按顺序一次一次地拿到数组里的每一个成员,是因为我们借助数组的Symbol.iterator生成了它对应的迭代器对象,通过反复调用迭代器对象的next方法访问了数组成员,像这样:

const arr = [1, 2, 3] 
// 通过调用iterator,拿到迭代器对象 
const iterator = arr[Symbol.iterator]() 

// 对迭代器对象执行next,就能逐个访问集合的成员 
iterator.next() 
iterator.next() 
iterator.next()

控制台运行显示:

image.png 而for...of...做的事情,基本等价于下面这通操作:

// 通过调用iterator,拿到迭代器对象
const iterator = arr[Symbol.iterator]()

// 初始化一个迭代结果
let now = { done: false }

// 循环往外迭代成员
while(!now.done) {
    now = iterator.next()
    if(!now.done) {
        console.log(`现在遍历到了${now.value}`)
    }
}

ps:此处推荐阅读迭代协议,相信大家读过后会对迭代器在ES6中的实现有更深的理解。

自己实现迭代器生成函数

上边说迭代器对象全凭迭代器生成函数帮我们生成。在ES6中,实现一个迭代器生成函数并不是什么难事儿,因为ES6早帮我们考虑好了全套的解决方案,内置了贴心的生成器(Generator)供我们使用:

// 编写一个迭代器生成函数
function *iteratorGenerator() {
    yield '1号选手'
    yield '2号选手'
    yield '3号选手'
}

const iterator = iteratorGenerator()

iterator.next()
iterator.next()
iterator.next()

image.png 写一个生成器函数并没有什么难度,但在面试的过程中,面试官往往对生成器这种语法糖背后的实现逻辑更感兴趣。下面我们要做的,不仅仅是写一个迭代器对象,而是用ES5去写一个能够生成迭代器对象的迭代器生成函数(解析在注释里):

// 定义生成器函数,入参是任意集合
function iteratorGenerator(list) {
    // idx记录当前访问的索引
    var idx = 0
    // len记录传入集合的长度
    var len = list.length
    return {
        // 自定义next方法
        next: function() {
            // 如果索引还没有超出集合长度,done为false
            var done = idx >= len
            // 如果done为false,则可以继续取值
            var value = !done ? list[idx++] : undefined
            
            // 将当前值与遍历是否完毕(done)返回
            return {
                done: done,
                value: value
            }
        }
    }
}

var iterator = iteratorGenerator(['1号选手', '2号选手', '3号选手'])
iterator.next()

此处为了记录每次遍历的位置,我们实现了一个闭包,借助自由变量来做我们的迭代过程中的“下标”。

image.png 迭代器模式是一种相对简单的模式,简单到很多时候都不认为他是一种设计模式。

迭代器模式还比较特别,它非常重要,重要到语言和框架都争着抢着帮我们实现。但也正因为如此,大家业务开发中需要手动写迭代器的场景几乎没有,所以很少有同学会去刻意留意迭代器模式、思考它背后的实现机制。

感谢

谢谢你读完本篇文章,希望对你能有所帮助,如有问题欢迎各位指正。

我是Nicnic,如果觉得写得可以的话,请点个赞吧❤。

写作不易,「点赞」+「在看」+「转发」 谢谢支持❤

往期好文

《前端JS高频面试题---1.发布-订阅模式》

《前端JS高频面试题---2.单例模式》

《前端JS高频面试题---3.代理模式》

《前端JS高频面试题---4.策略模式》

《前端CSS高频面试题---1.CSS选择器、优先级、以及继承属性》

《前端CSS高频面试题---2.em/px/rem/vh/vw的区别》

《前端CSS高频面试题---3.如何实现两栏布局,右侧自适应?三栏布局中间自适应呢?》