[译] 5个常见的JavaScript内存错误

284 阅读8分钟

题目:5 Common JavaScript Memory Mistakes
作者:Jose Granja
本文翻译自:betterprogramming.pub/5-common-ja…

Photo by Possessed Photography on Unsplash.

JavaScript不提供任何内存管理原语。而是由JavaScript VM通过内存回收过程来管理内存。该过程称为垃圾收集。
由于我们不能强迫它运行,我们如何知道它会正常运行?我们对此有什么了解?

  • 在此过程中,脚本执行被暂停。
  • 它释放内存以获取无法访问的资源。
  • 这是不确定的。
  • 它不会一次性检查整个内存,而是会运行多个循环。
  • 这是不可预测的。必要时将执行。 这是否意味着我们不必担心资源和内存分配?当然不是。如果不小心,可能会造成一些内存泄漏。

什么是内存泄漏?

内存泄漏是软件无法回收的已分配内存。
Javascript为你提供了垃圾回收过程,并不意味着你可以避免内存泄漏。为了有资格进行垃圾回收,不得在其他地方引用该对象。如果你保留对未使用资源的引用,则将防止这些资源被未分配。这就是所谓的无意识内存保留。
内存泄漏可能会导致更频繁地运行垃圾回收器。由于此过程将阻止脚本运行,因此可能会降低Web应用速度。这将使它不那么活泼,这将被用户注意到。它甚至可能导致你的Web应用程序崩溃。

我们如何防止我们的Web应用程序泄漏内存?很简单:避免保留不必要的资源。让我们看一下可能发生这种情况的最常见情况。

1.计时器监听器

让我们看看setInterval计时器。它是常用的Web API功能。

The setInterval() method, offered on the Window and Worker interfaces, repeatedly calls a function or executes a code snippet, with a fixed time delay between each call. It returns an interval ID which uniquely identifies the interval, so you can remove it later by calling clearInterval(). This method is defined by the WindowOrWorkerGlobalScope mixin.” — MDN Web Docs

让我们创建一个调用回调函数的组件,以通知其在x循环后完成。我在这个特定示例中使用React,但这适用于任何FE框架。

import React, { useRef } from 'react';

const Timer = ({ cicles, onFinish }) => {
    const currentCicles = useRef(0);

    setInterval(() => {
        if (currentCicles.current >= cicles) {
            onFinish();
            return;
        }
        currentCicles.current++;
    }, 500);

    return (
        <div>Loading ...</div>
    );
}

export default Timer;

起初,看来没有什么是错的。让我们创建一个触发该计时器并分析其内存性能的组件:

import React, { useState } from 'react';
import styles from '../styles/Home.module.css'
import Timer from '../components/Timer';

export default function Home() {
    const [showTimer, setShowTimer] = useState();
    const onFinish = () => setShowTimer(false);

    return (
      <div className={styles.container}>
          {showTimer ? (
              <Timer cicles={10} onFinish={onFinish} />
          ): (
              <button onClick={() => setShowTimer(true)}>
                Retry
              </button>
          )}
      </div>
    )
}

单击retry按钮几次后,这就是我们使用Chrome开发工具获得的内存使用量的结果:

你可以看到点击retry按钮时分配的内存越来越多。这意味着先前分配的内存没有被释放。间隔计时器仍在运行而不是被替换。
我们该如何解决?返回的setInterval是一个间隔ID,我们可以使用它来取消间隔。在这种特定情况下,我们可以在组件卸载后调用clearInterval

useEffect(() => {
    const intervalId = setInterval(() => {
        if (currentCicles.current >= cicles) {
            onFinish();
            return;
        }
        currentCicles.current++;
    }, 500);

    return () => clearInterval(intervalId);
}, [])

有时,很难在代码审查中发现这些问题。最佳实践是创建可以管理所有复杂性的抽象。
当我们在这里使用React时,我们可以将所有这些逻辑包装在一个自定义的Hook中:

import { useEffect } from 'react';

export const useTimeout = (refreshCycle = 100, callback) => {
    useEffect(() => {
        if (refreshCycle <= 0) {
            setTimeout(callback, 0);
            return;
        }

        const intervalId = setInterval(() => {
            callback();
        }, refreshCycle);

        return () => clearInterval(intervalId);
    }, [refreshCycle, setInterval, clearInterval]);
};

export default useTimeout;

现在,每当你需要使用setInterval时,你都可以执行以下操作:

const handleTimeout = () => ...;
useTimeout(100, handleTimeout);

现在,你可以使用此useTimeout钩子,并且不必担心内存泄漏,所有这些都由抽象管理的。

2.事件监听器

Web API提供了许多事件侦听器,你可以将其链接到自己需要的地方。上文,我们介绍了setTimeout。现在我们来看看addEventListener

让我们为我们的Web应用程序创建键盘快捷键功能。由于我们在不同页面上具有不同的功能,因此我们将创建不同的快捷方式功能:

function homeShortcuts({ key}) {
    if (key === 'E') {
        console.log('edit widget')
    }
}

// user lands on home and we execute
document.addEventListener('keyup', homeShortcuts); 


// user does some stuff and navigates to settings

function settingsShortcuts({ key}) {
    if (key === 'E') {
        console.log('edit setting')
    }
}

// user lands on home and we execute
document.addEventListener('keyup', settingsShortcuts); 

一切似乎都很好,只是我们在执行第二个addEventListener事件时没有清理前一个keyup事件。代替换我们的keyup监听事件,此代码将添加另一个callback回调函数。这意味着当按下一个键时,它都会触发这两个功能。

要清除上一个回调,我们需要使用removeEventListener。让我们看一个代码示例:

document.removeEventListener(‘keyup’, homeShortcuts);

让我们重构代码以防止这种不良行为:

function homeShortcuts({ key}) {
    if (key === 'E') {
        console.log('edit widget')
    }
}

// user lands on home and we execute
document.addEventListener('keyup', homeShortcuts); 


// user does some stuff and navigates to settings

function settingsShortcuts({ key}) {
    if (key === 'E') {
        console.log('edit setting')
    }
}

// user lands on home and we execute
document.removeEventListener('keyup', homeShortcuts); 
document.addEventListener('keyup', settingsShortcuts);

根据经验,使用全局对象时,你需要小心谨慎。

3.观察者

观察者都是许多不知道的浏览器Web API功能开发人员。如果你要检查HTML元素的可见性或大小的更改,它们将会变的很强大。

让我们看看Intersection Observer API的示例:

“The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport.” — MDN Web Docs

尽管功能强大,但你需要负责任地使用它。在完成观察对象之后,你需要取消监听事件。

代码如下:

const ref = ...
const visible = (visible) => {
  console.log(`It is ${visible}`);
}

useEffect(() => {
    if (!ref) {
        return;
    }

    observer.current = new IntersectionObserver(
        (entries) => {
            if (!entries[0].isIntersecting) {
                visible(true);
            } else {
                visbile(false);
            }
        },
        { rootMargin: `-${header.height}px` },
    );

    observer.current.observe(ref);
}, [ref]);

代码看起来不错,但是,一旦组件不加载,对观察者会发生什么?它不会被清除,因此将造成泄漏内存。我们该如何解决?只需使用disconnect方法:

const ref = ...
const visible = (visible) => {
  console.log(`It is ${visible}`);
}

useEffect(() => {
    if (!ref) {
        return;
    }

    observer.current = new IntersectionObserver(
        (entries) => {
            if (!entries[0].isIntersecting) {
                visible(true);
            } else {
                visbile(false);
            }
        },
        { rootMargin: `-${header.height}px` },
    );

    observer.current.observe(ref);

    return () => observer.current?.disconnect();
}, [ref]);

现在我们可以确定,当组件不被加载时,观察者将断开连接。

4.Window对象

将对象添加到Window上是一个常见的错误。在某些情况下,可能很难找到——特别是你想从全局上下文中找到这个关键字。
示例:

function addElement(element) {
    if (!this.stack) {
        this.stack = {
            elements: []
        }
    }

    this.stack.elements.push(element);
}

它看起来没什么事,这取决于你从中调用addElement的上下文。如果你从w全局上下文中调用addElement,你将造成内存泄露。

另一个问题可能是错误地定义了全局变量:

var a = 'example 1'; // scoped to the place where var was created
b = 'example 2'; // added to the Window object

为避免此类问题,请在JavaScript中使用严格模式:

"use strict"

通过使用严格模式,你可以在JavaScript编译器中,定义自己的变量。你仍然可以在需要时使用全局。但是,你必须以显式方式使用它。

严格模式将如何影响我们之前的示例:

  • addElement函数上,在全局范围内this属于未定义。
  • 如果未const | let | var在变量上指定,则会出现以下错误:
Uncaught ReferenceError: b is not defined // b未定义

5.持有DOM引用

DOM节点也不是没有内存泄漏。你需要注意不要保留它们的引用。否则,垃圾回收机制因为题目还可以使用将没有办法清理它们。
以下代码示例将说明这一点:

const elements = [];
const list = document.getElementById('list');

function addElement() {
    // clean nodes
    list.innerHTML = '';

    const divElement= document.createElement('div');
    const element = document.createTextNode(`adding element ${elements.length}`);
    divElement.appendChild(element);


    list.appendChild(divElement);
    elements.push(divElement);
}

document.getElementById('addElement').onclick = addElement;

请注意,该addElement函数清除了list的div,并向其添加了一个新的子元素。这个新的子元素将添加到elements数组中。

下次执行addElement元素时,该元素将从list的div中删除。但是,它不适合进行垃圾回收,它可以实现是因为它存储在elements数组中。这使得你在执行每个节点时都会执行addElement

让我们在执行几次之后监听该函数:

我们可以在上面的屏幕截图中看到节点如何泄漏。然而我们该如何解决呢?清除所有elements数组,将使它们有资格进行垃圾回收。

结论

在本文中,我们看到了内存泄漏的最常见方法。很明显,JavaScript本身不会造成泄漏内存。而是由开发人员无意间保留了变量造成的。只要代码整洁,并且我们不要忘记清理内存变量,就不会发生泄漏。

我们必须了解JavaScript中的内存和垃圾回收的工作方式。一些开发人员会认为JavaScript会自动回收垃圾,所以并没有在意。

建议定期在Web应用程序上运行浏览器分析器工具。唯一方法就是确保没有任何泄漏和遗留下来。Chrome开发者使用performance标签来检测一些异常的地方。发现问题后,你可以通过分析快照并比较快照来使用profiler选项卡对其进行更深入的研究。
有时,我们花时间优化方法,却忘记了内存是Web应用程序的重要部分。
Cheers!

本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情