乐趣区

JavaScript 是如何工作的:JavaScript 的内存模型

这是专门探索 JavaScript 及其所构建的组件的系列文章的第 21 篇。
如果你错过了前面的章节,可以在这里找到它们:

JavaScript 是如何工作的:引擎,运行时和调用堆栈的概述!
JavaScript 是如何工作的:深入 V8 引擎 & 编写优化代码的 5 个技巧!
JavaScript 是如何工作的:内存管理 + 如何处理 4 个常见的内存泄漏!
JavaScript 是如何工作的:事件循环和异步编程的崛起 + 5 种使用 async/await 更好地编码方式!
JavaScript 是如何工作的:深入探索 websocket 和 HTTP/ 2 与 SSE + 如何选择正确的路径!
JavaScript 是如何工作的:与 WebAssembly 比较 及其使用场景!
JavaScript 是如何工作的:Web Workers 的构建块 + 5 个使用他们的场景!
JavaScript 是如何工作的:Service Worker 的生命周期及使用场景!
JavaScript 是如何工作的:Web 推送通知的机制!
JavaScript 是如何工作的:使用 MutationObserver 跟踪 DOM 的变化!
JavaScript 是如何工作的:渲染引擎和优化其性能的技巧!
JavaScript 是如何工作的:深入网络层 + 如何优化性能和安全!
JavaScript 是如何工作的:CSS 和 JS 动画底层原理及如何优化它们的性能!
JavaScript 是如何工作的:解析、抽象语法树(AST)+ 提升编译速度 5 个技巧!
JavaScript 是如何工作的:深入类和继承内部原理 +Babel 和 TypeScript 之间转换!
JavaScript 是如何工作的:存储引擎 + 如何选择合适的存储 API!
JavaScript 是如何工作的:Shadow DOM 的内部结构 + 如何编写独立的组件!
JavaScript 是如何工作的:WebRTC 和对等网络的机制!
JavaScript 是如何工作的:编写自己的 Web 开发框架 + React 及其虚拟 DOM 原理!
JavaScript 是如何工作的:模块的构建以及对应的打包工具

// 声明一些变量并初始化它们
var a = 5
let b = ‘xy’
const c = true

// 分配新值
a = 6
b = b + ‘z’
c = false // 类型错误: 不可对常量赋值

作为程序员,声明变量、初始化变量 (或不初始化变量) 以及稍后为它们分配新值是我们每天都要做的事情。
但是当这样做的时候会发生什么呢? JavaScript 如何在内部处理这些基本功能? 更重要的是,作为程序员,理解 JavaScript 的底层细节对我们有什么好处。
下面,我打算介绍以下内容:

JS 原始数据类型的变量声明和赋值
JavaScript 内存模型:调用堆栈和堆
JS 引用类型的变量声明和赋值

let vs const

JS 原始数据类型的变量声明和赋值
让我们从一个简单的例子开始。下面,我们声明一个名为 myNumber 的变量,并用值 23 初始化它。
let myNumber = 23

当执行此代码时,JS 将执行:

为变量 (myNumber) 创建唯一标识符(identifier)。
在内存中分配一个地址(在运行时分配)。
将值 23 存储在分配的地址。

虽然我们通俗地说,“myNumber 等于 23”,更专业地说,myNumber 等于保存值 23 的内存地址,这是一个值得理解的重要区别。
如果我们要创建一个名为 newVar 的新变量并把 myNumber 赋值给它。
let newVar = myNumber

因为 myNumber 在技术上实际是等于“0012CCGWH80”,所以 newVar 也等于“0012CCGWH80”,这是保存值为 23 的内存地址。通俗地说就是 newVar 现在的值为 23。

因为 myNumber 等于内存地址 0012CCGWH80,所以将它赋值给 newVar 就等于将 0012CCGWH80 赋值给 newVar。
现在,如果我这样做会发生什么:
myNumber = myNumber + 1

myNumber 的值肯定是 24。但是 newVar 的值是否也为 24 呢?,因为它们指向相同的内存地址?
答案是否定的。由于 JS 中的原始数据类型是不可变的,当 myNumber + 1 解析为 24 时,JS 将在内存中分配一个新地址,将 24 作为其值存储,myNumber 将指向新地址。

这是另一个例子:
let myString = ‘abc’
myString = myString + ‘d’

虽然一个初级 JS 程序员可能会说,字母 d 只是简单在原来存放 adbc 内存地址上的值,从技术上讲,这是错的。当 abc 与 d 拼接时,因为字符串也是 JS 中的基本数据类型,不可变的,所以需要分配一个新的内存地址,abcd 存储在这个新的内存地址中,myString 指向这个新的内存地址。

下一步是了解原始数据类型的内存分配位置。
JavaScript 内存模型:调用堆栈和堆
JS 内存模型可以理解为有两个不同的区域: 调用堆栈 (call stack) 和堆(heap)。

调用堆栈是存放原始数据类型的地方(除了函数调用之外)。上一节中声明变量后调用堆栈的粗略表示如下。

在上图中,我抽象出了内存地址以显示每个变量的值。但是,不要忘记实际上变量指向内存地址,然后保存一个值。这将是理解 let vs. const 一节的关键。
堆是存储引用类型的地方。跟调用堆栈主要的区别在于,堆可以存储无序的数据,这些数据可以动态地增长,非常适合数组和对象。
JS 引用类型的变量声明和赋值
让我们从一个简单的例子开始。下面,我们声明一个名为 myArray 的变量,并用一个空数组初始化它。
let myArray = []

当你声明变量“myArray”并为其指定非原始数据类型(如“[]”)时,以下是在内存中发生的情况:

为变量创建唯一标识符(“myArray”)
在内存中分配一个地址(将在运行时分配)
存储在堆上分配的内存地址的值(将在运行时分配)
堆上的内存地址存储分配的值(空数组[])

从这里,我们可以 push, pop,或对数组做任何我们想做的。
myArray.push(“first”)
myArray.push(“second”)
myArray.push(“third”)
myArray.push(“fourth”)
myArray.pop()

let vs const
一般来说,我们应该尽可能多地使用 const,只有当我们知道某个变量将发生改变时才使用 let。
让我们明确一下我们所说的“改变”是什么意思。
let sum = 0
sum = 1 + 2 + 3 + 4 + 5
let numbers = []
numbers.push(1)
numbers.push(2)
numbers.push(3)
numbers.push(4)
numbers.push(5)

这个程序员使用 let 正确地声明了 sum,因为他们知道值会改变。但是,这个程序员使用 let 错误地声明了数组 numbers,因为他将把东西推入数组理解为改变数组的值。
解释“改变”的正确方法是更改内存地址。let 允许你更改内存地址。const 不允许你更改内存地址。
const importantID = 489
importantID = 100 // 类型错误: 赋值给常量变量

让我们想象一下这里发生了什么。
当声明 importantID 时,分配了一个内存地址,并存储 489 的值。记住,将变量 importantID 看作等于内存地址。

当将 100 分配给 importantID 时,因为 100 是一个原始数据类型,所以会分配一个新的内存地址,并将 100 的值存储这里。
然后 JS 尝试将新的内存地址分配给 importantID,这就是抛出错误的地方,这也是我们想要的行为,因为我们不想改变这个 importantID 的值。

当你将 100 分配给 importantID 时,实际上是在尝试分配存储 100 的新内存地址,这是不允许的,因为 importantID 是用 const 声明的。
如上所述,假设的初级 JS 程序员使用 let 错误地声明了他们的数组。相反,他们应该用 const 声明它。这在一开始看起来可能令人困惑,我承认这一点也不直观。
初学者会认为数组只有在我们可以改变的情况下才有用,const 使数组不可变,那么为什么要使用它呢?请记住:“改变”是指改变内存地址。让我们深入探讨一下为什么使用 const 声明数组是完全可以的。
const myArray = []

在声明 myArray 时,将在调用堆栈上分配内存地址,该值是在堆上分配的内存地址。堆上存储的值是实际的空数组。想象一下,它是这样的:

如果我们这么做:
myArray.push(1)
myArray.push(2)
myArray.push(3)
myArray.push(4)
myArray.push(5)

执行 push 操作实际是将数字放入堆中存在的数组。而 myArray 的内存地址没有改变。这就是为什么虽然使用 const 声明了 myArray,但没有抛出任何错误。
myArray 仍然等于 0458AFCZX91,它的值是另一个内存地址 22VVCX011,它在堆上有一个数组的值。
如果我们这样做,就会抛出一个错误:
myArray = 3

由于 3 是一个原始数据类型,因此生成一个新的调用堆栈上的内存地址,其值为 3,然后我们将尝试将新的内存地址分配给 myArray,由于 myArray 是用 const 声明的,所以这是不允许的。

另一个会抛出错误的例子:
myArray = [‘a’]

由于 [a] 是一个新的引用类型的数组,因此将分配调用堆栈上的一个新内存地址,并存储堆上的一个内存地址的值,其它值为 [a]。然后,我们尝试将调用堆栈内存地址分配给 myArray,这会抛出一个错误。

对于使用 const 声明的对象(如数组),由于对象是引用类型,因此可以添加键,更新值等等。
const myObj = {}
myObj[‘newKey’] = ‘someValue’ // 这不会抛出错误

为什么这些知识对我们有用呢
JavaScript 是世界上排名第一的编程语言(根据 GitHub 和 Stack Overflow 的年度开发人员调查)。掌握并成为“JS 忍者”是我们所有人都渴望成为的人。
任何质量好的的 JS 课程或书籍都提倡使用 let, const 来代替 var,但他们并不一定说出原因。对于初学者来说,为什么某些 const 变量在“改变”其值时会抛出错误而其他 const 变量却没有。对我来说这是有道理的,为什么这些程序员默认使用 let 到处避免麻烦。
但是,不建议这样做。谷歌拥有世界上最好的一些程序员,在他们的 JavaScript 风格指南中说,使用 const 或 let 声明所有本地变量。默认情况下使用 const,除非需要重新分配变量,不使用 var 关键字(原文)。
虽然他们没有明确说明原因,但据我所知,有几个原因

先发制人地限制未来的 bug。
使用 const 声明的变量必须在声明时初始化,这迫使程序员经常在范围方面更仔细地放置它们。这最终会导致更好的内存管理和性能。
要通过代码与任何可能遇到它的人交流,哪些变量是不可变的(就 JS 而言),哪些变量可以重新分配。

希望上面的解释能帮助你开始明白为什么或者什么时候应该在代码中使用 let 和 const。
你的点赞是我持续分享好东西的动力,欢迎点赞!
欢迎加入前端大家庭,里面会经常分享一些技术资源。

退出移动版