DRAFT 《现代 JavaScript 教程》前端基础系列 06:对象基石——基础语法、引用复制与垃圾回收机制
约 4681 字大约 16 分钟
2026-04-17
在 JavaScript 中,对象是几乎所有特性的核心基础。本篇文章将带你深入理解 JavaScript 对象的本质,从基础的键值对语法、动态属性操作,到对象独有的“引用复制”特性,最后深度剖析 V8 引擎底层的自动垃圾回收(Garbage Collection)机制与可达性原理,为你夯实最核心的底层语言认知。
【本篇核心收获】
- 掌握对象的基础语法、属性操作(点符号与方括号)、计算属性及属性简写
- 深刻理解对象作为“引用类型”的内存存储与赋值复制机制
- 学会使用
Object.assign与深浅克隆方案处理对象拷贝 - 掌握
"in"操作符与"for..in"循环遍历的内部排序规则 - 深度剖析 JavaScript 垃圾回收机制中的“可达性”原理及标记清除(mark-and-sweep)算法
对象基础语法与属性操作
在 JavaScript 中,有七种原始类型的值只包含一种数据,而对象则用来存储键值对和更复杂的实体。对象几乎渗透到了这门语言的方方面面,是我们深入理解 JavaScript 必须跨越的第一步。
对象的创建
我们可以通过带有可选属性列表的花括号 {…} 来创建对象。一个属性就是一个键值对(key: value),其中键(key,也叫属性名)是一个字符串,值(value)可以是任何值。
我们可以把对象想象成一个带有签名文件的文件柜。每一条数据都基于键存储在文件中,这样就可以根据“键”轻松查找、添加或删除文件。
创建空对象有两种语法:
let user = new Object(); // "构造函数" 的语法
let user = {}; // "字面量" 的语法通常我们更倾向于使用花括号 {},这种方式被称为字面量语法。
文本和属性
我们可以在创建对象时,立即将一些属性以键值对的形式放到 {...} 中:
let user = { // 一个对象
name: "John", // 键 "name",值 "John"
age: 30 // 键 "age",值 30
};属性的键位于冒号 : 的前面,值在冒号的右边。生成的 user 对象可以被想象为一个放置着两个标记有 "name" 和 "age" 文件的柜子,如图3所示。
我们可以随时添加、删除和读取文件。使用点符号访问属性值:
// 读取文件的属性:
alert( user.name ); // John
alert( user.age ); // 30属性的值可以是任意类型,比如增加一个布尔类型:
user.isAdmin = true;可以使用 delete 操作符移除属性:
delete user.age;我们也可以用多词语来作为属性名,但必须给它们加上引号:
let user = {
name: "John",
age: 30,
"likes birds": true // 多词属性名必须加引号
};💡 提示:尾随逗号
列表中的最后一个属性应以逗号结尾。这叫做尾随(trailing)或悬挂(hanging)逗号。这样可以保证所有的行结构相似,非常便于后续添加、删除和移动属性。
方括号与计算属性
对于多词属性,点操作就失效了(例如 user.likes birds = true 会提示语法错误)。点符号要求键是有效的变量标识符(不含空格,不以数字开头,不含特殊字符,允许使用 $ 和 _)。
这时我们可以使用方括号,它可用于任何字符串:
let user = {};
// 设置
user["likes birds"] = true;
// 读取
alert(user["likes birds"]); // true
// 删除
delete user["likes birds"];方括号还可以通过任意表达式或变量来动态获取属性名,这赋予了代码极大的灵活性:
let user = {
name: "John",
age: 30
};
let key = prompt("What do you want to know about the user?", "name");
// 访问变量
alert( user[key] ); // John(如果输入 "name")⚠️ 避坑指南:点符号不支持变量
如果使用点符号user.key,程序会去寻找字面量名为"key"的属性,而不会将其解析为变量,最终会返回undefined。
计算属性
在对象字面量中创建对象时,可以使用方括号,这被称为计算属性。其含义是属性名应该从变量或表达式中获取:
let fruit = prompt("Which fruit to buy?", "apple");
let bag = {
[fruit]: 5, // 属性名是从 fruit 变量中得到的
};
alert( bag.apple ); // 5 如果 fruit="apple"我们甚至可以在方括号中使用更复杂的表达式,例如 [fruit + 'Computers']: 5。通常,当属性名已知且简单时使用点符号;需要动态或复杂内容时使用方括号。
属性值简写与名称限制
在实际开发中,我们经常使用已存在的变量作为属性名。为此,JavaScript 提供了一种特殊的属性值简写方法:
function makeUser(name, age) {
return {
name, // 与 name: name 相同
age, // 与 age: age 相同
// ...
};
}我们可以混合使用简写与常规方式,如 { name, age: 30 }。
属性名称限制
普通变量不能是语言的保留字(如 "for"、"let" 等),但对象的属性名不受此限制:
// 这些属性都没问题
let obj = {
for: 1,
let: 2,
return: 3
};
alert( obj.for + obj.let + obj.return ); // 6属性名可以是任何字符串或者 Symbol。如果使用其他类型,它们会被自动转换为字符串。例如,数字 0 作为键时等同于 "0"。
⚠️ 避坑指南:特殊的
__proto__属性
对象中存在一个名为__proto__的历史遗留特殊属性。你不能将其设置为非对象的值,赋值为诸如数字5的操作将被直接忽略,它仍会保持为[object Object]。
属性存在性测试:"in" 操作符
JavaScript 对象的一个特点是:即使读取不存在的属性也不会报错,只会得到 undefined。判断属性是否存在的最可靠方法是使用 "in" 操作符:
"key" in object使用示例:
let user = { age: 30 };
let key = "age";
alert( key in user ); // true,属性 "age" 存在
alert( "blabla" in user ); // false,不存在💡 提示:为何需要
"in"操作符?
大多数情况用!== undefined比较即可。但如果属性存在,且恰好被赋值为undefined,普通的比对就会失效。此时只有"in"操作符能准确判定该属性实际存在于对象中。
"for..in" 循环与排序规则
为了遍历对象的所有键,可以使用特殊的循环语法:for..in。
let user = {
name: "John",
age: 30,
isAdmin: true
};
for (let key in user) {
// 属性名
alert( key ); // name, age, isAdmin
// 属性键的值
alert( user[key] ); // John, 30, true
}像对象一样排序
对象属性的遍历是有顺序的,规则是:整数属性会被升序排序,其他属性则按照创建的顺序显示。
这里的“整数属性”是指可以在不做任何更改的情况下与整数进行相互转换的字符串。例如 "49" 是整数属性,而 "+49" 或 "1.2" 则不是。
如果我们希望属性严格按照创建顺序输出,可以给整数属性添加一个 "+" 前缀来“欺骗”程序,打破其自动排序规则。
模块小结:对象是存储键值对的集合。我们可以使用点符号和方括号读写属性,结合计算属性、简写语法进行灵活声明。同时,
"in"操作符和for..in循环为判断和遍历对象属性提供了原生支持。
对象引用和复制
对象与原始类型的根本区别之一是:对象是**“通过引用”**存储和复制的,而原始类型(字符串、数字、布尔值等)总是“作为一个整体”复制。
引用赋值的本质
对于原始类型,赋值会产生两个完全独立的变量,如图7所示:
let message = "Hello!";
let phrase = message;但是,对象并非如此。赋值了对象的变量存储的不是对象本身,而是该对象“在内存中的地址”——即对该对象的“引用”。
let user = {
name: "John"
};该对象被存储在内存中的某个位置,而变量 user 保存的是指向它的“引用”,如图8所示。
当一个对象变量被复制时,仅仅复制了引用,该对象自身并没有被复制。
let user = { name: "John" };
let admin = user; // 复制引用如图9所示,现在有两个变量,它们保存的都是对同一个对象的引用。这就像我们有两把钥匙开同一个柜子。
通过任何一个引用进行的修改,都会影响到原对象:
admin.name = 'Pete'; // 通过 "admin" 引用来修改
alert(user.name); // 'Pete',修改能通过 "user" 引用看到💡 提示:通过引用来比较
仅当两个对象为同一对象(引用的内存地址相同)时,它们才相等(==或===)。两个独立创建的空对象{},即使看起来一模一样,它们也是不相等的。
⚠️ 避坑指南:使用
const声明的对象可以被修改
声明为const user = { name: "John" };的对象,其属性user.name = "Pete"是允许被修改的。const仅限制我们不能将user = ...作为一个整体进行重新赋值。
克隆与合并,Object.assign
如果我们需要创建一个完全独立的相同对象(拷贝结构),可以通过遍历属性并在原始类型层面赋值来实现:
let user = { name: "John", age: 30 };
let clone = {}; // 新的空对象
for (let key in user) {
clone[key] = user[key];
}更好的方式是使用原生的 Object.assign 方法:
Object.assign(dest, [src1, src2, src3...])- 第一个参数
dest是目标对象。 - 后面的参数
src1...srcN是源对象,会将它们的所有属性拷贝到目标对象中。 - 如果属性名存在冲突,后传入的值会覆盖前面的值。
可以简单地用来拷贝对象:
let clone = Object.assign({}, user);深层克隆
如果对象的属性引用了其他对象(比如嵌套对象),那么 Object.assign 只会进行“浅拷贝”,嵌套对象的引用依然被共享:
let user = {
name: "John",
sizes: { height: 182, width: 50 }
};
let clone = Object.assign({}, user);
alert( user.sizes === clone.sizes ); // true,依然是同一个对象要解决这个问题并彻底独立,需要使用一个拷贝循环,遇到属性是对象时递归复制其结构。这种技术被称为“深拷贝”。在实际开发中,我们通常使用 lodash 库的 cloneDeep(obj) 方法来避免重复造轮子。
模块小结:对象作为引用类型,赋值时仅拷贝内存地址,修改副本将影响原数据。如果要获得独立拷贝,需要使用
Object.assign进行浅克隆,或通过递归实现深克隆。
垃圾回收
对于开发者来说,JavaScript 的内存管理是自动且无形的。所有的原始值、对象、函数都会占用内存。当我们不再需要某些数据时,JavaScript 引擎的垃圾收集器(Garbage Collector)会在后台监控状态,并自动删除已不可达的对象。
可达性(Reachability)
JavaScript 内存管理的核心概念是可达性。“可达”值是指那些以某种方式可访问或可用的值。
有一些固有的可达值无法被释放,被称为根(roots),包括:
- 当前执行的函数,及其局部变量和参数
- 当前嵌套调用链上的其他函数、局部变量和参数
- 全局变量等
如果一个值可以从根通过引用或者引用链进行访问,则认为该值是可达的。
来看一个简单例子,全局变量 "user" 引用了对象 {name: "John"},如图10所示。
如果我们将 user 的值重写:
user = null;如图11所示,对象丢失了唯一的引用,变成了不可达。垃圾回收器会认为它是垃圾数据并释放内存。
但如果同时有多个引用指向它:
let user = { name: "John" };
let admin = user;如图12所示,此时即使执行 user = null;,对象仍然可通过 admin 访问,因此它必须被保留在内存中。
相互关联的对象与孤岛
考虑一个更加复杂的相互引用的家庭结构,如图13所示:
如果我们移除对 "John" 对象的两个引用:
delete family.father;
delete family.mother.husband;如果仅仅删除其中一个,对象依然可达(图14);但是两个都删除后,"John" 彻底断开了所有的外部传入引用(图15)。
对外引出多少引用并不重要,只有传入引用才能使对象保持可达。此时 John 对象将被删除(图16)。
⚠️ 避坑指南:无法到达的岛屿
如果我们将根部的family = null;断开,即使内部的各个对象仍在相互引用,但由于缺乏从外部“根”进来的链路,它们将形成一座“孤岛”,并被整体作为垃圾清理出内存,如图17所示。
内部算法:标记和清除(Mark-and-Sweep)
垃圾回收的基本算法是 mark-and-sweep,其执行步骤如下:
- 垃圾收集器找到所有的根,并“标记”它们。
- 遍历并标记来自根的所有引用。
- 遍历被标记对象,继续标记它们的引用。已遍历对象会被记住,避免死循环。
- 如此往复,直到所有可达的引用都被访问到。
- 最终,没有被标记的对象都将被删除。
我们可以结合图18至图22演示整个过程:右侧有个明显的“孤岛”结构。
首先标记所有的根(图19)。
顺着引用标记下一层对象(图20)。
继续遍历直至全部可达对象均被标记(图21)。
未被标记的对象被判定为不可达垃圾并被清除(图22)。
JavaScript 引擎的垃圾回收优化
为了不造成代码执行的卡顿,现代引擎实施了许多优化策略:
- 分代收集(Generational collection):对象分为“新”和“旧”。生命周期短的对象很快被清除,存活时间长的对象检查频率会降低。
- 增量收集(Incremental collection):将大对象集拆分为多部分逐一清除,分散单次大回收带来的延迟。
- 闲时收集(Idle-time collection):收集器仅在 CPU 空闲时尝试运行,大幅减少对执行流的影响。
模块小结:JavaScript 基于“可达性”自动管理内存。以全局变量或执行上下文作为“根”出发,“标记和清除”算法负责追踪有效链路并批量回收不可达的孤岛结构,而引擎底层做了大量的性能优化保证运行流畅。
动手实战:对象基础任务解析
为了巩固对对象的认知,以下提供了4个典型的基础实操任务:
1. 你好,对象
按要求依次创建空对象、增加属性、修改属性、删除属性:
let user = {};
user.name = "John";
user.surname = "Smith";
user.name = "Pete";
delete user.name;2. 检查空对象
编写 isEmpty(obj) 函数,当对象没有属性时返回 true:
function isEmpty(obj) {
for (let key in obj) {
// 只要能进入循环,说明存在属性
return false;
}
return true;
}3. 对象属性求和
写代码求出团队对象工资的总和并保存到 sum:
let salaries = {
John: 100,
Ann: 160,
Pete: 130
};
let sum = 0;
for (let key in salaries) {
sum += salaries[key];
}
alert(sum); // 3904. 将数值属性值都乘以 2
创建 multiplyNumeric(obj) 函数就地修改对象中的数值属性:
function multiplyNumeric(obj) {
for (let key in obj) {
if (typeof obj[key] == 'number') {
obj[key] *= 2;
}
}
}【本篇核心知识点速记】
- 对象的创建与读写:推荐使用字面量
{};通过点符号obj.prop或动态方括号obj["prop"]操作属性。 - 属性命名与特殊语法:属性名无保留字限制;支持计算属性
[变量名]: value及简写属性prop。 - 属性的存在与遍历:使用
"key" in obj准确判断属性存在性;通过for..in循环遍历(牢记整数属性按升序排列,其他按创建顺序排列)。 - 对象引用机制:对象赋值仅传递“内存地址引用”,共享原对象。只有
Object.assign等方法能实现真正的克隆拷贝(浅拷贝/深拷贝)。 - 可达性与垃圾回收:以全局变量或执行栈为“根”,顺着引用链路标记所有可达对象,切断传入引用的对象及“孤岛”会被“标记-清除”机制自动回收。
