比过去的网页,今天流行的 SPA 需要开发人员更加关注程序中的内存泄漏情况。因为以前的网站在浏览时会不断刷新页面,可是 SPA 网站往往只有少数几个页面,很少完全重新加载。这篇文章主要探讨 JS 代码中容易导致内存泄漏的模式,并给出改进对策。
本文最初发布于 ditdot.hr 网站,经作者授权由 InfoQ 中文站编译并分享。
什么是内存泄漏?
内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
JavaScript 是一个有垃圾回收机制的语言,我们不需要手动回收内存。当本应在垃圾回收周期中清理的内存中的对象,通过另一个对象的无意引用从根保持可访问状态时,就会发生内存泄漏,并可能导致性能下降的后果。
内存泄漏通常很难发现和定位。泄漏的 JavaScript 代码从任何层面都不会被认为是无效的,并且浏览器在运行时不会引发任何错误。
检查内存使用情况的最快方法是查看浏览器的 任务管理器(不是操作系统的那个任务管理器)。在 Linux 和 Windows 上按 Shift+Esc 来访问 Chrome 的任务管理器;Firefox 则在地址栏中键入 about:performance。我们能用它查看每个选项卡的 JavaScript 内存占用量。如果发现异常的内存使用量持续增长,就很可能出现了泄漏。
开发工具 提供了更高级的内存管理方法。通过 Chrome 的性能工具,我们可以直观地分析页面在运行时的性能。像下面这种模式就是内存泄漏的典型表现:
除此之外,Chrome 和 Firefox 的开发工具都可以用“内存”工具进一步探索内存使用情况。
JS 代码中常见的几个内存泄漏源
全局变量
全局变量总是从根可用,并且永远不会回收垃圾。在非严格模式下,一些错误会导致变量从本地域泄漏到全局域:
function createGlobalVariables() {
leaking1 = 'I leak into the global scope'; // assigning value to the undeclared variable
this.leaking2 = 'I also leak into the global scope'; // 'this' points to the global object
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'
预防措施:使用严格模式("use strict")。
闭包
函数作用域内的变量将在函数退出调用栈后清除,并且如果函数外部没有其他指向它们的引用,则将清理它们。但闭包将保留引用的变量并保持活动状态。
function outer() {
const potentiallyHugeArray = [];
return function inner() {
potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
console.log('Hello');
};
};
const sayHello = outer(); // contains definition of the function inner
function repeat(fn, num) {
for (let i = 0; i < num; i++){
fn();
}
}
repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray
// now imagine repeat(sayHello, 100000)
在此示例中,从任何一个函数都不会返回 potentialHugeArray,并且无法到达它,但它的大小可以无限增加,具体取决于我们调用函数 inner() 的次数。
预防措施:闭包是肯定会用到的,所以重要的是:
计时器
如果我们在代码中设置了递归计时器(recurring timer),则只要回调可调用,计时器回调中对该对象的引用就将保持活动状态。
在下面的示例中,由于我们没有对 setInterval 的引用,因此它永远不会被清除,并且 data.hugeString 会一直保留在内存中。
function setCallback() {
const data = {
counter: 0,
hugeString: new Array(100000).join('x')
};
return function cb() {
data.counter++; // data object is now part of the callback's scope
console.log(data.counter);
}
}
setInterval(setCallback(), 1000); // how do we stop it?
预防措施:尤其是在回调的生命周期不确定或 undefined 的情况下:
function setCallback() {
// 'unpacking' the data object
let counter = 0;
const hugeString = new Array(100000).join('x'); // gets removed when the setCallback returns
return function cb() {
counter++; // only counter is part of the callback's scope
console.log(counter);
}
}
const timerId = setInterval(setCallback(), 1000); // saving the interval ID
// doing something ...
clearInterval(timerId); // stopping the timer i.e. if button pressed
事件侦听器
添加后,事件侦听器将一直保持有效,直到:
对于某些类型的事件,应该一直保留到用户离开页面为止。但是,有时我们希望事件侦听器执行特定的次数。
const hugeString = new Array(100000).join('x');
document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
doSomething(hugeString); // hugeString is now forever kept in the callback's scope
});
在上面的示例中,用一个匿名内联函数作为事件侦听器,这意味着无法使用 removeEventListener() 将其删除。同样,该文档也无法删除,因此即使我们只需要触发它一次,它和它域中的内容就都删不掉了。
预防措施:我们应该始终创建指向事件侦听器的引用并将其传递给 removeEventListener(),来注销不再需要的事件侦听器。
function listener() {
doSomething(hugeString);
}
document.addEventListener('keyup', listener); // named function can be referenced here...
document.removeEventListener('keyup', listener); // ...and here
如果事件侦听器仅执行一次,则 addEventListener() 可以使用第三个参数。假设{once: true}作为第三个参数传递给 addEventListener(),则在处理一次事件后,将自动删除侦听器函数。
document.addEventListener('keyup', function listener() {
doSomething(hugeString);
}, {once: true}); // listener will be removed after running once
缓存
如果我们不删除未使用的对象且不控制对象大小,那么缓存就会失控。
let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const mapCache = new Map();
function cache(obj){
if (!mapCache.has(obj)){
const value = `${obj.name} has an id of ${obj.id}`;
mapCache.set(obj, value);
return [value, 'computed'];
}
return [mapCache.get(obj), 'cached'];
}
cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_1); // ['Peter has an id of 12345', 'cached']
cache(user_2); // ['Mark has an id of 54321', 'computed']
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321")
user_1 = null; // removing the inactive user
// Garbage Collector
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") // first entry is still in cache
在上面的示例中,缓存仍保留在 user_1 对象上。因此,我们还需要清除不会再重用的条目的缓存。
可能的解决方案:我们可以使用 WeakMap。它的数据结构中,键名是对象的弱引用,它仅接受对象作为键名,所以其对应的对象可能会被自动回收。当对象被回收后,WeakMap 自动移除对应的键值对。在以下示例中,在使 user_1 对象为空后,下一次垃圾回收后关联的条目会自动从 WeakMap 中删除。
let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const weakMapCache = new WeakMap();
function cache(obj){
// ...same as above, but with weakMapCache
return [weakMapCache.get(obj), 'cached'];
}
cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_2); // ['Mark has an id of 54321', 'computed']
console.log(weakMapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321"}
user_1 = null; // removing the inactive user
// Garbage Collector
console.log(weakMapCache); // ((…) => "Mark has an id of 54321") - first entry gets garbage collected
分离的 DOM 元素
如果 DOM 节点具有来自 JavaScript 的直接引用,则即使从 DOM 树中删除了该节点,也不会对其垃圾回收。
在以下示例中,我们创建了一个 div 元素并将其附加到 document.body。removeChild() 无法正常工作,并且由于仍然存在指向 div 的变量,所以堆快照将显示分离的 HTMLDivElement。
function createElement() {
const div = document.createElement('div');
div.id = 'detached';
return div;
}
// this will keep referencing the DOM element even after deleteElement() is called
const detachedDiv = createElement();
document.body.appendChild(detachedDiv);
function deleteElement() {
document.body.removeChild(document.getElementById('detached'));
}
deleteElement(); // Heap snapshot will show detached div#detached
怎么预防呢?一种方案是将 DOM 引用移入本地域。在下面的示例中,在函数 appendElement() 完成之后,将删除指向 DOM 元素的变量。
function createElement() {...} // same as above
// DOM references are inside the function scope
function appendElement() {
const detachedDiv = createElement();
document.body.appendChild(detachedDiv);
}
appendElement();
function deleteElement() {
document.body.removeChild(document.getElementById('detached'));
}
deleteElement(); // no detached div#detached elements in the Heap Snapshot
关注我并转发此篇文章,私信我“领取资料”,即可免费获得InfoQ价值4999元迷你书!
者:京东零售 谢天
在任何语言开发的过程中,对于内存的管理都非常重要,JavaScript 也不例外。
然而在前端浏览器中,用户一般不会在一个页面停留很久,即使有一点内存泄漏,重新加载页面内存也会跟着释放。而且浏览器也有自己的自动回收内存的机制,所以前端并没有特别关注内存泄漏的问题。
但是如果我们对内存泄漏没有什么概念,有时候还是有可能因为内存泄漏,导致页面卡顿。了解内存泄漏,如何避免内存泄漏,都是不可缺少的。
在硬件级别上,计算机内存由大量触发器组成。每个触发器包含几个晶体管,能够存储一个位。单个触发器可以通过唯一标识符寻址,因此我们可以读取和覆盖它们。因此,从概念上讲,我们可以把我们的整个计算机内存看作是一个巨大的位数组,我们可以读和写。
这是内存的底层概念,JavaScript 作为一个高级语言,不需要通过二进制进行内存的读写,而是相关的 JavaScript 引擎做了这部分的工作。
内存也会有生命周期,不管什么程序语言,一般可以按照顺序分为三个周期:
内存分配 -> 内存使用 -> 内存释放
在计算机科学中,内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。
如果内存不需要时,没有经过生命周期的的释放期,那么就存在内存泄漏。
内存泄漏的简单理解:无用的内存还在占用,得不到释放和归还。比较严重时,无用的内存会持续递增,从而导致整个系统的卡顿,甚至崩溃。
像 C 语言这样的底层语言一般都有底层的内存管理接口,但是 JavaScript 是在创建变量时自动进行了内存分配,并且在不使用时自动释放,释放的过程称为“垃圾回收”。然而就是因为自动回收的机制,让我们错误的感觉开发者不必关心内存的管理。
JavaScript 内存管理机制和内存的生命周期是一致的,首先需要分配内存,然后使用内存,最后释放内存。绝大多数情况下不需要手动释放内存,只需要关注对内存的使用(变量、函数、对象等)。
JavaScript 定义变量就会自动分配内存,我们只需要了解 JavaScript 的内存是自动分配的就可以了。
let num = 1;
const str = "名字";
const obj = {
a: 1,
b: 2
}
const arr = [1, 2, 3];
function func (arg) { ... }
使用值的过程实际上是对分配的内存进行读写的操作,读取和写入的操作可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。
// 继续上部分
// 写入内存
num = 2;
// 读取内存,写入内存
func(num);
垃圾回收被称为 GC(Garbage Collection)
内存泄漏一般都是发生在这一步,JavaScript 的内存回收机制虽然可以回收绝大部分的垃圾内存,但是还是存在回收不了的情况,如果存在这些情况,需要我们自己手动清理内存。
以前一些老版本的浏览器的 JavaScript 回收机制没有那么完善,经常出现一些 bug 的内存泄漏,不过现在的浏览器一般都没有这个问题了。
这里了解下现在 JavaScript 的垃圾内存的两种回收方式,熟悉一下这两种算法可以帮助我们理解一些内存泄漏的场景。
这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。
// “对象”分配给 obj1
var obj1 = {
a: 1,
b: 2
}
// obj2 引用“对象”
var obj2 = obj1;
// “对象”的原始引用 obj1 被 obj2 替换
obj1 = 1;
当前执行环境中,“对象”内存还没有被回收,需要手动释放“对象”的内存(在没有离开当前执行环境的前提下)
obj2 = null;
// 或者 obj2 = 1;
// 只要替换“对象”就可以了
这样引用的“对象”内存就被回收了。
ES6 中把引用分为强引用和弱引用,这个目前只有在 Set 和 Map 中才存在。
强引用才会有引用计数叠加,只有引用计数为 0 的对象的内存才会被回收,所以一般需要手动回收内存(手动回收的前提在于标记清除法还没执行,还处于当前的执行环境)。
而弱引用没有触发引用计数叠加,只要引用计数为 0,弱引用就会自动消失,无需手动回收内存。
当变量进入执行时标记为“进入环境”,当变量离开执行环境时则标记为“离开环境”,被标记为“进入环境”的变量是不能被回收的,因为它们正在被使用,而标记为“离开环境”的变量则可以被回收。
环境可以理解为我们的执行上下文,全局作用域的变量只会在页面关闭时才会被销毁。
// 假设这里是全局上下文
var b = 1; // b 标记进入环境
function func() {
var a = 1;
return a + b; // 函数执行时,a 被标记进入环境
}
func();
// 函数执行结束,a 被标记离开环境,被回收
// 但是 b 没有标记离开环境
JavaScript 的内存回收机制虽然能回收绝大部分的垃圾内存,但是还是存在回收不了的情况。程序员要让浏览器内存泄漏,浏览器也是管不了的。
下面有些例子是在执行环境中,没离开当前执行环境,还没触发标记清除法。所以你需要读懂上面 JavaScript 的内存回收机制,才能更好的理解下面的场景。
// 在全局作用域下定义
function count(num) {
a = 1; // a 相当于 window.a = 1;
return a + num;
}
不过在 eslint 帮助下,这种场景现在基本没人会犯了,eslint 会直接报错,了解下就好。
无用的计时器忘记清理,是最容易犯的错误之一。
拿一个 vue 组件举个例子。
<script>
export default {
mounted() {
setInterval(() => {
this.fetchData();
}, 2000);
},
methods: {
fetchData() { ... }
}
}
</script>
上面的组件销毁的时候,setInterval 还是在运行的,里面涉及到的内存都是没法回收的(浏览器会认为这是必须的内存,不是垃圾内存),需要在组件销毁的时候清除计时器。
<script>
export default {
mounted() {
this.timer = setInterval(() => { ... }, 2000);
},
beforeDestroy() {
clearInterval(this.timer);
}
}
</script>
无用的事件监听器忘记清理也是最容易犯的错误之一。
还是使用 vue 组件举个例子。
<script>
export default {
mounted() {
window.addEventListener('resize', () => { ... });
}
}
</script>
上面的组件销毁的时候,resize 事件还是在监听中,里面涉及到的内存都是没法回收的,需要在组件销毁的时候移除相关的事件。
<script>
export default {
mounted() {
this.resizeEvent = () => { ... };
window.addEventListener('resize', this.resizeEvent);
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeEvent);
}
}
</script>
Set 是 ES6 中新增的数据结构,如果对 Set 不熟,可以看这里。
如下是有内存泄漏的(成员是引用类型,即对象):
let testSet = new Set();
let value = { a: 1 };
testSet.add(value);
value = null;
需要改成这样,才会没有内存泄漏:
let testSet = new Set();
let value = { a: 1 };
testSet.add(value);
testSet.delete(value);
value = null;
有个更便捷的方式,使用 WeakSet,WeakSet 的成员是弱引用,内存回收不会考虑这个引用是否存在。
let testSet = new WeakSet();
let value = { a: 1 };
testSet.add(value);
value = null;
Map 是 ES6 中新增的数据结构,如果对 Map 不熟,可以看这里。
如下是有内存泄漏的(成员是引用类型,即对象):
let map = new Map();
let key = [1, 2, 3];
map.set(key, 1);
key = null;
需要改成这样,才会没有内存泄漏:
let map = new Map();
let key = [1, 2, 3];
map.set(key, 1);
map.delete(key);
key = null;
有个更便捷的方式,使用 WeakMap,WeakMap 的键名是弱引用,内存回收不会考虑到这个引用是否存在。
let map = new WeakMap();
let key = [1, 2, 3];
map.set(key, 1);
key = null
和上面事件监听器的道理是一样的。
建设订阅发布事件有三个方法,emit、on、off 三个方法。
还是继续使用 vue 组件举例子:
<template>
<div @click="onClick"></div>
</template>
<script>
import EventEmitter from 'event';
export default {
mounted() {
EventEmitter.on('test', () => { ... });
},
methods: {
onClick() {
EventEmitter.emit('test');
}
}
}
</script>
上面组件销毁的时候,自定义 test 事件还是在监听中,里面涉及到的内存都是没办法回收的,需要在组件销毁的时候移除相关的事件。
<template>
<div @click="onClick"></div>
</template>
<script>
import EventEmitter from 'event';
export default {
mounted() {
EventEmitter.on('test', () => { ... });
},
methods: {
onClick() {
EventEmitter.emit('test');
}
},
beforeDestroy() {
EventEmitter.off('test');
}
}
</script>
闭包是经常使用的,闭包能提供很多的便利,
首先看下下面的代码:
function closure() {
const name = '名字';
return () => {
return name.split('').reverse().join('');
}
}
const reverseName = closure();
reverseName(); // 这里调用了 reverseName
上面有没有内存泄漏?是没有的,因为 name 变量是要用到的(非垃圾),这也是从侧面反映了闭包的缺点,内存占用相对高,数量多了会影响性能。
但是如果 reverseName 没有被调用,在当前执行环境未结束的情况下,严格来说,这样是有内存泄漏的,name 变量是被 closure 返回的函数调用了,但是返回的函数没被使用,在这个场景下 name 就属于垃圾内存。name 不是必须的,但是还是占用了内存,也不可被回收。
当然这种也是极端情况,很少人会犯这种低级错误。这个例子可以让我们更清楚的认识内存泄漏。
每个页面上的 DOM 都是占用内存的,建设有一个页面 A 元素,我们获取到了 A 元素 DOM 对象,然后赋值到了一个变量(内存指向是一样的),然后移除了页面上的 A 元素,如果这个变量由于其他原因没有被回收,那么就存在内存泄漏,如下面的例子:
class Test {
constructor() {
this.elements = {
button: document.querySelector('#button'),
div: document.querySelector('#div')
}
}
removeButton() {
document.body.removeChild(this.elements.button);
// this.elements.button = null
}
}
const test = new Test();
test.removeButton();
上面的例子 button 元素虽然在页面上移除了,但是内存指向换成了 this.elements.button,内存占用还是存在的。所以上面的代码还需要这么写:this.elements.button = null,手动释放内存。
内存泄漏时,内存一般都是周期性的增长,我们可以借助谷歌浏览器的开发者工具进行判断。
这里针对下面的例子进行一步步的的排查和找到问题点:
<html>
<body>
<div id="app">
<button id="run">运行</button>
<button id="stop">停止</button>
</div>
<script>
const arr = []
for (let i = 0; i < 200000; i++) {
arr.push(i)
}
let newArr = []
function run() {
newArr = newArr.concat(arr)
}
let clearRun
document.querySelector('#run').onclick = function() {
clearRun = setInterval(() => {
run()
}, 1000)
}
document.querySelector('#stop').onclick = function() {
clearInterval(clearRun)
}
</script>
</body>
</html>
访问上面的代码页面,打开开发者工具,切换至 Performance 选项,勾选 Memory 选项。
在页面上点击运行按钮,然后在开发者工具上面点击左上角的录制按钮,10 秒后在页面上点击停止按钮,5 秒停止内存录制。得到内存走势如下:
由上图可知,10 秒之前内存周期性增长,10 秒后点击了停止按钮,内存平稳,不再递增。我们可以使用内存走势图判断是否存在内存泄漏。
上一步确认内存泄漏问题后,我们继续利用开发者工具进行问题查找。
访问上面的代码页面,打开开发者工具,切换至 Memory 选项。页面上点击运行按钮,然后点击开发者工具左上角的录制按钮,录制完成后继续点击录制,直到录制完成三个为止。然后点击页面上的停止按钮,在连续录制三次内存(不要清理之前的录制)。
从这里也可以看出,点击运行按钮之后,内存在不断的递增。点击停止按钮之后,内存就平稳了。虽然我们也可以用这种方式来判断是否存在内存泄漏,但是没有第一步的方法便捷,走势图也更加直观。
然后第二步的主要目的是为了记录 JavaScript 堆内存,我们可以看到哪个堆占用的内存更高。
从内存记录中,发现 array 对象占用最大,展开后发现,第一个 object elements 占用最大,选择这个 object elements 后可以在下面看到 newArr 变量,然后点击后面的高亮链接,就可以跳转到 newArr 附近。
端的学习已经进入了一个艰难的上升期,越来越发现自己学习的东西还多得多,需要掌握的知识面宽广了很多,知识点需要理解的深度也加深了很多。今天看到前端内存泄漏相关,自己总结总结,也便于自己以后学习记忆。由于经验所致,必然会有不足之处,欢迎指正!
内存是计算机中重要的部件之一,它是与CPU进行沟通的桥梁。计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大。
内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。 说白了就是 不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。
看完上面的解释,脑海中就会有一点概念,计算机正常运转会用到内存,内存像是一个中转站,他把你暂存的数据,马上就会用到的数据存储在这,以让你更快捷方便的使用, 那你肯定会想到一个问题,暂存的数据到底哪些该存储在这里,存储的东西不会一直在这,又是怎么消失的呢?
在我的理解,前端开发中,全局的、被引用的对象就会被保存在内存中。比如我们常见的闭包:
function leak(arg) {
this.arg = arg;
}
function test() {
var l1= new leak('It is a leak');
document.body.addEventListener('click', function() {
l1.arg = 'Here you are!'
})
}
test()
很明显,l1被闭包环境引用,无法被回收
对前端开发来说只需要理解'引用计数法'就可以了 语言引擎有一张"引用表",保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放。
2. 如果是在Node环境下,可以用Node提供的process.memoryUsage()方法来检查内存泄露:具体方法可以参考阮一峰的例子:https://github.com/ruanyf/es6tutorial/issues/362#issuecomment-292451925
判断内存泄露以heapUsed为准。
作者:天微蔚蓝
链接:https://juejin.im/post/5c6663a85188252a160efa3c
来源:掘金
*请认真填写需求信息,我们会在24小时内与您取得联系。