Skip to content

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,其执行步骤如下:

  1. 垃圾收集器找到所有的根,并“标记”它们。
  2. 遍历并标记来自根的所有引用。
  3. 遍历被标记对象,继续标记它们的引用。已遍历对象会被记住,避免死循环。
  4. 如此往复,直到所有可达的引用都被访问到。
  5. 最终,没有被标记的对象都将被删除。

我们可以结合图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); // 390

4. 将数值属性值都乘以 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 等方法能实现真正的克隆拷贝(浅拷贝/深拷贝)。
  • 可达性与垃圾回收:以全局变量或执行栈为“根”,顺着引用链路标记所有可达对象,切断传入引用的对象及“孤岛”会被“标记-清除”机制自动回收。
贡献者: weew12