- 原文地址:JavaScript’s Memory Model
- 原文作者:Ethan Nam
- 译者:Chor
// 声明一些变量并进行初始化
var a = 5
let b = 'xy'
const c = true
// 重新赋值
a = 6
b = b + 'z'
c = false // TypeError: Assignment to constant variable
对我们程序员来说,声明变量、进行初始化和赋值几乎是每天都在做的一件事情。不过,这些操作本质上做了什么事情呢?JavaScript 是如何在内部对这些进行处理的?更重要的是,了解 JavaScript 的底层细节对我们程序员有什么好处?
本文的大纲如下:
- JS 基本类型的变量声明和赋值
- JS 的内存模型:调用栈和堆
- JS 引用类型的变量声明和赋值
- Let vs const
JS 基本类型的变量声明和赋值
我们先从一个简单的例子讲起:声明一个名为 muNumber
的变量,并初始化赋值为 23。
let myNumber = 23
当执行这一行代码的时候,JS 将会 ......
- 为变量创建一个唯一的标识符(
myNumber
) - 在栈内存中分配一块空间(将在运行时完成分配)
- 将值 23 保存在这个分配出去的空间中
我们习惯的说法是“myNumber
等于23”,但更严谨的说法应该是,myNumber
等于保存着值 23 的那个内存空间的地址。这两者的区别很关键,需要搞清楚。
如果我们创建一个新变量 newVar
并将 myNumber
赋值给它 ......
let newVar = myNumber
...... 由于 myNumber
实际上等于内存地址 “0012CCGWH80”,因此这一操作会使得 newVar
也等于 “0012CCGWH80”,也就是等于保存着值 23 的那个内存地址。最终,我们可能会习惯说“newVar
现在等于 23 了”。
那么,如果我这样做会发生什么呢?
myNumber = myNumber + 1
myNumber
自然会“等于” 24,不过 newVar
和 myNumber
指向的可是同一块内存空间啊,newVar
是否也会“等于” 24 呢?
并不会。在 JS 中,基本数据类型是不可改变的,在 “myNumber + 1” 被解析为 “24” 的时候,JS 实际上将会在内存中重新分配一块新的空间用于存放 24 这个值,而 myNumber
将会转而指向这个新的内存空间的地址。
再看一个类型的例子:
let myString = 'abc'
myString = myString + 'd'
JS 初学者可能会认为,无论字符串 abc
存放在内存的哪个地方,这个操作都会将字符 d
拼接在字符串后面。这种想法是错误的。别忘了,在 JS 中字符串也是基本类型。当 abc
与 d
拼接的时候,在内存中会重新分配一块新的空间用于存放 abcd
这个字符串,而 myString
将会转而指向这个新的内存空间的地址(同时,abc
依然位于原先的内存空间中)。
接下来我们看一下基本类型的内存分配发生在哪里。
JS 的内存模型:调用栈和堆
简单理解,可以认为 JS 的内存模型包含两个不同的区域,一个是调用栈,一个是堆。
除了函数调用之外,调用栈同时也用于存放基本类型的数据。以上一小节的代码为例,在声明变量后,调用栈可以粗略表示如下图:
在上面这张图中,我对内存地址进行了抽象,以显示每个变量的值,但请记住,(正如之前所说的)变量始终指向某一块保存着某个值的内存空间。这是理解 let vs const 这一小节的关键。
再来看一下堆。
堆是引用类型变量存放的地方。堆相对于栈的一个关键区别就在于,堆可以存放动态增长的无序数据 —— 尤其是数组和对象。
JS 引用类型的变量声明和赋值
在变量声明与赋值这方面,引用类型变量与基本类型变量的行为表现有很大的差异。
我们同样从一个简单的例子讲起。下面声明一个名为 myArray
的变量并初始化为一个空数组:
let myArray = []
当你声明一个变量 myArray
并通过引用类型数据(比如 []
)为它赋值的时候,在内存中的操作是这样的:
- 为变量创建一个唯一的标识符(
myArray
) - 在堆内存中分配一块空间(将在运行时完成分配)
- 这个空间存放着此前所赋的值(空数组
[]
) - 在栈内存中分配一块空间
- 这个空间存放着指向被分配的堆空间的地址
我们可以对 myArray
进行各种数组操作:
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
变量是正确的,毕竟 sum
变量的值确实会改变;不过,用 let
声明 numbers
是错误的。而错误的根源在于,这些人认为往数组中添加元素是在改变它的值。
所谓的“改变”,实际上指的是内存地址的改变。let
声明的变量允许我们修改内存地址,而 const
则不允许。
const importantID = 489
importantID = 100 // TypeError: Assignment to constant variable
我们研究一下这里为什么会报错。
当声明 importantID
变量之后,某一块内存空间被分配出去,用于存放 489 这个值。牢记我们之前所说的,变量 importantID
从来只等于某一个内存地址。
当把 100 赋值给 importantID
的时候,由于 100 是基本类型的值,内存中会分配一块新的空间用于存放 100。之后,JS 试图将这块新空间的地址赋值给 importantID
,此时就会报错。这其实正是我们期望的结果,因为我们根本就不想对这个非常重要的 ID 进行改动 .......
这样就说得通了,用 let
声明数组是错误的(不合适的),应该用 const
才行。这对初学者来说确实比较困惑,毕竟这完全不符合直觉啊!初学者会认为,既然是数组肯定需要有所改动,而 const
声明的常量明明是不可改动的啊,那为何还要用 const
?不过,你必须得记住:所谓的“改变”指的是内存地址的改变。我们再来深入理解一下,为什么在这里使用 const
完全没问题,并且绝对是更好的选择。
const myArray = []
在声明 myArray
之后,调用栈会分配一块内存空间,它所存放的值是指向堆中某个被分配内存空间的地址。而堆中的这个空间才是实际上存放空数组的地方。看下面的图理解一下:
如果我们进行这些操作:
myArray.push(1)
myArray.push(2)
myArray.push(3)
myArray.push(4)
myArray.push(5)
这将会往堆中的数组添加元素。不过,myArray
的内存地址可是至始至终都没改变的。这也就解释了为什么 myArray
是用 const
声明的,但是对它(数组)的修改却不会报错。因为,myArray
始终等于内存地址 “0458AFCZX91”,该地址指向的空间存放着另一个内存地址 “22VVCX011”,而这第二个地址指向的空间则真正存放着堆中的数组。
如果我们这么做,则会报错:
myArray = 3
因为 3 是基本类型的值,这么做会在内存中分配一块新的空间用于存放 3,同时会修改 myArray
的值,使其等于这块新空间的地址。而由于 myArray
是用 const
声明的,这样修改就必然会报错。
下面这样做同样会报错:
myArray = ['a']
由于 [‘a’]
是一个新的引用类型的数组,因此在栈中会分配一块新的空间来存放堆中的某个空间地址,堆中这块空间则用于存放[‘a’]
。之后我们试图把新的内存地址赋值给 myArray
,这样显然也是会报错的。
对于用 const
声明的对象,它和数组的表现也是一样的。因为对象也是引用类型的数据,可以添加键,更新值,诸如此类。
const myObj = {}
myObj['newKey'] = 'someValue' // this will not throw an error
知道这些有什么用?
GitHub 和 Stack Overflow 年度开发者调查报告) 的相关数据显示,JavaScript 是排名第一的语言。精通这门语言并成为一名“JS 大师”可能是我们梦寐以求的。在任何一门像样的 JS 课程或者一本书中,都会倡导我们多使用 const
和 let
,少使用 var
,但他们基本上都没有解释这其中的缘由。很多初学者会疑惑为什么有些用 const
声明的变量在“修改”的时候确实会报错,而有些变量却不会。我能够理解,正是这种反直觉的体验让他们更喜欢随处都使用 let
,毕竟谁也不想踩坑嘛。
不过,这并不是我们推荐的方式。Google 作为一家拥有顶尖程序员的公司,它的 JavaScript 风格指南中就有这么一段话:用 const
或者 let
声明所有的局部变量。除非一个变量有重新赋值的需要,否则默认使用 const
进行声明。绝不允许使用 var
关键字 (来源)。
虽然他们没有指出个中缘由,不过我认为有下面这些理由:
- 预先避免将来可能产生的 bug
- 用
const
声明的变量在声明的时候就必须进行初始化,这会引导开发者关注这些变量在作用域中的表现,最终有助于促进更好的内存管理与性能表现。 - 带来更好的可读性,任何接管代码的人都能知道,哪些变量是不可修改的(就 JS 而言),哪些变量是可以重新赋值的。
希望本文能够帮助你理解使用 const
或者 let
声明变量的个中缘由以及应用场景。
参考:
- Google JS Style Guide
- Learning JavaScript: Call By Sharing, Parameter Passing
- How JavaScript works: memory management + how to handle 4 common memory leaks
交流
目前专注于前端领域和交互设计领域的学习,热爱分享和交流。感兴趣的朋友可以关注公众号,一起学习和进步。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。