对象:属性、解构与枚举
一个标题、一个页数可以各用一个变量;当它们共同描述同一篇笔记时,对象把关系保存在一起。对象的关键不只是花括号,而是属性键、属性是否存在,以及读取时发生什么。
字面量与动态属性
const title = '对象';
const field = 'minutes';
const note = { title, [field]: 15, 'reading level': '基础' };
console.log(note.title, note[field]); // 对象 15
console.log(note['reading level']); // 基础
note.minutes = 20;
delete note['reading level'];{ title } 是 { title: title } 的简写;[field] 先求表达式的值,再把它用作键。点号后是固定属性名,方括号内可以是变量或表达式。note.field 找的是文字 'field',不会读取变量 field。
普通对象键只能是字符串或 Symbol。数字键会变成字符串;两个普通对象作为键也可能都变为 '[object Object]',因此无法可靠地区分。需要对象身份作键时使用本章 Map。
同一对方括号,在创建和读取时都先求键
const selected = 'minutes';
const note = { selected: 5, [selected]: 20 };
console.log(note.selected); // 5
console.log(note[selected]); // 20
console.log(note['minutes']); // 20点号后面的selected是字面属性名;方括号里的selected是一个表达式,先读变量得到字符串minutes,再找那个属性。两者访问的不是同一个键,只是代码里用了同一串字母。
数字键也会先成为字符串:
const record = { 1: '数字写法' };
record['1'] = '字符串写法';
console.log(record[1], Object.keys(record)); // 字符串写法 ['1']它没有保存两个“类型不同但显示一样”的键。对象作为键的问题也是同一路径:普通对象先转原始值,通常形成同样的文本,造成覆盖。Map用另一套键身份规则,本章集合篇会把这个需求接过去,而不是用更复杂的字符串拼接掩盖冲突。
不存在和存在但为 undefined
const note = { title: undefined };
console.log(note.title, note.summary); // undefined undefined
console.log(Object.hasOwn(note, 'title')); // true
console.log(Object.hasOwn(note, 'summary')); // false
console.log('toString' in note); // true:继承来的属性单看读取结果区分不了“没有这个属性”和“属性值刚好是 undefined”。Object.hasOwn 只检查自己的属性;in 还沿原型链检查继承属性。不要直接信任 obj.hasOwnProperty(...),对象可能有同名字段,也可能没有那个方法。
删除属性与赋 undefined 不是同一件事。删除后属性不再属于对象;若原型上存在同名属性,读取可能转而得到继承值。普通业务记录尽量让字段语义明确,复杂属性规则留到第四章。
可选链检查的是明确的一段
const note = { author: null };
console.log(note.author?.name ?? '匿名'); // 匿名
const missing = null;
console.log(missing?.author?.name); // undefined?. 在检查位置为 null/undefined 时短路。若 note 也可能为空,就不能只写 note.author?.name。括号可能中断连续链:(missing?.author).name 仍会报错。可选链不是异常捕获工具,也不能赋值给 note?.title。
可选调用 obj.run?.() 只允许方法缺失,不把数字这样的不可调用值变成函数。变量根本没有声明时,unknown?.name 也会抛 ReferenceError。
解构不是绑定到原属性的实时窗口
const note = { title: '对象', minutes: 0 };
const { title: heading, minutes = 10, summary = '待补充' } = note;
note.title = '数组';
console.log(heading, minutes, summary); // 对象 0 待补充
const { title, ...rest } = note;
console.log(rest); // { minutes: 0 }title: heading 把属性 title 的值放进局部变量 heading。缺省值只处理 undefined,不覆盖 0 或 null。剩余属性形成新对象,但嵌套值仍可能共享引用。解构读取得到的是当时的值,不会自动追踪原属性后续更新。
已有变量进行对象解构赋值时要包括号,例如 ({ title } = note);否则开头花括号可能被解析为代码块。嵌套解构要求中间对象存在,层层加缺省值虽然可行,但输入不稳定时先校验通常更清楚。
默认值不等于缺失检查,也不等于深层保护
let fallbackCalls = 0;
function fallback() {
fallbackCalls += 1;
return 10;
}
const { minutes: a = fallback() } = {};
const { minutes: b = fallback() } = { minutes: undefined };
const { minutes: c = fallback() } = { minutes: null };
const { minutes: d = fallback() } = { minutes: 0 };
console.log(a, b, c, d, fallbackCalls); // 10 10 null 0 2解构先读取属性,再判断读到的值是否为undefined。没有该属性与属性值为undefined都会启用默认值;null和0则保留。默认表达式也是惰性的,不需要它时不会执行。
const { author: { name } = {} } = note 的内层默认对象只在author为undefined时启用。如果author为null,仍无法解构。这与 note.author?.name ?? '匿名' 的空值保护不同:一个组织提取绑定,一个组织短路取值,选择应看实际允许的数据形状。
解构对象属性时还会正常执行属性读取,包括读取继承属性或访问器;它不是“只拿自己可枚举字段”的别名。剩余属性 ...rest 却只收集未被选中的自有可枚举属性,二者的取值范围不同。不要用一条“解构就是复制对象”概括整个语法。
数组解构按位置分配,对象解构按名字读取
接下来枚举对象会得到一对一对的键和值,例如 ['title', '数组']。先学会拆开这样的小数组,才能读懂后面的循环。方括号在右侧创建数组,在声明左侧则是提取模式:
const pair = ['title', '数组'];
const [key, value] = pair;
console.log(key, value); // title 数组
console.log(pair[0], pair[1]); // title 数组:原数组没有被拆毁对于这里的普通数组,可以先把它理解为依次读取位置0、1。变量可以叫任意名字,[value, key] 会把第一项交给value,并不会因为变量名而交换输入。对象解构 { title } 则按属性名找值,书写顺序不决定从哪个属性取。
跳项、缺省和剩余项分别负责什么
const row = ['JS', 'internal', undefined, '函数', '对象'];
const [title, , minutes = 10, ...topics] = row;
console.log(title, minutes, topics); // JS 10 ['函数', '对象']
const [zero = 10, empty = 10, missing = 10] = [0, null];
console.log(zero, empty, missing); // 0 null 10第二个逗号跳过位置1,仍继续消费那个位置;第三项读到undefined才使用默认值10。超出数组长度也得到undefined,因此missing用默认值,而0和null都保留。默认表达式只在需要时执行,与前面的对象解构一致。
...topics 将余下元素收集到一个新数组,必须在最后,后面也不能再加逗号;没有余项时得到空数组。新数组只复制元素值,元素若是对象,仍共享身份:
const note = { page: 1 };
const [first, ...rest] = [note, note];
rest[0].page = 2;
console.log(first.page, note.page); // 2 2声明新名字与给已有名字赋值
let left = '基础';
let right = '深入';
[left, right] = [right, left];
console.log(left, right); // 深入 基础右侧先把原来的right、left放进临时数组,再从左到右赋值,所以能交换。若连续写 left = right; right = left;,第一步已经丢失原来的left,两边会相同。这里没有let或const,是给已有绑定赋值;若重写 let [left, right] 就是在当前作用域重复声明,若left是const则不能重新赋值。
数组解构实际遵循迭代顺序,不要求输入一定是Array;第三章字符串和第六章迭代会继续说明。只有索引与length的普通对象不会自动变成可解构序列;null和undefined也不能用方括号解构。现在先使用明确的数组输入,不把它当作任意对象的数字键读取工具。
枚举方法各自选择不同的属性集合
| 方法 | 自有属性 | 继承属性 | Symbol | 不可枚举 |
|---|---|---|---|---|
Object.keys/values/entries | 是 | 否 | 否 | 否 |
for...in | 是 | 是 | 否 | 否 |
Object.getOwnPropertyNames | 是 | 否 | 否 | 是 |
Object.getOwnPropertySymbols | 是 | 否 | 是 | 是 |
Reflect.ownKeys | 是 | 否 | 是 | 是 |
const note = { title: '数组', minutes: 20 };
for (const [key, value] of Object.entries(note)) {
console.log(key, value);
}
const rebuilt = Object.fromEntries([['reading', 20], ['practice', 10]]);
console.log(rebuilt); // { reading: 20, practice: 10 }Object.entries 先把对象变成键值对数组;for...of 每次取出一对,[key, value] 再将这一对拆进两个局部名字。Object.fromEntries 走相反的组装方向,读取每对的第一项作键、第二项作值。fromEntries 可以接收 Symbol 键,而 entries 不会列出它们,往返不是对任意对象的无损操作。
普通对象自有键的常见顺序是:数组索引字符串升序,其他字符串按创建顺序,Symbol 按创建顺序。'10' 与 '2' 不一定按插入顺序出现;需要纯插入顺序的键值集合使用 Map。
浅复制只划开第一层
const original = { title: '对象', progress: { page: 1 } };
const copy = { ...original, title: '副本' };
copy.progress.page = 2;
console.log(original.title, original.progress.page); // 对象 2对象展开与 Object.assign 都会读取源对象自有可枚举属性,包含 Symbol;它们不保留属性描述符或原型。assign 写入已有目标,可能触发目标 setter;对象展开在新对象上定义属性,行为并不完全相同。需要复制的究竟是数据、访问器行为还是类实例,必须先区分;本章最后一篇专门比较克隆方式。
练习
为
{ count: undefined }判断 count 是否存在,解释为什么与obj.count !== undefined不同。把
{ title: 'JS', minutes: 0 }解构为name与缺省为 10 的minutes,预测结果。为什么note.selected与note[selected]可能不同?解构默认值对缺属性、undefined、null、0分别怎样处理,默认函数执行几次?
输入行是
['JS', 'internal', 0, '函数', '对象']。用数组解构跳过第二项,提取title、缺省为10的minutes与其余topics,再组装{ title, minutes, topics }。将输入换成['JS']和['JS', 'internal', null],写出预期值;不得让0或null意外变成10。