11基础篇对象遍历的不同方法和适用场景
属性和原型链操作是对象操作中最为关键的组成部分,但只有补齐遍历
这一环才能实现对象的完整访问能力。大家可以这样理解,对象就像一个拥有很多把锁的黑盒,你用相应的钥匙(key)就能打开相应的锁(value),但是你连用哪些钥匙都不知道,那么只能俩眼一抹黑,啥也做不了。
单纯依靠暴露出来的属性访问语法和 API,我们是无法遍历对象的,只有引擎的底层才知道对象的结构。前面曾经提到过对象的内部属性中有这么一个 [[OwnPropertyKeys]]
,它可理解为一个数组,记录了对象的所有键(key)。
对象的遍历实际上是对键的遍历,因此都离不开对 [[OwnPropertyKeys]]
的访问,只不过策略有所不同。
我们把遍历需求分为 4 个层次:
遍历对象自身的可枚举数据
Object.keys
、Object.values
和 Object.entries
是遍历对象自身属性的常用方法。原型链上的属性不会被纳入最终结果:
const obj = Object.create({
// 原型链不会被遍历
age: 12
}, {
name: {
value: 'foo',
enumerable: true,
}
});
Object.keys(obj); // ["name"]
这三个函数的筛选逻辑本质上是一致的,都是调用了一个叫做 EnumerableOwnProperties()
的内部方法,只不过输出的数据不同,一个是所有的键,一个是所有的值,最后一个是键值。
从这个方法的名字上就能看到,它只会遍历到可枚举
的属性,因此,要想使得某个键不出现在其结果中,可以设置 enumerable
为 false:
const obj = Object.create(null, {
name: {
value: 'foo',
enumerable: true,
},
age: {
value: 'foo',
// 不可枚举
enumerable: false,
}
});
Object.keys(obj); // ["name"]
还有一个特征没有明显体现出来,就是它不会遍历到 Symbol
类型的键:
const obj = {
name: 'foo',
// Symbol 不输出
[Symbol('age')]: 16,
};
Object.keys(obj); // ["name"]
因此,我们可以总结出 Object.keys/values/entries 只会遍历出对象自身的、可枚举的、以字符串类型为键的属性
,这三个条件,缺一不可。
现在我们放松部分条件,希望不可枚举的,以及 Symbol 类型的也会被遍历到,该怎么办呢?
遍历对象自身的所有数据
问题归结为遍历对象自身的所有数据,等价于获取 [[OwnPropertyKeys]]
的内容。
Object.getOwnPropertyNames
可以用来获取其中的字符串键,Object.getOwnPropertySymbols
用来获取其中的 Symbol
键,把它们合起来,就相当于得到 [[OwnPropertyKeys]]
完整内容:
var obj = Object.create(null, {
[Symbol('b')]: {
value: 'b',
writable: false,
enumerable: true,
configurable: true,
},
a: {
value: 'a',
writable: true,
enumerable: true,
configurable: true,
},
});
console.log([
...Object.getOwnPropertyNames(obj),
...Object.getOwnPropertySymbols(obj),
]); // ["a", Symbol(b)]
至于说为什么这样设计,要把字符串和 Symbol 分开,其实是一个历史问题。
Object.getOwnPropertyNames
是 ES5 引入的,当时还没有 Symbol 类型,因此它只会返回一个字符串数组。ES6 引入 Symbol 之后,如果要求 Object.getOwnPropertyNames
也返回 Symbol 类型的话,那么恐怕很多代码都会出错。所以为了向后兼容的考量,又引入了一个 Object.getOwnPropertySymbols
专门返回 Symbol 类型的键。
但话说回来,ES6 同时又引入了一个 Reflect.ownKeys
函数,实打实地返回的就是 [[OwnPropertyKeys]]
的完整内容,免去了需要拼接的麻烦:
console.log(Reflect.ownKeys(obj)); // ["a", Symbol(b)]
能提供和 Reflect.ownKeys
类似效果的还有 Object.getOwnPropertyDescriptors
,它提供的也是 [[OwnPropertyKeys]]
的全部内容,外加各个键的属性描述符:
// {
// a: { value: 'a', writable: true, enumerable: true, configurable: true },
// [Symbol(b)]: { value: 'b', writable: false, enumerable: true, configurable: true }
// }
console.log(Object.getOwnPropertyDescriptors(obj));
💡
Object.getOwnPropertyDescriptors
是Object.getOwnPropertyDescriptor
的批量版本。
总体而言,这几个 API 相较于前面的,提供的信息量更加全面。至于使用哪个,很大程度上取决于需求。
接下来,我们突破对象自有属性的限制,来把原型链也考虑进去。
遍历对象及原型链的所有可枚举数据
能够实现遍历原型链的现成方法,目前只有 for...in
一种,然而,它一是只能遍历可枚举属性,二是只能遍历字符串键的属性:
var obj = Object.create(
Object.create(null, {
// 原型链上的属性会被遍历
d: {
value: 'd',
writable: true,
enumerable: true,
configurable: true,
},
}), {
// Symbol 不会被遍历
[Symbol('b')]: {
value: 'b',
writable: false,
enumerable: true,
configurable: true,
},
a: {
value: 'a',
writable: true,
enumerable: true,
configurable: true,
},
// 不可枚举的属性不会被遍历
c: {
value: 'c',
writable: true,
enumerable: false,
configurable: true,
},
}
);
for (let key in obj) {
console.log(key); // a d
}
如果想把 Symbol
包括进来,甚至和那些不可枚举的属性,我们只能自己实现。下面就是一种未经过优化的代码,仅代表其可能性,大家看看能不能读得懂:
function getExtendedKeys(obj) {
const visitedKeys = new Set();
let current = obj;
// 向上遍历原型链
while (current) {
// 遍历当前属性
const keys = Reflect.ownKeys(current);
keys.forEach(key => {
// 去重
if (visitedKeys.has(key)) return;
visitedKeys.add(key);
});
current = Object.getPrototypeOf(current);
}
return Array.from(visitedKeys);
}
核心原理仍然是原型链遍历和属性遍历。
可以这样说,上面我们讲到的所有遍历方法,无论是 Object.keys/values/entries/getOwnPropertyNames/getOwnPropertySymbols/getOwnPropertyDescriptors
,还是 Reflect.ownKeys
,亦或是 for...in
,都是基于对象属性的,不可能遍历出属性之外的东西。如果我们想实现遍历数据的动态性,那就必须先把它写入到对象中才行。
现在,我们有了更加强大的语法,for...of
,它真正实现了突破对象属性圈子的能力。
完全自定义遍历数据
for...of
不绑定任何对象属性,每次遍历出什么数据,完全是自定义的。从这一点上来说,for...in
的功能是其子集。在如今的 ECMAScript 规范中,for...in
依赖的是内部的一种特殊迭代器。而 迭代器(iterator)
,正是 for...of
工作原理的本质所在。
什么是迭代器
可以认为迭代器就是一个接口(interface),实现了该接口的对象,就可以被 for...of
遍历。
有多种方式可以实现迭代器。
第一种是利用生成器函数。前面函数那一章我们讲过,生成器函数始终返回一个迭代器对象:
function* range(start, end) {
for (let i = start; i <= end; ++i) {
yield i;
}
}
for (const i of range(3, 6)) {
console.log(i); // 3 4 5 6
}
简单理解的话,yield
指令的右侧值就是遍历时每次得到的值。显然这里返回的数据和对象的属性没有任何关系。
迭代器也可以不通过 for...of
调用,它主要就包含一个 next
函数,返回格式是:
{
value?: any;
done?: boolean;
}
所谓的遍历过程,本质上就是一直调用 next
函数,直到 done
为 true:
let it = range(3,6), value, done;
while(1) {
const ret = it.next();
done = ret.done;
value = ret.value;
if (done) break;
console.log(value); // 3 4 5 6
}
第二种定义迭代器的方法是第一种的变种,需要使用到前面曾经提到过的 Symbol
常量: Symbolt.iterator
,定义了这个键的对象,且值为一个生成器,那么该对象就可以被遍历:
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
*[Symbol.iterator]() {
for (let i = this.start; i <= this.end; ++i) {
yield i;
}
}
}
for (const i of new Range(3, 6)) {
console.log(i); // 3 4 5 6
}
💡 之所以字符串、数组、Map、Set 都可以在
for...of
中使用,就是因为它们在原型上都定义了[Symbol.iterator]
属性。
这个特性给了我们自定义遍历数据的很大灵活性,大家可以自己试一试,把前面我们写的那个 getExtendedKeys
函数改写为一个迭代器,进而能用 for...of
遍历。
如果你不熟悉或者不喜欢生成器,那么第三种定义迭代器的方法就很适合你:
function createRanger(start, end) {
let current = start;
return {
next() {
const nextValue = current++;
return {
value: nextValue,
done: nextValue > end,
};
},
// 返回自身
[Symbol.iterator]() {
return this;
},
};
}
for (const i of createRanger(3,6)) {
console.log(i); // 3 4 5 6
}
本质上它在模拟迭代器的结构。next
函数遵照协议,必须返回一个 { value, done }
结构的对象,你自己来决定其中的字段值。但这还不够,for...of
会发现被遍历的对象依旧不是迭代器,这就需要靠 [Symbol.iterator]()
函数的返回值了。
前面的例子中,生成器函数一定返回迭代器对象,那么在这里,我们就强行返回自身,这样就“骗过”了 for...of
。
以上就是三种迭代器的定义方法。比较来说,它们适合不同的场景:
- 第一种,生成器函数,适合简单入参的、无额外数据字段的场景;
- 第二种,对象,适合需要进一步封装额外数据、增加内聚性的场景;
- 第三种,迭代器模拟,是第二种的变种,适合不想用生成器的场景。
无论哪一种,当我们遍历的时候,数据都是立即输出的,也就是说它们都是同步遍历
。
假设有这么一个场景,我们需要遍历一个很大的数据库,不可能一次性把数据全都加载过来,因此需要一边遍历,一边读取,而读取是异步的,怎么办?
异步遍历
很多人对异步迭代有一定的误解,认为在 for 循环中调用异步过程,就算异步遍历了:
for (const item of datas) {
await Promise.resolve(item).then(...
}
但事实上这只是同步迭代、异步消费。我们所说的异步遍历,指的是从数据集合中取出的过程就是异步的,不关心消费过程是否异步。用 Promise 来描述异步的话, 大概是这样的:
class AsyncProducer {
constructor(size) {
this.current = size;
}
async produce() {
return this.current--;
}
}
我们过去怎么做的呢?异步递归
,本质上还是 Promise 首尾相连:
const producer = new AsyncProducer(5);
async function process() {
const num = await producer.produce();
if (0 === num) {
return;
}
console.log(num); // 5 4 3 2 1
await process();
}
process().finally(() => console.log('all done'));
但这样的代码阅读起来稍微有一些吃力,现在我们有更好的办法,就是使用异步迭代语法:for await...of
。
await
字样代表其必须在一个异步函数内部才能运行,而且还与 Promise
脱不开干系,是 for...of
的超集。for...of
能用的地方,for await...of
也能用,比如:
for await (const num of [1, 2, 3]) {
console.log(num); // 1 2 3
}
不过两者有一个很大的不同,for await...of
会把迭代器返回的值用 Promise
包裹进去,然后再 resolve
出来,因此上面代码中的三次打印动作之间并不是同步的,也正因为多了这两步操作,它要比 for...of
慢一点点。
所以说,在同步迭代器上,虽然可以,但没有理由使用 for await...of
语法。它真正能体现价值的,是异步迭代器
。
同步迭代器的三种定义方法也适用于异步迭代器,只不过需要把同步的生成器函数改成异步生成器函数,Symbol.iterator
改成 Symbol.asyntIterator
:
// 第一种,异步生成器函数
async function* range(start, end) {
for (let i = start; i <= end; ++i) {
yield i;
}
}
// 第二种,Symbol.iterator => Symbol.asyncIterator,注意这里虽然同步和异步都能工作,但是只有异步才有实际意义
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
async *[Symbol.asyncIterator]() {
for (let i = this.start; i <= this.end; ++i) {
yield i;
}
}
}
// 第三种,Symbol.iterator => Symbol.asyncIterator,注意,这里的函数必须是同步的
// 同时 next 返回的是 Promise 格式
function createRanger(start, end) {
let current = start;
return {
next() {
const nextValue = current++;
return Promise.resolve({
value: nextValue,
done: nextValue > end,
});
},
// 必须同步返回自身
[Symbol.asyncIterator]() {
return this;
},
};
}
现在我们改写一下上面那个异步递归
:
const producer = {
current: 5,
// 异步生成器函数
async * [Symbol.asyncIterator]() {
for(let i = this.current; i > 0;i--) {
yield await Promise.resolve(i);
}
}
};
// 必须在异步函数内部执行
(async() => {
for await(const num of producer) {
console.log(num); // 5 4 3 2 1
}
})();
这么遍历是不是更容易阅读呢?大家用这么一句话理解就行了:异步迭代 = 同步迭代 + Promise
。
小结
遍历是对象这样的属性集合的常见操作,除了大家耳熟能详的 for...in
之外,还有 Object.keys/values/entries/getOwnPropertyNames/getOwnPropertySymbols/getOwnPropertyDescriptors
、Reflect.ownKeys
、for...of
、for await...of
这么多种,本章节一共“遍历”了这 10 种方式。肯定有些同学已经眼花缭乱了,感觉更加迷惑。
我们可以分成两类来看待这 10 个方法,最独特的莫过于 for...of
和 for await...of
,它们本质上和对象的属性无关,而剩下的 8 种则全部是在对象属性这个范围内工作的。我制作了下面这张表格,来体现它们的异同点:
遍历方法 | 包括自身 String 属性 |
包括自身 Symbol 属性 |
包括原型链 属性 |
包括不可枚举 属性 |
---|---|---|---|---|
for...in |
✅ | ❌ | ✅ | ❌ |
Object.keys/values/entries |
✅ | ❌ | ❌ | ❌ |
Object.getOwnPropertyNames |
✅ | ❌ | ❌ | ✅ |
Object.getOwnPropertySymbols |
❌ | ✅ | ❌ | ✅ |
Object.getOwnPropertyDescriptors |
✅ | ✅ | ❌ | ✅ |
Reflect.ownKeys |
✅ | ✅ | ❌ | ✅ |
有两条关键特征需要关注:
- 只有
for...in
能遍历原型链; - 带
own
字样的都不关心是否可枚举。
除了遍历范围之外,它们返回的信息量也有不同,比如 Object.values/entries/getOwnPropertyDescriptors
,在有些场景可能必须要用到某些 API,特别是 Object.getOwnPropertyDescriptors
,它提供的的信息量几乎是最完备的。再加上原型链的相关知识,我们可以实现任意逻辑的遍历操作,当上面这些现成的遍历方法不满足的时候,你就可以自己去实现了。
到这里为止,我们在对象上的各种操作基本就都了解完毕了,三要素:属性
、原型
和遍历
,大家要记牢。
下一节,我们回过头来看 ES6 以后创建对象的新语法 class
,看它到底是如何工作的,以作为对象操作的高级案例来巩固相关知识。