常見的JavaScript內存錯誤及解決方法

前言:

JavaScript 不提供任何內存管理操作。相反,內存由 JavaScript VM 通過內存回收過程管理,該過程稱為垃圾收集。

既然我們不能強制的垃圾回收,那我們怎麼知道它能正常工作?我們對它又瞭解多少呢?

腳本執行在此過程中暫停
它為不可訪問的資源釋放內存
它是不確定的
它不會一次檢查整個內存,而是在多個周期中運行
它是不可預測的,但它會在必要時執行
這是否意味著無需擔心資源和內存分配問題?當然不是。如果我們一不小心,可能會產生一些內存泄漏。

什麼是內存泄漏?

內存泄漏是軟件無法回收的已分配的內存塊。

Javascript 提供瞭一個垃圾收集程序,但這並不意味著我們就能避免內存泄漏。為瞭符合垃圾收集的條件,該對象必須不被其他地方引用。如果持有對未使用的資源的引用,這將會阻止這些資源被回收。這就是所謂的無意識的內存保持。

泄露內存可能會導致垃圾收集器更頻繁地運行。由於這個過程會阻止腳本的運行,它可能會讓我們程序卡起來,這麼一卡,挑剔的用戶肯定會註意到,一用不爽瞭,那這個產品離下線的日子就不完瞭。更嚴重可能會讓整個應用奔潰,那就gg瞭。

如何防止內存泄漏? 主要還是我們應該避免保留不必要的資源。來看看一些常見的場景。

1.計時器的監聽

setInterval() 方法重復調用函數或執行代碼片段,每次調用之間有固定的時間延遲。它返回一個時間間隔ID,該ID唯一地標識時間間隔,因此您可以稍後通過調用 clearInterval() 來刪除它。

我們創建一個組件,它調用一個回調函數來表示它在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 Dev Tools獲取內存使用的結果:

當我們點擊重試按鈕時,可以看到分配的內存越來越多。這說明之前分配的內存沒有被釋放。計時器仍然在運行而不是被替換。

怎麼解決這個問題?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 Hook,而不必擔心內存被泄露,這也是抽象化的好處。

2.事件監聽

Web API提供瞭大量的事件監聽器。在前面,我們討論瞭setTimeout。現在來看看 addEventListener

在這個事例中,我們創建一個鍵盤快捷鍵功能。由於我們在不同的頁面上有不同的功能,所以將創建不同的快捷鍵功能

function homeShortcuts({ key}) { 
    if (key === 'E') { 
        console.log('edit widget') 
    } 
} 
 
// 用戶在主頁上登陸,我們執行 
document.addEventListener('keyup', homeShortcuts);  
 
 
// 用戶做一些事情,然後導航到設置 
 
function settingsShortcuts({ key}) { 
    if (key === 'E') { 
        console.log('edit setting') 
    } 
} 
 
// 用戶在主頁上登陸,我們執行 
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.Observers

Observers 是一個瀏覽器的 Web API功能,很多開發者都不知道。如果你想檢查HTML元素的可見性或大小的變化,這個就很強大瞭。

IntersectionObserver接口 (從屬於Intersection Observer API) 提供瞭一種異步觀察目標元素與其祖先元素或頂級文檔視窗(viewport)交叉狀態的方法。祖先元素與視窗(viewport)被稱為根(root)。

盡管它很強大,但我們也要謹慎的使用它。一旦完成瞭對對象的觀察,就要記得在不用的時候取消它。

看看代碼:

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 Object

Window 添加對象是一個常見的錯誤。在某些場景中,可能很難找到它,特別是在使用 Window Execution上下文中的this關鍵字。

看看下面的例子:

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


它看起來無害,但這取決於你從哪個上下文調用addElement。如果你從Window Context調用addElement,那就會越堆越多。

另一個問題可能是錯誤地定義瞭一個全局變量:

var a = 'example 1'; // 作用域限定在創建var的地方 
b = 'example 2'; // 添加到Window對象中 


要防止這種問題可以使用嚴格模式:

"use strict" 


通過使用嚴格模式,向JavaScript編譯器暗示,你想保護自己免受這些行為的影響。當你需要時,你仍然可以使用Window。不過,你必須以明確的方式使用它。

嚴格模式是如何影響我們前面的例子:

  • 對於 addElement 函數,當從全局作用域調用時,this 是未定義的
  • 如果沒有在一個變量上指定const | let | var,你會得到以下錯誤:
Uncaught ReferenceError: b is not defined 


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 函數清除列表 div,並將一個新元素作為子元素添加到它中。這個新創建的元素被添加到 elements 數組中。

下一次執行 addElement 時,該元素將從列表 div 中刪除,但是它不適合進行垃圾收集,因為它存儲在 elements 數組中。

我們在執行幾次之後監視函數:

在上面的截圖中看到節點是如何被泄露的。那怎麼解決這個問題?清除 elements 數組將使它們有資格進行垃圾收集。

總結:

在這篇文章中,我們已經看到瞭最常見的內存泄露方式。很明顯,JavaScript本身並沒有泄漏內存。相反,它是由開發者方面無意的內存保持造成的。隻要代碼是整潔的,而且我們不忘自己清理,就不會發生泄漏。

瞭解內存和垃圾回收在JavaScript中是如何工作的是必須的。一些開發者得到瞭錯誤的意識,認為由於它是自動的,所以他們不需要擔心這個問題。

到此這篇關於常見的JavaScript內存錯誤的文章就介紹到這瞭,更多相關JavaScript內存錯誤內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: