JavaScript 基础知识全面讲解:内存、类型、转换与操作符
2025-12-24 16:28
语言特性本身没有对错,但代码的可维护性,取决于你是否清楚它在做什么。
JavaScript 数据类型
JavaScript 分为两大类型,分别是基本类型和引用类型。其中:
- 基本类型(原始类型):
Number、String、Boolean、Null、Undefined、Symbol、BigInt - 引用类型(对象类型):
Object及其子类(Array、Function、Date等)
它们分别有不同的存储方式和行为特征,其中:
- 基本类型(原始类型):值直接存储在栈中,赋值为值拷贝,占用空间小,访问快。
- 引用类型(对象类型):栈中保存的是“指向堆中对象”的引用(指针),赋值为引用拷贝,占用空间大,访问相对慢。
栈(Stack):数据的“储物格”
在计算机科学中,栈是一种特殊的内存空间。你可以把它想象成一个细长的桶(或者一叠盘子),它具有以下两个核心物理特征:
- 后进先出 (LIFO, Last In First Out):最后放进去的数据,最先被拿出来。
- 空间连续且固定:系统在编译时或函数运行时,会分配一块大小确定的连续内存给栈。
在 JavaScript 中,栈负责存储执行上下文(Execution Context),每个函数调用都会创建一个新的执行上下文,包含该函数的参数、局部变量、this 指针等信息。这样实现的好处在于:
-
函数调用天然是“后进先出”(LIFO)的结构
- 函数调用和返回的顺序天然符合栈的特性:最后调用的函数最先返回。
- 这和递归、嵌套调用完美匹配,避免了复杂的链表或队列管理。
-
作用域链和变量访问的实现依赖栈
- 每个执行上下文都有自己的词法环境(Lexical Environment)。
- 当查找变量时,JS 会从当前上下文开始,沿着作用域链向上查找(直到全局)。
- 作用域链的形成正是基于调用栈的顺序:当前函数上下文 → 外层函数上下文 → … → 全局上下文。
- 如果没有栈结构,作用域链的维护会变得异常复杂。
-
支持同步执行模型z
- JavaScript 是单线程的,同一时刻只能执行一个上下文。
- 栈结构保证了“一个函数没执行完,下一个函数不能抢占”的同步执行语义。
堆(Heap):对象的“大仓库”
相比之下,堆是一块巨大、动态、无序的内存区域,没有严格的进出顺序,由运行时动态分配和管理。
堆主要负责存储:
- 所有引用类型的实际数据(如对象、数组、函数、Map、Set 等)
- 那些大小不确定、生命周期较长或需要共享的数据
为什么要把对象放在堆里?
- 对象的大小运行时才知道(可以随时加属性),无法在函数调用时预分配固定空间。
- 对象可能被多个变量共享(引用),也可能被闭包长期持有,生命周期复杂。
- 堆支持动态分配和垃圾回收(GC),完美适应 JavaScript 的动态特性。
栈里只存了一个指向堆中对象的引用(地址指针)。当你把对象赋值给另一个变量时,拷贝的只是这个指针,所以多个变量会指向同一个堆对象——这也是浅拷贝的根本原因。
赋值 vs 浅拷贝 vs 深拷贝
理解了栈和堆的内存模型后,你会发现“赋值”、“浅拷贝”与“深拷贝”的区别,本质上就是在内存中复制了什么的区别。
赋值
当你把一个对象赋值给另一个变量时,你仅仅是复制了栈中的引用地址(指针)。两个变量指向的是堆内存中同一个对象实体。
JSconst original = { name: "Arafat", colors: ["blue"] }; const copy = original; // 赋值 copy.name = "Tafara"; console.log(original.name); // "Tafara" —— 原对象被改了
浅拷贝
浅拷贝是指创建一个新对象,将原对象的属性值复制到新对象中。如果属性值是基本类型,直接复制值;如果属性值是引用类型,复制的是引用地址(指针),而不是对象本身。
JSconst 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 是自动内存管理语言,开发者无需手动 free 或 delete 对象。堆内存中那些不再被任何变量、闭包或 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(二进制无法精确表示某些小数) |
| String | UTF-16 编码的字符序列 | "hello", 'world', `template` | 不可变:任何“修改”操作都会创建新字符串,利于性能优化和字符串池复用 |
| Boolean | 真假值 | true, false | 只有这两个值,没有“truthy/falsy”之分(后者是转换后的概念) |
| Undefined | 变量声明但未赋值 | undefined | 表示“缺少值”,通常由 JS 引擎自动赋予 |
| Null | 有意表示“空值”或“无对象” | null | typeof null === "object" 是历史遗留 bug(1995 年实现错误,至今未修复以保持兼容) |
| Symbol | ES6 引入的唯一且不可变的值 | Symbol('id') | 主要用于对象属性名,避免命名冲突(如私有属性) |
| BigInt | ES2020 引入的任意精度整数 | 9007199254740992n | 解决 Number 安全整数范围限制(±2^53-1) |
为什么原始类型设计为不可变(immutable)?
- 性能优化:值固定不变,引擎可以安全复用(如字符串池 intern),减少内存分配。
- 字符串池:JS 引擎会自动缓存常用字符串,避免重复创建。
- 线程/并发安全:即使未来 JS 支持多线程,原始值也不会产生竞争条件。
- 原始值天然线程安全,因为线程 A 和 B 同时读取 a 的值, 后续进行修改必然会得到新的值;而引用类型不使用锁会出现丢失更新的问题。
- 并发 + 共享 + 可变 ⇒ 竞态条件,只要三者同时成立,就一定会出问题。
- 比如 js 中的
Web Worker和Node.js Worker Threads它们之间无法直接共享 JS 对象,只能通过拷贝或显式共享(SharedArrayBuffer),而原始值postMessage(1);永远是安全的。
- 值语义清晰:赋值就是真正的“拷贝一份”,行为可预测,避免引用类型的副作用。
- 引用类型赋值只是拷贝引用,而不是对象本身。
引用类型 —— 只有一种:Object
一切非原始类型都是 Object 的“子类”或实例,包括:
- 普通对象
{} - 数组
[] - 函数
function() {} - Date、RegExp、Map、Set、WeakMap、WeakSet 等
为什么 JavaScript 只有一种引用类型?
这是 JS 最核心的设计哲学之一:基于原型的动态语言,一切皆对象。
- 简化语言设计:不需要为每种复杂结构定义新类型,直接复用 Object 的原型链。
- 极高的灵活性:运行时可以随时给对象添加/删除属性。
- 统一的继承机制:所有对象都通过 proto 或 prototype 链继承。
可变性(mutable)带来的影响
引用类型默认是可变的,这带来强大表达力,也埋下副作用隐患。
JSconst arr = [1, 2]; arr.push(3); // 原数组被修改 arr.length = 0; // 清空原数组 const obj = { x: 1 }; obj.y = 2; // 随时添加属性 delete obj.x; // 随时删除属性
类型检测方法
typeof
typeof 是最基础的类型检测方法,返回一个表示值类型的字符串。
JStypeof 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" ...
结果就导致:
JStypeof 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),返回一个标准化字符串:
JSObject.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 引擎维护默认类型标签,如
Array、Date、Function等。 - 不依赖原型链,不受跨窗口影响
JSconst obj = { [Symbol.toStringTag]: 'GoldyAI' }; console.log(Object.prototype.toString.call(obj)) // [object GoldyAI]
Array.isArray —— 官方数组检测
JSArray.isArray([]); // true Array.isArray({}); // false
检测数组时使用 Array.isArray,其他对象使用 toString.call。
隐式类型转换:JavaScript 的“双刃剑”
隐式类型转换是 JavaScript 最具争议的特性之一。作为一种弱类型(loosely typed)语言,JS 在操作不同类型的值时,会自动尝试将它们转换为兼容的类型。这种“便利”让初学者快速上手,但也制造了无数 bug。
为什么 JavaScript 有隐式类型转换?
- 设计初衷:JS 诞生于 1995 年的 Netscape 浏览器,旨在快速编写网页脚本(如表单验证)。弱类型让代码更简洁,例如
'1' + 1直接输出'11',而不需要手动转换(如 Java 中的String.valueOf(1))。 - 历史遗留:借鉴 Perl 和 C 的松散规则,但未预见大规模应用。ECMAScript 标准虽定义了转换规则,但这些规则复杂且不直观,导致如
[] == ![]为 true 的“奇葩”结果。 - 权衡与代价:转换提供灵活性,但牺牲了类型安全。现代语言(如 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()。 - 如果还是非原始,抛出错误。
JSconst 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。
JSconsole.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()。
JSconsole.log(String(42)); // '42' console.log(String(true)); // 'true' console.log(String([])); // '' console.log(String({})); // '[object Object]'
ToBoolean:转为布尔的规则(falsy 值)
所有值在布尔上下文中(如 if、&&、!!)都会隐式转为布尔。falsy 值有 6 个:false,0,-0,0,""(空字符串),null,undefined 和 NaN,其他均为truthy。
JSif ('0') console.log('truthy'); // 输出('0' 是非空字符串) if ([]) console.log('truthy'); // 输出(空数组是对象,非 falsy) if (0) console.log('falsy'); // 不输出
常见的场景与经典陷阱
+ 操作符:加法 vs 拼接
- 如果一方是字符串 → 拼接(ToString)。
- 否则 → 加法(ToNumber)。
JSconsole.log(1 + '2'); // '12'(数字转字符串) console.log(1 + true); // 2(true → 1) console.log([] + {}); // '[object Object]'([] → '', {} → '[object Object]')
算术操作:- * / %
总是尝试 ToNumber。
JSconsole.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:将对象转为原始值后比较。
JSconsole.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 工程更强调:
- 显式优于隐式
- 约束优于自由
- 可预测性优于“看起来能跑”
语言本身不会替你负责,但理解它的行为边界,可以让你少踩坑、少背锅。
如果你看到这里,谢谢你花时间阅读这篇小小的开篇文章。希望未来能与你在技术的旅途中有更多交流。