
前端(vue)入門到精通課程:進入學習JavaScript 不提供任何記憶體管理操作。相反,記憶體由JavaScript VM 透過記憶體回收過程管理,該過程稱為垃圾收集。
既然我們不能強制的垃圾回收,那我們怎麼知道它能正常運作?我們對它又了解多少呢?
腳本執行在此過程中暫停
它為不可訪問的資源釋放內存
它是不確定的
它不會一次檢查整個內存,而是在多個週期中運行
它是不可預測的,但它會在必要時執行
這是否意味著無需擔心資源和內存分配問題?當然不是。如果我們一不小心,可能會產生一些記憶體洩漏。

記憶體洩漏是軟體無法回收的已分配的記憶體區塊。
Javascript 提供了一個垃圾收集程序,但這並不代表我們就能避免記憶體洩漏。為了符合垃圾收集的條件,該物件必須不被其他地方引用。如果持有未使用的資源的引用,這將會阻止這些資源被回收。這就是所謂的無意識的記憶體保持。
洩漏記憶體可能會導致垃圾收集器更頻繁地運行。由於這個過程會阻止腳本的運行,它可能會讓我們程式卡起來,這麼一卡,挑剔的用戶肯定會注意到,一用不爽了,那這個產品離下線的日子就不完了。更嚴重可能會讓整個應用程式奔潰,那就gg了。
如何防止記憶體洩漏? 主要還是我們應該避免保留不必要的資源。來看看一些常見的場景。
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 (
<p>Loading ...</p>
);
}
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 (
<p className={styles.container}>
{showTimer ? (
<Timer cicles={10} onFinish={onFinish} />
): (
<button onClick={() => setShowTimer(true)}>
Retry
</button>
)}
</p>
)
}在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 ,而不必擔心記憶體洩露,這也是抽象的好處。
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);根據經驗,當使用來自全域物件的工具時,需要灰常小心。
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]);向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
DOM節點也無法避免記憶體洩漏。我們需要注意不要保存它們的引用。否則,垃圾回收器將無法清理它們,因為它們仍然是可訪問的。
用一小段程式碼示範一下:
const elements = [];
const list = document.getElementById('list');
function addElement() {
// clean nodes
list.innerHTML = '';
const pElement= document.createElement('p');
const element = document.createTextNode(`adding element ${elements.length}`);
pElement.appendChild(element);
list.appendChild(pElement);
elements.push(pElement);
}
document.getElementById('addElement').onclick = addElement;注意, addElement函數清除列表p ,並將一個新元素作為子元素添加到它中。這個新建立的元素被加到elements數組中。
下一次執行addElement時,該元素將從清單p中刪除,但它不適合進行垃圾收集,因為它儲存在elements陣列中。
我們在執行幾次之後監視函數:
在上面的截圖中看到節點是如何被洩漏的。那要怎麼解決這個問題?清除elements數組將使它們有資格進行垃圾收集。
在這篇文章中,我們已經看到了最常見的記憶體洩漏方式。很明顯,JavaScript本身並沒有洩漏記憶體。相反,它是由開發者方面無意的記憶體保持造成的。只要程式碼是整潔的,而且我們不忘自己清理,就不會發生洩漏。
了解記憶體和垃圾回收在JavaScript中是如何運作的是必須的。一些開發者得到了錯誤的意識,認為由於它是自動的,所以他們不需要擔心這個問題。