第十二阶段:内存、垃圾回收、性能优化
- JS 内存划分:
- 栈内存(基本类型) :基本数据类型(
number、string、boolean、null、undefined、symbol、bigint)的值直接存储在栈内存中。栈内存的特点是数据存储和访问速度快,因为它遵循后进先出(LIFO)的原则。例如:
js
let num = 10;
let str = 'hello';
let bool = true;
这里的 num、str 和 bool 都是基本类型,它们的值直接存储在栈内存中。
- 堆内存(引用类型) :引用数据类型(如
Object、Array、Function等)的值存储在堆内存中。在栈内存中只存储一个指向堆内存中实际数据的引用地址。例如:
js
let obj = { name: 'John' };
let arr = [1, 2, 3];
function func() {}
对于 obj、arr 和 func,栈内存中存储的是指向堆内存中对应数据的地址,实际的数据存储在堆内存中。这是因为引用类型的数据结构可能比较复杂,需要动态分配内存空间,堆内存更适合这种需求。
- 垃圾回收机制 GC
引用计数算法:
-
是什么:引用计数算法为每个对象维护一个引用计数,记录该对象被引用的次数。当一个对象的引用计数变为 0 时,说明该对象不再被使用,垃圾回收器会回收其占用的内存。例如,当你创建一个对象并将其赋值给一个变量时,该对象的引用计数加 1;当变量被重新赋值或超出作用域时,对象的引用计数减 1。
-
缺点:引用计数算法无法处理循环引用的问题。例如,两个对象相互引用,即使它们都不再被外部引用,但它们的引用计数都不会为 0,导致内存无法回收。因此,现代 JavaScript 引擎主要使用标记清除算法,而引用计数算法在一些场景下作为辅助手段。
标记清除算法:
-
是什么 :这是 JavaScript 中最常用的垃圾回收算法。它的工作过程分为两个阶段:标记阶段和清除阶段。在标记阶段,垃圾回收器从根对象(如全局对象
window)开始,遍历所有可以访问到的对象,并标记它们。然后在清除阶段,垃圾回收器会遍历堆内存,回收所有未被标记的对象所占用的内存空间。例如,当一个对象不再被任何根对象或其他已标记对象引用时,它就不会被标记,从而在清除阶段被回收。 -
为什么:这种算法可以有效回收不再使用的对象内存,避免内存泄漏。它不依赖于对象的引用计数,能够处理循环引用的情况(在引用计数算法中,循环引用可能导致对象无法被回收)。
- 内存泄漏场景:
- 全局变量:如果定义了全局变量且没有及时释放,它会一直存在于内存中。例如:
js
// 全局变量
let globalVar;
function createGlobalVar() {
globalVar = { largeData: new Array(1000000).fill(1) };
}
createGlobalVar();
// 即使createGlobalVar函数执行完毕,globalVar及其数据仍占用内存
- 闭包:闭包可以访问外部函数作用域中的变量,这可能导致这些变量在函数执行完毕后仍然无法被垃圾回收。例如:
js
function outer() {
let largeObject = { data: new Array(1000000).fill(1) };
return function inner() {
return largeObject.data[0];
};
}
let innerFunc = outer();
// 这里outer函数执行完毕,但largeObject由于被闭包innerFunc引用,无法被回收
- 定时器未清除 :如果设置了定时器(
setTimeout或setInterval)但没有及时清除,定时器的回调函数及其依赖的变量会一直存在于内存中。例如:
js
let intervalId = setInterval(() => {
console.log('定时器运行');
}, 1000);
// 如果不调用clearInterval(intervalId),定时器及其回调会持续占用内存
- DOM 引用:当 JavaScript 对象持有对 DOM 元素的引用,而该 DOM 元素从文档中移除时,如果没有清除这些引用,相关对象和 DOM 元素占用的内存不会被回收。例如:
html
<div id="myDiv"></div>
<script>
let div = document.getElementById('myDiv');
let largeObj = { relatedDiv: div };
// 假设myDiv从文档中移除,但由于largeObj持有对它的引用,相关内存无法回收
</script>
- 事件未解绑:如果为 DOM 元素或其他对象添加了事件监听器,但在对象销毁或不再需要时没有解绑事件,事件监听器及其相关的闭包会一直存在于内存中。例如:
html
<button id="myButton">点击</button>
<script>
let button = document.getElementById('myButton');
function handleClick() {
console.log('按钮点击');
}
button.addEventListener('click', handleClick);
// 如果没有调用button.removeEventListener('click', handleClick),handleClick函数及其依赖会占用内存
</script>
- 性能优化方向
循环优化:
- 减少循环体中的计算:将循环体中不依赖循环变量的计算提前到循环外部。例如:
js
// 优化前
for (let i = 0; i < arr.length; i++) {
let result = complexCalculation();
arr[i] = result;
}
// 优化后
let result = complexCalculation();
for (let i = 0; i < arr.length; i++) {
arr[i] = result;
}
- 使用更高效的循环方式 :对于数组,
for循环通常比forEach性能更好,特别是在处理大数据量时。例如:
js
let arr = new Array(1000000).fill(1);
// 使用for循环
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}
// 使用forEach循环
sum = 0;
arr.forEach(num => sum += num);
// for循环在处理大数据量时性能更好
减少 DOM 操作(文档碎片 DocumentFragment):DOM 操作是比较昂贵的操作,频繁操作 DOM 会导致性能下降。可以使用文档碎片来批量操作 DOM,最后将文档碎片添加到 DOM 树中。例如:
html
<ul id="myList"></ul>
<script>
let list = document.getElementById('myList');
let fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) {
let li = document.createElement('li');
li.textContent = `列表项 ${i}`;
fragment.appendChild(li);
}
list.appendChild(fragment);
</script>
防抖节流:
- 防抖(Debounce):在事件触发后,等待一定时间(例如 300 毫秒),如果在这段时间内事件再次触发,则重新计时。常用于输入框搜索、窗口 resize 等场景。例如:
html
<input type="text" id="searchInput">
<script>
let input = document.getElementById('searchInput');
function debounce(func, delay) {
let timer;
return function () {
let context = this;
let args = arguments;
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(context, args);
}, delay);
};
}
function search() {
console.log('搜索:', input.value);
}
input.addEventListener('input', debounce(search, 300));
</script>
- 节流(Throttle):在事件持续触发的过程中,每隔一定时间(例如 100 毫秒)执行一次回调函数。常用于滚动条滚动、鼠标移动等场景。例如:
html
<div id="myDiv" style="height: 200px; overflow: auto;">内容</div>
<script>
let div = document.getElementById('myDiv');
function throttle(func, delay) {
let lastTime = 0;
return function () {
let context = this;
let args = arguments;
let now = new Date().getTime();
if (now- lastTime >= delay) {
func.apply(context, args);
lastTime = now;
}
};
}
function handleScroll() {
console.log('滚动中');
}
div.addEventListener('scroll', throttle(handleScroll, 100));
</script>
- 虚拟列表 :在处理大量列表数据时,只渲染可见区域的列表项,通过滚动来动态更新可见区域的内容。例如,在 React 中可以使用
react- virtualized库来实现虚拟列表。这样可以显著减少 DOM 元素的数量,提高性能。 - 请求缓存 :对于频繁请求且数据变化不大的 API,可以在前端缓存请求结果。例如,使用
localStorage或内存缓存来存储请求结果,下次请求时先检查缓存中是否有数据,如果有则直接使用缓存数据,避免重复请求。例如:
js
function fetchData() {
let cachedData = localStorage.getItem('apiData');
if (cachedData) {
return JSON.parse(cachedData);
}
// 实际请求API
let data = { result: '示例数据' };
localStorage.setItem('apiData', JSON.stringify(data));
return data;
}
- 避免深拷贝 :深拷贝会创建一个与原对象完全相同的新对象,包括所有层级的嵌套对象和数组。这可能会消耗大量内存和时间。在一些情况下,可以通过其他方式来实现类似的功能,比如使用浅拷贝(
Object.assign或展开运算符)或者直接操作原对象(前提是不影响其他部分的逻辑)。例如:
js
let obj1 = { a: { b: 1 } };
// 浅拷贝
let obj2 = { ...obj1 };
// 这里obj2.a和obj1.a指向同一个对象,并非深拷贝
- 合理使用 WeakMap/WeakSet 弱引用 :
WeakMap和WeakSet中的键或值是弱引用,当对象没有其他引用时,垃圾回收器可以回收这些对象,即使它们在WeakMap或WeakSet中。这可以避免因对象引用导致的内存泄漏。例如,在缓存 DOM 元素相关数据时,可以使用WeakMap:
js
let weakMap = new WeakMap();
let div = document.getElementById('myDiv');
weakMap.set(div, { data: '与div相关的数据' });
// 当div从文档中移除且没有其他引用时,垃圾回收器可以回收div及其相关数据
- 懒加载 :延迟加载页面中暂时不需要的资源,如图片、脚本等。当用户滚动到相关区域或有实际需求时再加载。例如,在 HTML 中可以使用
loading="lazy"属性来实现图片的懒加载:
html
<img src="image.jpg" alt="示例图片" loading="lazy">
- 分块加载:将大型脚本或样式文件分割成多个小块,按需加载。例如,在 Webpack 中可以使用代码分割功能来实现分块加载,提高页面的加载速度。
- requestAnimationFrame 动画优化 :
requestAnimationFrame会在浏览器下一次重绘之前调用传入的回调函数,它会根据浏览器的刷新频率来调整动画的执行,比使用setTimeout或setInterval实现动画更平滑,性能更好。例如:
js
function animate() {
// 动画逻辑
requestAnimationFrame(animate);
}
animate();
实战练习:
- 制造内存泄漏场景,控制台 Memory 面板观察
- WeakMap 改造缓存,自动释放对象
- DocumentFragment 批量插入 DOM 减少重绘回流
思考题:
- JS 垃圾回收什么时候触发?
- WeakMap 键对象无引用后,什么时候回收?
- 重绘和回流区别,如何减少?