JavaScript 中的内存管理是如何工作的

1,882 阅读6分钟

作者 | Andréas Hanss

来源 | JS IN PLAIN ENGLISH

链接 | How memory management works in JavaScript

老实说,我是在阅读整个文档之前尝试做些事情的人……大多数情况下,这样做都没什么问题,如果遇到问题,我会重新查看文档来寻找答案。

但是最近,我在使用 React 应用程序尝试 JavaScript 引用时遇到了一些问题,由于我的代码没有以可预测的方式进行更改,因此检测起来并不容易。

我以为已经掌握了这些东西,但是我是在很久之前阅读了文档,而我已经不太记得了。

这些问题对我帮助很大,我又重新学习了一些缺失的东西,这些东西可能很基础...即便我已经有多年的编程经验。 我想与大家分享一下学到的东西。

今天的这个问题是关于对 JavaScript 中的内存管理的理解。

JavaScript 内存管理

为了理解 JS 内存管理,我们需要记住两个规则,这些规则非常简单:

  • 基本类型(字符串,数字,布尔值)以副本的形式作为函数参数。
  • 对象以引用的形式作为函数参数
  • 函数和数组(null也一样,不过实际上它是不可变),在 JavaScript 中被视为对象

您只需在浏览器控制台中尝试使用 typeof [] 来验证,可以看到会打印出 object 。

在 JavaScript 中引用的工作方式以及如何解释

我们以下讨论的是 JavaScript 引擎中的内存管理,而不论框架和环境是什么,不过稍后我们会简单说明一下为什么在 ReactJS 和 hooks 中讨论这个会变得很乏味。

const someObject = { a: 5 };
const someArray = [1, 2];

你可能会将 const someObject = { a: 5 }; 理解为

someObject 是在 {a:5} 引用位置(内存位置)创建的

但实际上,应该理解为

someObject 变量引用了指向对象 {a:5} 的内存位置

上面的语句可以被分割成

| const someObject   | =                   | { a: 5 };
| const someArray    | =                   | [1, 2]
| variable reference | assignment operator | object Reference

更多解释可以查看 siwalikm/Smemory.md 和 rtablada/memory.md

现在我们有了基本知识,让我们看看为什么如果理解不清,可能会带来问题。

JavaScript闭包和引用

快速理解闭包

闭包是一个可以记住其外部变量并可以访问它们的函数。在JavaScript中,几乎所有函数自然而然都是闭包。

使用 JavaScript 时,您基本上都是在使用闭包和管理作用域,这样做就需要了解引用的工作方式。

在闭包内,有一个词法环境的概念,它包含不同范围的值。在这里我不会做太多的说明,如果您想了解更多,请在 此处 阅读更多信息。

基本类型的参数以副本的方式传递。以下是一个简单的示例。

在下面的示例中,修改 a 或 b 对作用域 2 内的所有 arg 都没有副作用。同样,在作用域 2 内修改 arg 对 a 或 b 也没有影响。

记住:作用域由 { } 来限定

// Scope 1
let a = 5;
let b = "hello";
function test(arg) {
  // Scope 2
  arg = "Something else";
}
test(a)
test(b)

**对象类型的参数以引用的方式来传递。**我们将通过带有修改和赋值的闭包来研究这一问题。

JavaScript 中的闭包赋值与修改

闭包和回调是 JavaScript 编程和事件驱动编程的核心,所以大多数时候你需要依赖它们。

在下面的示例中,有一个 NodeJS 片段,它将模仿向关注一个话题的用户显示消息的行为。但是这段代码有一些操作:

  • 在程序执行期间,订阅的用户将会变化。我每隔10秒使用 setInterval 函数模仿一次。有时会添加一个感兴趣的用户,有时会删除一个。
  • 每秒钟我们都会向订阅的用户发送随机消息。

在文件的末尾,有两个 setInterval 函数负责修改已订阅用户数组。我们通过 startListeningForMessages 闭包函数创建了一个变量范围。

让我们尝试运行一下代码。

随意使用您自己的 NodeJS 环境。

const events = require("events");

// Vars
let interestedUsers = [];
const goodPlanTopic = new events.EventEmitter();

function startListeneningForMessages(topic, listeningUsers) {
  // Each time a message is received, we sent a notification to user interest in our topic
  function onNewMessage(newMessage) {
    listeningUsers.forEach(user =>
      console.log("Sent message to [" + user + "] : " + newMessage),
    );
  }

  // Subscribe to topic
  topic.on("message", onNewMessage);

  // We return function used to cancel listener
  return () => topic.off("message", onNewMessage);
}

// Every second we have a new appearing message (pushed in emitter).
setInterval(() => {
  goodPlanTopic.emit(
    "message",
    "Text message N°" +
      Math.random()
        .toFixed(6)
        .substr(2),
  );
}, 1000);

// Start programm
startListeningForMessages(goodPlanTopic, interestedUsers); // We are passing our array

console.log("Started, wait 10 seconds before first event…");

// Every ten seconds, we add or remove a new random user to the interested users list

// First scenario by re-assigning value

// setInterval(() => {
//   if (Math.random > 0.5) {
//     console.log("New user will be removed (but not repercuted)");
//     interestedUsers = interestedUsers.splice(
//       Math.floor(Math.random() * interestedUsers.length),
//       1,
//     );
//   } else {
//     console.log("New user will be added (but not repercuted)");
//     interestedUsers = [].concat(interestedUsers).push(
//       "user" +
//         Math.random()
//           .toFixed(3)
//           .substr(2),
//     );
//   }
// }, 10000);

// Second scenario by mutating
setInterval(() => {
  if (Math.random > 0.5) {
    console.log("New user will be removed (and repercuted)");
    interestedUsers.splice(
      Math.floor(Math.random() * interestedUsers.length),
      1,
    );
  } else {
    console.log("New user will be added (and repercuted)");
    interestedUsers.push(
      "user" +
        Math.random()
          .toFixed(3)
          .substr(2),
    );
  }
}, 10000);

第一次先不注释第一个 setInterval 而注释第二个,接着反过来注释第一个而不注释第二个。

看到差别了么? ** 让我们来解释一下:

  • 在第一个 interval 中,什么都没有显示。那是因为我们要替换数组的引用。即使作为引用传递, listeningUsers 参数仍然指向旧的 interestedUsers 引用。这样,当旧的 interestedUsers 仍然存在时,就可以重新赋值 interestedUsers ,但是由于引用仍然存在,也无法在闭包范围之外访问,因此无法进行垃圾回收。这可能是内存泄漏,但就我们而言,情况甚至更糟,因为我们不再与外部的列表同步。
  • 在第二个 interval 中,情况发生了变化。那是因为我们正在使用 Array 对象中的变异方法。这样,引用不会改变,但数组中的元素会改变。这样,我们就不会通过闭包函数失去上下文。

这就是为什么您应该总是问自己,在使用超出范围的变量时是否应该对某些东西进行修改或重新分配并考虑副作用。

这也适用于对象!

对于几乎相同的对象,如果您在闭包函数中编辑对象的键值,那么没问题,因为你仍指向相同的引用。但是,如果您在函数内部重新赋值对象,则会丢失原来的引用。

那 React hooks 呢?

同样的事情,如果您使用 useState 且不更改引用,则在调用 setter 之后 Object.is 比较返回 false  时, useState 仅会导致重新渲染,并且您的 UI 不会更新。

这就是为什么我们通常在状态更改时要求使用 useEffect 和 clean 闭包作用域的原因,但是有时这样做是在取消订阅/重新订阅新值的时间段内,您可能会从事件侦听器中丢失一些事件。在有对象状态或数组状态时,它也非常重要。