A

JavaScript 基础知识全面讲解:内存、类型、转换与操作符

2025-12-24 16:28

语言特性本身没有对错,但代码的可维护性,取决于你是否清楚它在做什么。

JavaScript 数据类型

JavaScript 分为两大类型,分别是基本类型和引用类型。其中:

  • 基本类型(原始类型):NumberStringBooleanNullUndefinedSymbolBigInt
  • 引用类型(对象类型):Object 及其子类(ArrayFunctionDate 等)

它们分别有不同的存储方式和行为特征,其中:

  • 基本类型(原始类型):值直接存储在栈中,赋值为值拷贝,占用空间小,访问快。
  • 引用类型(对象类型):栈中保存的是“指向堆中对象”的引用(指针),赋值为引用拷贝,占用空间大,访问相对慢。

栈(Stack):数据的“储物格”

在计算机科学中,栈是一种特殊的内存空间。你可以把它想象成一个细长的桶(或者一叠盘子),它具有以下两个核心物理特征:

  1. 后进先出 (LIFO, Last In First Out):最后放进去的数据,最先被拿出来。
  2. 空间连续且固定:系统在编译时或函数运行时,会分配一块大小确定的连续内存给栈。

在 JavaScript 中,栈负责存储执行上下文(Execution Context),每个函数调用都会创建一个新的执行上下文,包含该函数的参数、局部变量、this 指针等信息。这样实现的好处在于:

  1. 函数调用天然是“后进先出”(LIFO)的结构

    • 函数调用和返回的顺序天然符合栈的特性:最后调用的函数最先返回。
    • 这和递归、嵌套调用完美匹配,避免了复杂的链表或队列管理。
  2. 作用域链和变量访问的实现依赖栈

    • 每个执行上下文都有自己的词法环境(Lexical Environment)。
    • 当查找变量时,JS 会从当前上下文开始,沿着作用域链向上查找(直到全局)。
    • 作用域链的形成正是基于调用栈的顺序:当前函数上下文 → 外层函数上下文 → … → 全局上下文。
    • 如果没有栈结构,作用域链的维护会变得异常复杂。
  3. 支持同步执行模型z

    • JavaScript 是单线程的,同一时刻只能执行一个上下文。
    • 栈结构保证了“一个函数没执行完,下一个函数不能抢占”的同步执行语义。

堆(Heap):对象的“大仓库”

相比之下,堆是一块巨大、动态、无序的内存区域,没有严格的进出顺序,由运行时动态分配和管理。

堆主要负责存储:

  • 所有引用类型的实际数据(如对象、数组、函数、Map、Set 等)
  • 那些大小不确定、生命周期较长或需要共享的数据

为什么要把对象放在堆里?

  • 对象的大小运行时才知道(可以随时加属性),无法在函数调用时预分配固定空间。
  • 对象可能被多个变量共享(引用),也可能被闭包长期持有,生命周期复杂。
  • 堆支持动态分配和垃圾回收(GC),完美适应 JavaScript 的动态特性。

栈里只存了一个指向堆中对象的引用(地址指针)。当你把对象赋值给另一个变量时,拷贝的只是这个指针,所以多个变量会指向同一个堆对象——这也是浅拷贝的根本原因。

赋值 vs 浅拷贝 vs 深拷贝

理解了栈和堆的内存模型后,你会发现“赋值”、“浅拷贝”与“深拷贝”的区别,本质上就是在内存中复制了什么的区别。

赋值

当你把一个对象赋值给另一个变量时,你仅仅是复制了栈中的引用地址(指针)。两个变量指向的是堆内存中同一个对象实体。

JS
const original = { name: "Arafat", colors: ["blue"] }; const copy = original; // 赋值 copy.name = "Tafara"; console.log(original.name); // "Tafara" —— 原对象被改了

浅拷贝

浅拷贝是指创建一个新对象,将原对象的属性值复制到新对象中。如果属性值是基本类型,直接复制值;如果属性值是引用类型,复制的是引用地址(指针),而不是对象本身。

JS
const original = { name: "Arafat", colors: ["blue"] }; const shallowCopy = { ...original }; // 浅拷贝1 // const shallowCopy = Object.assign({}, original); // 浅拷贝2 // 针对数组: Array.prototype.slice() 或 [...arr] shallowCopy.name = "Tafara"; console.log(original.name); // "Arafat" —— 原对象未被改 shallowCopy.colors.push("red"); console.log(original.colors); // ["blue", "red"] —— 原对象被改了

深拷贝

深拷贝是指创建一个新对象,递归地复制原对象的所有属性值,包括引用类型的属性。深拷贝后的对象与原对象完全独立,互不影响。

实现深拷贝的方法有很多种,比较常用的有:

  • JSON 序列化/反序列化:JSON.parse(JSON.stringify(obj))
  • 递归复制:手动实现一个递归函数,遍历对象的所有属性,对引用类型属性递归复制。
  • 库函数:如 Lodash 的 _.cloneDeep(obj)structuredClone(现代浏览器/Node 支持,最推荐)。

虽然 JSON.parse(JSON.stringify()) 是最简单的深拷贝方案,但它有三个著名的“坑”:

  • 丢失函数和 Undefined:对象里的函数、undefined 或 Symbol 会被直接抹除。
  • 循环引用会报错:如果 a.prop = a,它会抛出错误。
  • 特殊类型处理:Date 对象会变成字符串,RegExp 会变成空对象。

在生产环境中,如果对象非常复杂,建议使用 Lodash 或现代浏览器内置的 structuredClone()(这是 2022 年后主流浏览器原生支持的深拷贝 API)。

需要注意的是,深拷贝会消耗更多的内存和时间,因为需要递归复制所有属性。在实际开发中,要根据具体场景权衡是否需要深拷贝。

JavaScript 垃圾回收(GC)是如何清理这些不再需要的堆内存的

JavaScript 是自动内存管理语言,开发者无需手动 freedelete 对象。堆内存中那些不再被任何变量、闭包或 DOM 引用的对象,就是“垃圾”。垃圾回收器的任务就是定期找出这些垃圾并释放它们占用的内存。 现代 JavaScript 引擎(如 V8、SpiderMonkey、JavaScriptCore)几乎都采用同一种主流算法:标记-清除(Mark-and-Sweep),结合多种优化策略。下面我们一步步详细讲解它的工作原理。

标记阶段(Mark)

从一组“根对象”(Roots)开始遍历。

  • 常见的根对象包括:
    • 全局对象(浏览器中是 window,Node 中是 global 或 globalThis)
    • 当前调用栈上的所有变量(执行上下文中的局部变量)
    • DOM 树中的节点(因为 DOM 和 JS 相互引用)
  • 只要从根对象能够**可达(reachable)**的对象,都被标记为“存活”。
  • 无法从根对象到达的对象,就是“不可达”,视为垃圾。

清除阶段(Sweep)

  • 遍历整个堆内存,把没有被标记的对象内存释放掉。
  • 被释放的内存空间重新加入空闲列表,供后续分配使用。
TEXT
根对象(Roots) ├── 全局变量 obj1 → { name: "A" } ← 被标记为存活 ├── 全局变量 obj2 → { name: "B" } ← 被标记为存活 └── 孤立对象 → { name: "C" } ← 未被标记 → 垃圾 → 清除

为什么需要“从根对象开始”?

因为只有根对象是肯定不会被回收的(它们始终存在)。如果一个对象只能通过另一个即将被回收的对象到达,那它也应该被回收——这就是“可达性”原则。

现代引擎的优化:分代回收(Generational GC)

单纯的标记-清除有一个问题:每次 GC 都要遍历整个堆,对象越多越慢。V8 等引擎引入了分代回收思想,把对象分为两代(或更多):

  • 新生代
    • 大多数对象“生得快,死得也快”(生命周期短)。
    • 空间较小(几 MB 到几十 MB)。
    • 使用 Scavenge 算法(具体是 Cheney's 半空间拷贝算法):
      • 把新生代分成两个半区:From 空间和 To 空间。
      • 存活对象直接拷贝到 To 空间,From 空间直接清空。
      • 速度极快,通常只需几毫秒。
  • 老生代 - 存活时间长的对象(如全局对象、闭包捕获的变量、长生命周期缓存)。 - 空间较大。 - 主要使用标记-清除,并配合标记-整理(Mark-Compact): - 清除后把存活对象向一端移动,解决内存碎片问题。 **晋升机制:**对象在新生代经历几次 GC(通常 2 次)仍存活,就被晋升到老生代。

这种分代策略极大提高了 GC 效率:80%-90% 的对象在新生代就死了,根本不用去老生代扫描。

其他重要优化

  • 增量标记(Incremental Marking)
    • 把标记阶段拆成小任务,穿插在 JS 执行间隙中进行。
    • 减少单次 GC 造成的长时间暂停(Stop-The-World)。
  • 并发标记(Concurrent Marking)
    • V8 从 2018 年起支持在后台线程并发执行标记(Orinoco 项目)。
    • 主线程几乎不卡顿,用户体验更好。
  • 并行清除(Parallel Sweeping): 多线程并行清除垃圾。

常见导致内存泄漏的情况(对象本该回收却没被回收)

即使有自动 GC,错误代码仍可能造成内存泄漏:

JS
// 1. 意外的全局变量 function leak() { leaked = { bigData: new Array(1000000).fill("leak") }; // 没有 var/let/const,成了全局 } // 2. 闭包持有大对象 function createLeaker() { const big = new Array(1000000).fill("leak"); return function () { return big.length; }; // 内部函数引用 big,导 致 big 无法回收 } // 3. 忘记移除事件监听 element.addEventListener("click", hugeHandler); // 如果 element 被移除但监听没解绑,hugeHandler 可能仍存活 // 4. 定时器未清除 setInterval(() => { /* 引用大对象 */ }, 1000);

还有一个就是控制台引用了对象,导致对象不能被回收。这是因为这时该对象已被控制台引用所以无法被清除,如果打印多次内存占用会迅速飙升。

生产环境下一定要记得移除 console.log,不仅是为了性能,也是为了信息安全。

如何观察和调试 GC?

  • Chrome DevTools → Memory 面板
    • Heap Snapshot:查看当前堆中对象分布
    • Allocation instrumentation on timeline:查看内存分配时间线
    • Performance 面板:观察 GC 造成的暂停(长条红色区域)
TEXT
根对象(全局、栈、DOM) ↓ 可达性遍历 标记所有存活对象 清除未标记对象 → 释放内存 (老生代)整理碎片 内存重新可用

JavaScript 的垃圾回收器已经非常智能和高效,普通开发几乎无需关心。但理解它的原理,能帮助你:

  • 写出更少内存泄漏的代码
  • 解释闭包、事件监听等为什么会“占用内存”
  • 在性能优化时针对性处理大对象和长生命周期数据

数据类型详解

JavaScript 共有 8 种内置数据类型(ECMAScript 标准),它们被严格分为两大类:原始类型(Primitive Types,7 种)引用类型(Reference Types,1 种)

这种分类直接决定了它们在内存中的存储方式(栈 vs 堆)、赋值行为(值拷贝 vs 引用拷贝)、可变性(immutable vs mutable),也是后续类型转换和操作符行为的基础。

原始类型(Primitive Types)—— 7 种,不可变(immutable)

类型说明典型值示例常见特点与陷阱
Number双精度浮点数(IEEE 754 标准)42, 3.14, NaN, Infinity精度问题:0.1 + 0.2 !== 0.3(二进制无法精确表示某些小数)
StringUTF-16 编码的字符序列"hello", 'world', `template`不可变:任何“修改”操作都会创建新字符串,利于性能优化和字符串池复用
Boolean真假值true, false只有这两个值,没有“truthy/falsy”之分(后者是转换后的概念)
Undefined变量声明但未赋值undefined表示“缺少值”,通常由 JS 引擎自动赋予
Null有意表示“空值”或“无对象”nulltypeof null === "object" 是历史遗留 bug(1995 年实现错误,至今未修复以保持兼容)
SymbolES6 引入的唯一且不可变的值Symbol('id')主要用于对象属性名,避免命名冲突(如私有属性)
BigIntES2020 引入的任意精度整数9007199254740992n解决 Number 安全整数范围限制(±2^53-1)

为什么原始类型设计为不可变(immutable)?

  1. 性能优化:值固定不变,引擎可以安全复用(如字符串池 intern),减少内存分配。
    • 字符串池:JS 引擎会自动缓存常用字符串,避免重复创建。
  2. 线程/并发安全:即使未来 JS 支持多线程,原始值也不会产生竞争条件。
    • 原始值天然线程安全,因为线程 A 和 B 同时读取 a 的值, 后续进行修改必然会得到新的值;而引用类型不使用锁会出现丢失更新的问题。
    • 并发 + 共享 + 可变 ⇒ 竞态条件,只要三者同时成立,就一定会出问题。
    • 比如 js 中的 Web WorkerNode.js Worker Threads 它们之间无法直接共享 JS 对象,只能通过拷贝或显式共享(SharedArrayBuffer),而原始值 postMessage(1); 永远是安全的。
  3. 值语义清晰:赋值就是真正的“拷贝一份”,行为可预测,避免引用类型的副作用。
    • 引用类型赋值只是拷贝引用,而不是对象本身。

引用类型 —— 只有一种:Object

一切非原始类型都是 Object 的“子类”或实例,包括:

  • 普通对象 {}
  • 数组 []
  • 函数 function() {}
  • Date、RegExp、Map、Set、WeakMap、WeakSet 等

为什么 JavaScript 只有一种引用类型?

这是 JS 最核心的设计哲学之一:基于原型的动态语言,一切皆对象

  • 简化语言设计:不需要为每种复杂结构定义新类型,直接复用 Object 的原型链。
  • 极高的灵活性:运行时可以随时给对象添加/删除属性。
  • 统一的继承机制:所有对象都通过 proto 或 prototype 链继承。

可变性(mutable)带来的影响

引用类型默认是可变的,这带来强大表达力,也埋下副作用隐患。

JS
const arr = [1, 2]; arr.push(3); // 原数组被修改 arr.length = 0; // 清空原数组 const obj = { x: 1 }; obj.y = 2; // 随时添加属性 delete obj.x; // 随时删除属性

类型检测方法

typeof

typeof 是最基础的类型检测方法,返回一个表示值类型的字符串。

JS
typeof 1; // "number" typeof "x"; // "string" typeof null; // "object"(历史遗留) typeof undefined; // "undefined" typeof Symbol(); // "symbol" typeof 1n; // "bigint" typeof []; // "object" typeof (() => {}); // "function" typeof NaN // "number"

NaN 是 JavaScript 中非常特殊的一个值,全称是 Not-a-Number(非数字),它表示“一个本应为数字的操作结果不是有效数字”;

  • 比如 Number('abc') 就会返回 NaN
  • 它不等于任何值,包括自己 NaN !== NaN
  • 判断是否是 NaN 可以使用 isNaN() 函数,它会先调用 ToNumber() 转换值,再判断是否是 NaN; Number.isNaN(value) 是 ES6 新增的方法,它不会调用 ToNumber() 转换值,只判断是否是 NaN。

可以准确的检测原始类型和函数,但是对于引用类型和 null 都统一返回 "object"null 会返回 "object" 是历史遗留问题,JavaScript 最初由 Brendan Eich 在 1995 年开发,底层实现使用**类型标签(type tag)**表示值的类型:

  • 0 表示对象(object)
  • 1 表示布尔值(boolean)
  • 2 表示字符串(string)
  • 3 表示数字(number)
  • 4 表示未定义(undefined)
  • 5 表示函数(function) 在那个时期,null 在内存里被表示为一个空指针(null pointer),其二进制值是 0。 而当时实现 typeof 时,检查类型标签:
TEXT
类型标签 0 → "object" 类型标签 1 → "boolean" ...

结果就导致:

JS
typeof null === "object"; // true

后面想修复此问题时 JS 已经广泛部署到网页中,如果改会破坏大量依赖的现有代码,所以 ECMAScript 决定保持兼容性,历史遗留问题一直保留到今天

instanceof(基于原型链)

instanceof 判断一个对象是否是某个构造函数的实例,其原理是检查构造函数的 prototype 是否出现在对象的原型链上。

JS
[] instanceof Array( // true () => {} ) instanceof Function; // true new Date() instanceof Date; // true

Object.prototype.toString.call

该方法可以获取对象的内部类型标签([[Class]]Symbol.toStringTag),返回一个标准化字符串:

JS
Object.prototype.toString.call([]); // "[object Array]" Object.prototype.toString.call(null); // "[object Null]" Object.prototype.toString.call(undefined); // "[object Undefined]"

原理:

  • 规范要求 Object.prototype.toString 返回 "[object ]"
  • 如果对象有 Symbol.toStringTag,就返回该值,可以被对象自定义 Symbol.toStringTag 覆盖
  • 对内建对象,JavaScript 引擎维护默认类型标签,如 ArrayDateFunction 等。
  • 不依赖原型链,不受跨窗口影响
JS
const obj = { [Symbol.toStringTag]: 'GoldyAI' }; console.log(Object.prototype.toString.call(obj)) // [object GoldyAI]

Array.isArray —— 官方数组检测

JS
Array.isArray([]); // true Array.isArray({}); // false

检测数组时使用 Array.isArray,其他对象使用 toString.call。


隐式类型转换:JavaScript 的“双刃剑”

隐式类型转换是 JavaScript 最具争议的特性之一。作为一种弱类型(loosely typed)语言,JS 在操作不同类型的值时,会自动尝试将它们转换为兼容的类型。这种“便利”让初学者快速上手,但也制造了无数 bug。

为什么 JavaScript 有隐式类型转换?

  1. 设计初衷:JS 诞生于 1995 年的 Netscape 浏览器,旨在快速编写网页脚本(如表单验证)。弱类型让代码更简洁,例如 '1' + 1 直接输出 '11',而不需要手动转换(如 Java 中的 String.valueOf(1))
  2. 历史遗留:借鉴 Perl 和 C 的松散规则,但未预见大规模应用。ECMAScript 标准虽定义了转换规则,但这些规则复杂且不直观,导致如 [] == ![] 为 true 的“奇葩”结果。
  3. 权衡与代价:转换提供灵活性,但牺牲了类型安全。现代语言(如 TypeScript)通过静态类型系统修复了这个问题,强制开发者显式转换,减少了运行时错误。

核心概念:隐式转换发生在布尔上下文(if、&&)、数字操作(- * /)、字符串拼接(+)和松散比较(==)中。JS 使用三个抽象操作来实现:

  • ToPrimitive(value, hint):将非原始值转为原始值。hint 可以是 'number' 或 'string'(默认 'number')。如果 hint 是 'number',则先调用 valueOf(),如果失败再调用 toString()。如果 hint 是 'string',则先调用 toString(),如果失败再调用 valueOf()
  • ToNumber(value):转为数字。
  • ToString(value):转为字符串。
  • ToBoolean(value):转为布尔值。

ToPrimitive:转换的起点

当操作对象/数组时,JS 先调用 ToPrimitive 将其转为原始值:

  • 先尝试 valueOf() 方法(返回原始值)。
  • 如果失败,再试 toString()
  • 如果还是非原始,抛出错误。
JS
const obj = { valueOf: () => 42, toString: () => 'obj' }; console.log(obj + 1); // 43(valueOf 返回数字,优先用) console.log(obj + ''); // '42'(+ 优先字符串,但 valueOf 生效) const arr = []; console.log(arr + 1); // '1'(toString 返回 '',再转为数字 0 +1 但 + 优先拼接)

这样设计好处就在于不同的引用类型可以自定义转换逻辑(如 Date 的 valueOf 返回时间戳),这增强了扩展性。

ToNumber:转为数字的规则

  • String → Number:可解析数字字符串('123' → 123),否则 NaN。
  • Boolean → Number:true → 1, false → 0。
  • Null → 0, Undefined → NaN。
  • Object → 先 ToPrimitive(hint 'number'),再 ToNumber。
JS
console.log(Number('123')); // 123 console.log(Number('abc')); // NaN console.log(Number(true)); // 1 console.log(Number([])); // 0([] → '' → 0) console.log(Number({})); // NaN({} → '[object Object]' → NaN)

ToString:转为字符串的规则

  • Number → String:直接转换(42 → '42')。
  • Boolean → 'true' / 'false'。
  • Null → 'null', Undefined → 'undefined'。
  • Object → 先 ToPrimitive(hint 'string'),通常调用 toString()。
JS
console.log(String(42)); // '42' console.log(String(true)); // 'true' console.log(String([])); // '' console.log(String({})); // '[object Object]'

ToBoolean:转为布尔的规则(falsy 值)

所有值在布尔上下文中(如 if、&&、!!)都会隐式转为布尔。falsy 值有 6 个:false0-00""(空字符串),nullundefinedNaN,其他均为truthy。

JS
if ('0') console.log('truthy'); // 输出('0' 是非空字符串) if ([]) console.log('truthy'); // 输出(空数组是对象,非 falsy) if (0) console.log('falsy'); // 不输出

常见的场景与经典陷阱

+ 操作符:加法 vs 拼接

  • 如果一方是字符串 → 拼接(ToString)。
  • 否则 → 加法(ToNumber)。
JS
console.log(1 + '2'); // '12'(数字转字符串) console.log(1 + true); // 2(true → 1) console.log([] + {}); // '[object Object]'([] → '', {} → '[object Object]')

算术操作:- * / %

总是尝试 ToNumber。

JS
console.log('5' - 2); // 3 console.log('5' * '2'); // 10 console.log('abc' - 1); // NaN

松散比较 ==

双方类型不同时,按规则转换后比较。规则优先级是:

  • Null/Undefined:null == undefined 为 true。
  • Number vs String:将字符串转为数字后比较。
  • Object vs 非 Object:将对象转为原始值后比较。
JS
console.log('1' == 1); // true('1' → 1) console.log([] == ![]); // true(![] → false → 0, [] → 0) console.log(0 == false); // true console.log('' == 0); // true('' → 0) console.log({} == '[object Object]'); // false({} → '[object Object]', 但 == 不转换右边?实际双方都 ToNumber,失败为 false)

隐式转换是 JS 灵活性的体现,但规则繁杂(源于历史),易导致 bug。理解 ToPrimitive 等抽象操作后,你能预测大多数行为。记住:便利不等于可靠,优先使用显式转换和严格操作。记住以下几点可以合理规避隐式转换的坑:

  • 优先使用严格相等 ===:无隐式转换。
  • 优先使用显式转换:用 Number()、String()、Boolean() 或 !!。
  • 避免 + 的歧义:用模板字符串或 Number 转换。
  • 用 ESLint 规则:如 eqeqeq 强制 ===,no-implicit-coercion 禁用隐式转换。
  • TypeScript:静态类型检查,从源头杜绝。

最后

JavaScript 的很多设计,并不是“写得不好”,而是“背着历史往前走”。
一旦代码规模变大、生命周期变长,这些早期为了“方便”做出的妥协,就会逐渐转化为维护成本。

理解内存模型,你会知道什么时候该拷贝、什么时候该共享;
理解类型系统,你会知道哪些判断是可靠的,哪些只是“凑巧可用”;
理解隐式转换规则,你就不再依赖运气写代码。

这也是为什么现代 JavaScript 工程更强调:

  • 显式优于隐式
  • 约束优于自由
  • 可预测性优于“看起来能跑”

语言本身不会替你负责,但理解它的行为边界,可以让你少踩坑、少背锅。


如果你看到这里,谢谢你花时间阅读这篇小小的开篇文章。希望未来能与你在技术的旅途中有更多交流。