阅读 133

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

摘要: 从内存角度理解 let 和 const 的意义。

Fundebug经授权转载,版权归原作者所有。

这是专门探索 JavaScript 及其所构建的组件的系列文章的第 21 篇。

如果你错过了前面的章节,可以在这里找到它们:

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

  1. 为变量(myNumber)创建唯一标识符(identifier)。
  2. 在内存中分配一个地址(在运行时分配)。
  3. 将值 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内存地址上的值,从技术上讲,这是错的。当 abcd 拼接时,因为字符串也是 JS 中的基本数据类型,不可变的,所以需要分配一个新的内存地址,abcd 存储在这个新的内存地址中,myString 指向这个新的内存地址。

下一步是了解原始数据类型的内存分配位置。

JavaScript 内存模型:调用堆栈和堆

JS 内存模型可以理解为有两个不同的区域:调用堆栈(call stack)堆(heap)

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

在上图中,我抽象出了内存地址以显示每个变量的值。 但是,不要忘记实际上变量指向内存地址,然后保存一个值。 这将是理解 let vs. const 一节的关键。

是存储引用类型的地方。跟调用堆栈主要的区别在于,堆可以存储无序的数据,这些数据可以动态地增长,非常适合数组和对象。

JS 引用类型的变量声明和赋值

让我们从一个简单的例子开始。下面,我们声明一个名为myArray的变量,并用一个空数组初始化它。

let myArray = [];
复制代码

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

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

从这里,我们可以 push, pop,或对数组做任何我们想做的。

myArray.push("first");
myArray.push("second");
myArray.push("third");
myArray.push("fourth");
myArray.pop();
复制代码

代码部署后可能存在的 BUG 没法实时知道,事后为了解决这些 BUG,花了大量的时间进行 log 调试,这边顺便给大家推荐一个好用的 BUG 监控工具 Fundebug

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 风格指南中说,使用 constlet 声明所有本地变量。默认情况下使用 const,除非需要重新分配变量,不使用 var 关键字(原文)。

虽然他们没有明确说明原因,但据我所知,有几个原因

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

希望上面的解释能帮助你开始明白为什么或者什么时候应该在代码中使用 letconst

关于Fundebug

Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了10亿+错误事件,付费客户有Google、360、金山软件、百姓网等众多品牌企业。欢迎大家免费试用

版权声明

转载时请注明作者Fundebug以及本文地址: blog.fundebug.com/2019/04/15/…

关注下面的标签,发现更多相似文章
评论