我是加菲猫🐱 ,如果你喜欢我的文章,欢迎点赞分享哦
加菲猫以前面试的时候,问到了扩展运算符的用法,面试官又补充了一个问题:一个对象需要满足什么条件,才可以被扩展运算符展开? 这个问题看似简单,后来想了想其实没那么简单,里面涉及很多知识点,其中不少跟平时开发密切相关,但往往又被大家忽略了。下面我们一起来看一下吧。
这个问题中,“扩展运算符”的概念是比较模糊的。我们知道,在 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 接口就是可迭代的。
在可迭代的结构中,都实现了 Symbol.iterator:
但是普通对象并没有实现 Symbol.iterator:
关于 Iterator(迭代器)的概念,可以参考阮一峰 ES6 教程:
简单来说,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
};
实际上数组、
Set、Map都自带了三个迭代器接口keys、values、entries,数组和Set默认的迭代器接口是values,Map默认迭代器接口是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)。
对象的每个属性都有一个描述对象(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():忽略enumerable为false的属性,只拷贝对象 自身 的 可枚举 属性
实际上,引入“可枚举”这个概念最初目的,就是让某些属性可以规避掉 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 接口,就可以对它使用扩展运算符,将其转为数组)。