目录

11基础篇对象遍历的不同方法和适用场景

属性和原型链操作是对象操作中最为关键的组成部分,但只有补齐遍历这一环才能实现对象的完整访问能力。大家可以这样理解,对象就像一个拥有很多把锁的黑盒,你用相应的钥匙(key)就能打开相应的锁(value),但是你连用哪些钥匙都不知道,那么只能俩眼一抹黑,啥也做不了。

单纯依靠暴露出来的属性访问语法和 API,我们是无法遍历对象的,只有引擎的底层才知道对象的结构。前面曾经提到过对象的内部属性中有这么一个 [[OwnPropertyKeys]],它可理解为一个数组,记录了对象的所有键(key)。

对象的遍历实际上是对键的遍历,因此都离不开对 [[OwnPropertyKeys]] 的访问,只不过策略有所不同。

我们把遍历需求分为 4 个层次:

遍历对象自身的可枚举数据

Object.keysObject.valuesObject.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.getOwnPropertyDescriptorsObject.getOwnPropertyDescriptor 的批量版本。

总体而言,这几个 API 相较于前面的,提供的信息量更加全面。至于使用哪个,很大程度上取决于需求。

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2c6c0e9fea6d47ec8cba88f1dfccf0d9~tplv-k3u1fbpfcp-watermark.image?

接下来,我们突破对象自有属性的限制,来把原型链也考虑进去。

遍历对象及原型链的所有可枚举数据

能够实现遍历原型链的现成方法,目前只有 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

以上就是三种迭代器的定义方法。比较来说,它们适合不同的场景:

  1. 第一种,生成器函数,适合简单入参的、无额外数据字段的场景;
  2. 第二种,对象,适合需要进一步封装额外数据、增加内聚性的场景;
  3. 第三种,迭代器模拟,是第二种的变种,适合不想用生成器的场景。

无论哪一种,当我们遍历的时候,数据都是立即输出的,也就是说它们都是同步遍历

假设有这么一个场景,我们需要遍历一个很大的数据库,不可能一次性把数据全都加载过来,因此需要一边遍历,一边读取,而读取是异步的,怎么办?

异步遍历

很多人对异步迭代有一定的误解,认为在 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/getOwnPropertyDescriptorsReflect.ownKeysfor...offor await...of 这么多种,本章节一共“遍历”了这 10 种方式。肯定有些同学已经眼花缭乱了,感觉更加迷惑。

我们可以分成两类来看待这 10 个方法,最独特的莫过于 for...offor await...of,它们本质上和对象的属性无关,而剩下的 8 种则全部是在对象属性这个范围内工作的。我制作了下面这张表格,来体现它们的异同点:

遍历方法 包括自身 String 属性 包括自身 Symbol 属性 包括原型链属性 包括不可枚举属性
for...in
Object.keys/values/entries
Object.getOwnPropertyNames
Object.getOwnPropertySymbols
Object.getOwnPropertyDescriptors
Reflect.ownKeys

有两条关键特征需要关注:

  1. 只有 for...in 能遍历原型链;
  2. own 字样的都不关心是否可枚举。

除了遍历范围之外,它们返回的信息量也有不同,比如 Object.values/entries/getOwnPropertyDescriptors,在有些场景可能必须要用到某些 API,特别是 Object.getOwnPropertyDescriptors,它提供的的信息量几乎是最完备的。再加上原型链的相关知识,我们可以实现任意逻辑的遍历操作,当上面这些现成的遍历方法不满足的时候,你就可以自己去实现了。

到这里为止,我们在对象上的各种操作基本就都了解完毕了,三要素:属性原型遍历,大家要记牢。

下一节,我们回过头来看 ES6 以后创建对象的新语法 class,看它到底是如何工作的,以作为对象操作的高级案例来巩固相关知识。