先记住这个答案
对象的键只有两种类型:字符串和 Symbol。字符串键谁都能写出同一个名字,Symbol 键却是每次 Symbol() 新生成的唯一值,所以不会和别的库、别的代码加的属性冲突。代价是它比较「低调」:Object.keys、Object.entries、for...in 和 JSON.stringify 都会跳过 Symbol 键。想拿到它,用 Object.getOwnPropertySymbols 单独取,或者用 Reflect.ownKeys 一次取出全部自有键。
- Symbol 键不会和任何字符串键冲突
- 常规字符串键枚举跳过 Symbol 键
Reflect.ownKeys可获取 Symbol 键- 展开与
Object.assign可复制符号键
哪些 API 看得见 Symbol 键
最直接的办法是给一个对象同时放字符串键和 Symbol 键,然后挨个 API 试一遍。
const id = Symbol('id')
const user = { name: 'Tom', [id]: 42 }
// Symbol 不能隐式转字符串,打印前先用 String() 显式转换
const show = (keys) => keys.map(String).join(', ')
// 这些 API 只看字符串键,Symbol 键被跳过
console.log(show(Object.keys(user)), JSON.stringify(user))
for (const key in user) console.log('for...in', key)
// 专门拿 Symbol 键,或者全都要
console.log(show(Object.getOwnPropertySymbols(user)))
console.log(show(Reflect.ownKeys(user)))
// 展开和 Object.assign 会复制可枚举的 Symbol 键
console.log({ ...user }[id], Object.assign({}, user)[id])
// 访问必须用同一个 Symbol,字符串 'id' 访问不到
console.log(user.id, user[id])查看输出与解释
name {"name":"Tom"}
for...in name
Symbol(id)
name, Symbol(id)
42 42
undefined 42user.id 是 undefined,因为点语法访问的是字符串键 'id',和 Symbol 键是两回事。要读 Symbol 键只能用方括号,并且手里得有当初那个 Symbol。
展开运算符会复制 Symbol 键,这点经常被答错。{ ...obj } 和 Object.assign 复制的是「自身的、可枚举的」属性,不区分键的类型。但 JSON.parse(JSON.stringify(obj)) 这种深拷贝会把 Symbol 键丢掉。
实际项目里拿它做什么
第一个场景是给别人的对象挂数据又不想撞名。比如在第三方库返回的对象上缓存一个计算结果,用字符串 __cache 有可能和库内部的字段重名,用 Symbol 就不会,而且序列化、遍历时也不会把这个缓存带出去。
第二个场景是语言内置的「钩子」。Symbol.iterator 让对象能被 for...of 遍历,Symbol.toPrimitive 控制对象的类型转换,Symbol.toStringTag 改变 Object.prototype.toString 的输出。这些协议都用 Symbol 做键,就是为了不和用户已有的字符串属性冲突,老代码里叫 iterator 的属性也不会被误认成迭代器。
容易答错的地方
- Symbol 键就是私有属性
- 不是。
Object.getOwnPropertySymbols和Reflect.ownKeys能把所有 Symbol 键列出来,拿到键就能读写。它只是不会在常规遍历里出现。需要真正私有,用 class 的#field或者闭包。 - 展开运算符会丢掉 Symbol 键
- 不会。
{ ...obj }和Object.assign都会复制可枚举的 Symbol 键。会丢的是JSON.stringify,以及只遍历Object.keys自己手写的拷贝函数。 Object.getOwnPropertyNames能拿到所有键- 它能拿到不可枚举的字符串键,但同样不包含 Symbol 键。要一次拿到全部,只有
Reflect.ownKeys。
面试官还会怎么问?
用 Symbol.for 创建的键也会被这些 API 忽略吗?
会。Symbol.for('id') 只是从全局注册表里取同一个 Symbol,类型还是 Symbol,Object.keys 照样跳过它。区别只在于任何地方调用 Symbol.for('id') 都能拿到同一个键,也就失去了「别人猜不到」的效果。
手写深拷贝时怎么保留 Symbol 键?
遍历键时用 Reflect.ownKeys(obj) 代替 Object.keys(obj),这样字符串键和 Symbol 键都能拿到。如果还要保留不可枚举属性和 getter,可以配合 Object.getOwnPropertyDescriptors 和 Object.defineProperties 一起用。
Reflect.ownKeys 返回的键是什么顺序?
先是整数形式的键,按数值从小到大;再是其它字符串键,按添加顺序;最后是 Symbol 键,也按添加顺序。所以 Symbol 键永远排在所有字符串键后面。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。