通过一个案例理解 JS 对象的 enumerable 和 iterator

320 阅读8分钟

我是加菲猫🐱 ,如果你喜欢我的文章,欢迎点赞分享哦

加菲猫以前面试的时候,问到了扩展运算符的用法,面试官又补充了一个问题:一个对象需要满足什么条件,才可以被扩展运算符展开? 这个问题看似简单,后来想了想其实没那么简单,里面涉及很多知识点,其中不少跟平时开发密切相关,但往往又被大家忽略了。下面我们一起来看一下吧。

这个问题中,“扩展运算符”的概念是比较模糊的。我们知道,在 JS 中有两种扩展运算符:

  • ES2015 扩展运算符
  • ES2018 对象展开语法(object-rest-spread)

这两种语法看起来都是 ...,但实际上完全不一样。ES2015 扩展运算符是针对数组,底层使用 for...of 基于迭代器实现,因此只要是可迭代结构,例如 String、Set、Map 也可使用扩展运算符展开为一个数组,此外还可以在函数参数中使用。我们来简单回顾一下:

// 展开数组
const foo = [...[1, 2, 3, 4]]; // [1, 2, 3 ,4]
// 展开字符串,实际上只要可迭代结构就可以展开
const bar = [..."2333"]; // ['2', '3', '3', '3']

const map = new Map([["a", 1]);
// 将 map 对象转为数组,使用默认迭代器接口 `entries`
const baz = [...map]; // [["a", 1]]
// 将 map 对象转为数组,使用 `values` 接口
const far = [...map.values()]; // [1]

// 函数参数展开
fn(...[1, 2, 3, 4]); // 实际传的是 fn(1, 2, 3, 4)

// 用在等号左边就是剩余运算符
const [foo, ...bar] = [1, 2, 3, 4];
console.log(bar); // [2, 3, 4]
// 用在函数形参上就是剩余参数
const fn = (foo, ...args) => {
  console.log(args); // 接收到一个参数数组
}

而 ES2018 对象展开语法,则是针对对象,底层使用 Object.assign,相当于就是合并对象属性,不能在函数参数中使用。我们也回顾一下:

const obj = {
  name: "dby",
  age: 12
};
// 相当于 Object.assign(obj, { age: 2333 })
obj = { ...obj, age: 2333};

// 在等号左边就是剩余运算符
// 下面的操作删除了一个对象属性
const { name, ...restObj } = obj;
console.log(restObj); // { age: 12 }

需要注意的是,普通对象不可迭代。如果尝试将普通对象展开到数组中,就会报错(但这个并不是绝对的,后面会讲):

[...{}] // Uncaught TypeError: {} is not iterable

注意这里的 ... 是 ES2015 扩展运算符

但是反过来数组可以展开到普通对象:

{ a: 1, ...[1, 2] }; // {0: 1, 1: 2, a: 1}
// 这里的行为跟 Object.assign 一致
Object.assign({a: 1}, [1, 2]); // {0: 1, 1: 2, a: 1}

注意这里的 ... 是 ES2018 对象展开语法

所以这个问题实际上要分情况讨论,对象是展开到数组,还是展开到另一个对象。

对象展开到数组 - iterator

刚才前面讲到,由于普通对象不可迭代,因此无法展开到数组中。除了不能展开到数组中,普通对象还不能使用 for...of 遍历(也是基于迭代器的)。那么怎样才能让普通对象变为可迭代呢?

之所以普通对象不可迭代,是因为普通对象没有实现 Symbol.iterator 接口。实际上,任何一个对象只要实现了 Symbol.iterator 接口就是可迭代的。

developer.mozilla.org/en-US/docs/…

在可迭代的结构中,都实现了 Symbol.iterator

Screen Shot 2022-02-03 at 8.15.55 PM.png

但是普通对象并没有实现 Symbol.iterator

Screen Shot 2022-02-03 at 8.18.17 PM.png

关于 Iterator(迭代器)的概念,可以参考阮一峰 ES6 教程:

es6.ruanyifeng.com/#docs/itera…

简单来说,ES6 为了统一集合类型数据结构的处理,增加了 iterator 接口,供 for...of 使用,简化了不同数据结构的处理。而 iterator 的遍历过程与 Generator 类似,通过调用 next 方法,返回一个包含 value(值) 和 done(是否遍历结束) 属性的对象。

因此,我们可以按照 iterator 规范自己实现 Symbol.iterator 接口。

const obj = {
  a: 1,
  b: 2,
  [Symbol.iterator]() {
    const keys = Object.keys(obj); 
    let index = 0;
    return {
      next() {
        if (index < keys.length) {
          // 迭代未结束
          return {
            value: this[keys[index++]],
            done: false
          };
        } else {
          // 迭代结束
          return { value: undefined, done: true };
        }
      }
    }
  }
}

有小伙伴问题,上面我们用了 Object.keys(),我们添加的 [Symbol.iterator] 也会被获取到吗?这个问题其实不用担心,Object.keys() 只会获取对象自身可枚举属性,不包括 Symbol 类型的属性,下面会讲到

我们还可以用 Generator 函数实现 iterator 接口:

onst obj = {
  a: 1,
  b: 2,
  [Symbol.iterator]: function* () {
    for (let key in obj) {
      if (Object.prototype.hasOwnProperty.call(obj, key)) {
        yield obj[key];
      }
    }
  }
}

然后我们来测试一下,这下可以使用扩展运算符展开到数组了,for...of 也可以遍历了:

const arr = [...obj]; // [1, 2]

for (const value of obj) {
  console.log(value); // 1 2
};

实际上数组、SetMap 都自带了三个迭代器接口 keysvaluesentries,数组和 Set 默认的迭代器接口是 valuesMap 默认迭代器接口是 entries。而普通对象没有迭代器接口,因此 Object.keys()Object.values()Object.entries 可以看作是给对象扩展了“迭代器接口”

对象展开到另一个对象 - enumerable

刚才提到对象展开到另一个对象,底层是通过 Object.assign 实现的。有小伙伴会问,这种方式不是都能展开吗,毕竟浅拷贝和合并对象属性都用的这个方法呀。大多数情况是没错,但还是有一些特殊情况:

let obj = {};

Object.defineProperties(obj, {
  a: {
    enumerable: true,
    value: 1,
  },
  b: {
    enumerable: false,
    value: 2,
  }
})

let obj2 = { ...obj };
Reflect.ownKeys(obj2); // ['a']

在上面的代码中,Reflect.ownKeys() 用于获取对象自身的所有属性(包含不可枚举以及 Symbol 属性)。我们给 b 属性设置了 enumerable: false,结果将 obj 展开到 obj2 之后,只有 a 属性被合并过来,b 属性并没有被合并过来。

有小伙伴会问,这里的 enumerable 是啥。这就要讲到对象属性的 可枚举性(enumerable)。

es6.ruanyifeng.com/?search=ES6…

对象的每个属性都有一个描述对象(Descriptor),用来控制该对象的行为,可以通过下面的方法获取:

let obj = { foo: 123 };
Object.getOwnPropertyDescriptor(obj, 'foo')
//  {
//    value: 123,
//    writable: true,
//    enumerable: true,
//    configurable: true
//  }

其中的 enumerable 属性,称为“可枚举性”,如果该属性为 false,某些操作会忽略当前属性:

  • for...in 循环:只遍历对象 自身 的和 继承可枚举 属性
  • Object.keys():返回对象 自身 的所有 可枚举 属性的键名
  • JSON.stringify():只序列化对象 自身可枚举 属性
  • Object.assign():忽略 enumerablefalse 的属性,只拷贝对象 自身可枚举 属性

实际上,引入“可枚举”这个概念最初目的,就是让某些属性可以规避掉 for...in 操作,不然所有每部属性和方法都会被遍历到。

例如,对象原型的 toString 方法,以及数组的 length 属性,就通过 enumerable: false,从而避免被 for...in 遍历到。此外,ES6 规定,所有 Class 的原型的方法都是不可枚举的

因此,通过上面的分析,Object.assign 在合并对象的时候会忽略不可枚举属性,使用 ES2018 对象展开语法的行为一致。为了让对象中的属性都能被拷贝到另一个对象中,需要确保这些属性都是可枚举的。

这里提一个问题,有小伙伴会问,for...in 循环和 Object.keys() 会忽略不可枚举属性,有时候我们需要取到这些不可枚举属性怎么办呢?

在 ES6 中一共有 5 种方法可以遍历对象的属性,我用一张表格总结一下:

自身可枚举属性自身不可枚举属性自身 Symbol 类型属性原型(继承)属性
for...in
Object.keys()
Object.getOwnPropertyNames()
Object.getOwnPropertySymbols()
Reflect.ownKeys()

Object.values()Object.entries 行为与 Object.keys() 一致

从上面的表格我们可以看出,只有 for...in 可以获取到继承属性,Reflect.ownKeys() 则可以获取到对象自身的所有属性。因此,如果我们想获取到对象不可枚举属性,可以使用 Object.getOwnPropertyNames() 方法或者 Reflect.ownKeys()

说到上面这个,想到一个问题,在实际开发中常常需要判断对象是否为空,很多同事会使用下面的代码:

const isEmpty = !!Object.keys(obj).length;

不是说不能这样写,通过上面的分析我们可以知道,Object.keys() 其实并不安全,只能拿到对象自身的可枚举属性。如果我们刻意将对象中某些属性设为不可枚举去隐藏它,就无法通过 Object.keys() 准确判断。

这时候有小伙伴可能会说,对象中有不可枚举属性的情况比较少见。确实不错,但是对象中使用 Symbol 类型的 key 是很常见的。如果使用 Object.keys() 就忽略了 Symbol 属性了,这肯定会出 bug 。因此最安全的方法是:

const isEmpty = !!Reflect.ownKeys(obj).length;

或者还有一个方法,对于需要判断对象是否为空的场景,一律使用 Map,借助 Map 对象上的 size 属性进行判断。

总结

在 JS 中有两种扩展运算符:ES2015 扩展运算符和 ES2018 对象展开语法。这道题其实没有具体指定哪种情况。如果是展开到对象,只需要保证对象属性可枚举就行(不可枚举属性不会被展开);如果是展开到数组,就要求这个对象实现了 iterator 接口(只要某个数据结构部署了 Iterator 接口,就可以对它使用扩展运算符,将其转为数组)。