本人是一个刚入门前端的小菜鸡,今天冲浪时偶然看到这么一道面试题:
1.请问下面代码是否可执行?
2.如果不可执行是因为什么?
3.你可以在不改变解构赋值的前提下使代码运行起来吗?
const [a, b] = { a: 1, b: 2 }
console.log(a)
console.log(b)
看到这个题我的第一想法是:不就是一个正常的解构赋值吗?。但是既然作为面试题问出来了,那大概率是不可执行的,所以我选择了不可执行。如我所愿,蒙对了,确实是不可执行,心中一阵窃喜。
但是为什么是不可执行的呢,其实我并不清楚。向下看解答,一个既熟悉又陌生的词映入眼帘:迭代。
依稀记得之前学习 js 基础的时候学习过这方面的知识,但是由于学的时候就似懂非懂,加之在后面的项目中几乎不怎么使用,所以印象并不深刻,久而久之在脑海里形成了这样一个模糊的概念:迭代 === 遍历。
再次接触迭代这个词是在背八股文的时候:
问:对象 { 'a': 1, 'b': 2 } 可以使用 for ... of 去遍历吗?
答:不可以,因为对象不是一个可迭代对象,所以不能使用 for ... of 遍历。
当时我也是这么去记忆的,面试被问到也是这么回答的,并没有继续深究... 直到今天看到这个面试题,我决定系统地学习一遍这方面的知识。
一、何为迭代
迭代是对可迭代对象的逐个元素访问的过程,这个过程是连续的,有序的。通过next()返回当前元素的信息(value和done),并指向该对象中的下一个元素。
这里没看懂也没关系,你只需要有这样一个印象:迭代就是对于具有迭代器的对象内部元素的顺序访问。就是这么简单。
注:本文中「对象」特指通过花括号{}创建的键值对集合(即普通对象,Object 类型);而「可迭代对象」中的「对象」是广义概念,指代任意符合可迭代协议的 JavaScript 值(例如数组、Map、Set 等内置可迭代类型,或自定义迭代逻辑的其他数据结构)。
二、可迭代对象
在 js 中可迭代对象包括 数组、字符串、Map和Set等。
看到这里你可能要问:对象为什么是不可迭代的?
是的,最开始我也有这样的疑惑,尤其是对象和数组,看上去都是存储了一排数据,怎么数组可迭代,对象就不可迭代呢?
原来,还真不是故意针对对象。你要想成为可迭代对象,必须要有一个属性,或者说在你的原型链上至少要能访问到一个属性: Symbol.iterator,这个属性对应一个方法,当我们执行方法,就会获取这个对象的一个迭代器。
还记得迭代器吗?上面提到过:迭代就是对于具有迭代器的对象内部元素的顺序访问。
哦,怪不得对象不可迭代,原来它原型链没有Symbol.iterator这样一个属性,我们也自然就不能通过这个属性对应的方法获取到它的迭代器,也就不能迭代了。
也就是说:能够通过 Symbol.iterator 拿到迭代器的对象,才是可迭代对象。
三、迭代器
上面介绍了,我们需要在可迭代对象的原型链上拿到Symbol.iterator对象对应的方法,并调用这个方法获取它的迭代器。下面代码中我们以数组为例获取它的迭代器:
const arr = [1, 2, 3]
const iter = arr[Symbol.iterator]() // 拿到该数组的迭代器
在迭代器 iter 中有一个方法 next(),该方法返回当前被指向的元素值,并且使指针的位置前进一步。
初始状态,指针指向数组的第一个与元素 1。第一次调用 next()函数会有如下返回:
{
value: 1,
done: false
}
其中 value值为当前元素的值,done表示迭代尚未结束,当迭代结束再次调用next将返回 undefined和true。
const arr = [1, 2, 3]
const iter = arr[Symbol.iterator]() // 拿到该数组的迭代器
console.log(iter.next()) // { value: 1, done: false }
console.log(iter.next()) // { value: 2, done: false }
console.log(iter.next()) // { value: 3, done: false }
console.log(iter.next()) // { value: undefined, done: true }
四、for ... of 的简单实现
最开始提到了一道八股文:我们不可以用 for ... of 去遍历一个对象,因为对象是不可迭代的。那看来 for ... of 中一定是用到了迭代器咯,而对象又不能通过访问Symbol.iterator获取迭代器,自然就不能通过 for ... of 去遍历了。
让我们使用迭代器实现一个简单的 for ... of 吧:
// 我们要实现的效果
const arr = [1, 2, 3]
for(let item of arr) {
console.log(item) // 打印 1, 2, 3
}
实现思路很简单,我们首先获取该数组的迭代器,然后循环使用next()方法,每次获取到 value和done的值,当 done 为 true 时跳出循环, done 为 false 时打印 value。
const arr = [1, 2, 3]
function myForOf(target) {
const iter = target[Symbol.iterator]() // 拿到 arr 的迭代器
while(true) {
const { value, done } = iter.next() // 调用迭代器的 next 方法获取 value 和 done
if (done) return
console.log(value) // 打印 1, 2, 3
}
}
五、八股并非真理——对象的可迭代化
经过上面的讲解和实践,我们知道,for...of 无非是去通过获取迭代器来遍历。那么我们可不可以通过给对象添加一个迭代器,使得 for...of也可以遍历对象呢?可以!
我们可以在对象的原型链上添加一个Symbol.iterator属性来定义迭代器,这样就可以使用 for...of 去迭代对象啦:
// 生成器的知识会在后面讲到
// 这里我们只需要知道,代码在Object原型上定义了一个迭代器
// 此时任意一个对象都变成了可迭代对象
Object.prototype[Symbol.iterator] = function* () {
yield* Object.values(this)
}
const obj = { 'a': 1, 'b': 2 , 'c': 3}
// 让我们开始书写八股文中的禁忌吧
for(let value of obj) {
console.log(value) // 打印 1, 2, 3
}
六、为何对象默认不能迭代
看到这里,我想大家肯定会问:那既然对象也可以通过设置迭代器变为可迭代对象,那为什么不从设计的时候就让对象是可迭代的呢?
不知道大家有没有注意到我上面反复提及到的一段话:按顺序访问。
回顾可迭代对象,如数组、字符串等,顺序性确实是其核心特征。以数组为例,索引不仅是访问元素的唯一标识,更是定义其顺序的基础 —— 每个元素通过连续的索引(0, 1, 2...)建立严格的前后关联,这种顺序直接决定了数组的遍历逻辑和操作语义。字符串亦是如此,虽然其元素是字符而非数值,但若没有字符间的顺序排列(如 "abc" 中 a 在前、b 在后),字符串的语义和操作(如切片、查找)将无法实现。
本质上,对于可迭代对象而言,顺序是其数据结构的内在属性—— 它不仅是元素排列的规则,更是迭代行为的前提:无论是 for...of 循环的逐个访问,还是扩展运算符(...)的展开逻辑,均依赖于可迭代对象预先定义的顺序规则。可以说,顺序性是可迭代对象与普通对象(无序集合)最根本的区别之一,也是其实现高效遍历和操作的基础。
对象的本质是构建 键值对的映射关系,其设计初衷并非为了维护元素的顺序性。在对象中,键值对的排列顺序既 不具备天然的确定性(不同环境遍历顺序可能不一致),也 不承载额外的逻辑意义—— 无论将哪一组键值对视为 “第一个”,都不会影响对象本身的语义和功能。
七、本篇总结
迭代就是对可迭代对象的元素的顺序访问。而所谓可迭代对象指的是可以在原型链上访问到Symbol.iterator属性并获取迭代器的那些元素。
迭代器通过 next() 方法实现对可迭代对象元素的依次访问,返回值有 value 和 done两个属性指示当前被访问的值以及迭代是否完成。
我们甚至可以通过在 Object 原型上定义迭代器使得 for..of 可以去遍历对象。
回到最开始的面试题上:
问:能运行吗?
答:不能运行,因为数组的解构赋值要求等号右面是一个可迭代对象,因为数组解构赋值的实质与 for ... of 类似,通过迭代器去提取元素。而普通对象不是可迭代对象,所以代码会报错。
咦?等等。
我们如何去实现一个简单的解构赋值?
以及...上面对于对象的改造中的生成器是什么?
面试题的第三个问题,我们该如何实现呢?又需要哪些知识?
这些疑问将在后续的文章中依次记录,继续聚焦于这个面试题,对空白的知识进行补充。