概述
Javascript是一门具有自动垃圾回收机制的语言,这种机制让开发人员不必过多的担心因为内存问题而引发的困扰。但是不可避免的,开发人员在开发过程中忽略了某些容易“泄露”的点,从而导致内存无法释放。本节从内存管理的角度介绍如何稳定的使用云平台进行开发。
常见情形
内存泄漏指某个对象被无意间添加了某条引用,导致虽然实际上并不需要了,但还是能一直被遍历可达,以致其内存始终无法回收。确保占用最少的内存可以让页面获得更好的性能,而优化内存占用的最佳方式,就是为执行中的代码只保存必要的数据。一旦数据不再有用,最好通过将其值设置为 null 来释放其引用,这一做法适用于大多数全局变量和全局对象的属性。局部变量会在它们离开执行环境时自动被解除引用。解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用是让值脱离执行环境,垃圾收集器会按照固定的时间间隔(或代码执行中预定的收集时间),周期性地执行这一操作以便垃圾收集器下次运行时将其回收。造成内存泄漏的常见情形可以概括成以下几种:
- 全局变量
比较容易忽略的情况是由this指向全局window对象造成的。
function foo(){
this.showInfo = 'memory leak';
}
foo();
上面代码中foo函数在执行时的环境是在全局环境中,this指向window,导致showInfo成为window对象下的一个属性,这时如果没有 及时清理showInfo属性,就会导致字符串占用的内存无法回收。如果只是一个字符串情况还不算太糟糕,但是如果showInfo对应的是一个庞大的对象,那就必须要考虑 这种情况带来的影响。全局变量的另外一种形式是函数中声明的变量没有使用声明关键字。
- 闭包
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<button id='btn'>click me</button>
</body>
<script type="text/javascript">
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if(originalThing) {}
};
theThing = {
longStr: Date.now() + Array(1000000).join('*'),
someMethod: function () {
console.log(longStr);
}
};
// 这里去除对 originalThing 的引用也可以避免内存泄漏
// originalThing = null
};
var btn=document.getElementById('btn')
btn.addEventListener('click',function(){
setInterval(replaceThing, 1000);
});
</script>
</html>
这里有一个经典例子。每次replaceThing被调用,theThing获取一个新的对象,其中包含一个大数组和一个新的闭包(someMethod)。同时,unused变量拥有一个闭包,该闭包具有对originalThing的引用(来自之前对replaceThing的调用的Thing,theThing 由第一次调用replaceThing 函数后不再是null)。重要的是,一旦为同一父作用域中的闭包创建了作用域,则该作用域是共享的。在该例中 someMethod 闭包被 unused 共享。 unused 拥有指向 originalThing 的引用。即使 unused 从来没有被使用, someMethod 方法也能通过 theThing 对象被使用。就像 someMethod 对 unused 共享了闭包作用域,即使 unused 从来没有被使用,它指向originalThing 的引用依然是活跃的状态,所以 originalThing 不会被回收。实质上,创建一个闭包的链接列表(其根以theThing变量的形式),并且这些闭包的范围中的每一个都包含对大数组的间接引用,导致相当大的泄漏。
- 计时器或者回调函数
var someResource = getData();
setInterval(function() {
var node = document.getElementById('Node');
if(node) {
// 定时器内部对外部数据的处理操作...
node.innerHTML = JSON.stringify(someResource);
}
}, 1000);
node在将来可以被移除,但定时器由于一直在运行而无法回收,所以无法回收其依赖项。此处可以将setInterval赋给一个变量timer,在timer不需要之后,在代码中将timer置为null。
- 脱离DOM的引用
var elements = {
button: document.getElementById('button'),
image: document.getElementById('image'),
text: document.getElementById('text')
};
function doStuff() {
elements.image.src = 'http://some.url/image';
elements.button.click();
console.log(elements.text.innerHTML);
// 更多其他事情...
}
function removeButton() {
document.body.removeChild(document.getElementById('button'));
// 移除了dom元素中的button,但在elements对象中任然保留了对button的应用,导致GC无法回收
}
此处除了移除dom节点,还需要清除elements对象中对button的引用。此外还要考虑 DOM 树内部或子节点的引用问题。假如代码中保存了表格某一个 <td> 的引用,将来决定删除整个表格的时候,直觉认为 GC 会回收除了已保存的 <td> 以外的其它节点。实际情况并非如此:此 <td> 是表格的子节点,子元素与父元素是引用关系。由于代码保留了 <td> 的引用,导致整个表格仍存在于内存中。
解决方式
- 尽量不声明全局变量
- 使用' use strict'
- 变量使用完后及时置空(null)
- 对于dom元素:移除不需要的事件监听
- 尽量不使用闭包
分析工具
现代浏览器的控制台工具中都包含了内存分析管理的调试工具,这里以chrome浏览器的控制台工具为例,介绍如何在浏览器中调试内存问题。在chrome浏览器中按F12可以打开控制台界面,在多个并排排列的标签页中,Performance和Memory是进行内存分析时常用的两个工具。Performance用于整体页面加载的性能分析,Memory用于监控浏览器的内存分配行为,获取不同类型的分析文件Profiles,从Profiles中可以追溯到内存泄漏的具体对象,从而在代码中修复Bug。以上面的闭包的代码为例,我们简单介绍一下如何从Memory面板中获取到内存异常的对象的信息。
为了方便获取到对比的信息,可以在source面板中在replaceThing函数结束的地方加一个断点,函数每运行一次就会在这个地方停下来,便于采集Profiles的信息,点击button按钮后,函数开始运行,每执行一次在遇到断点的时候停下来。从Select profiling type中选择Heap snapshot选项,再选择Take snapShop采集一次内存信息记录当前的内存信息表。再从sources面板选择Resume script execution让程序继续运行直到断点处停下来(或者使用快捷键 Ctrl+\ ),反复几次后,可以得到上图右侧的多幅堆快照。
从左侧的快照列表中可以发现,占用的内存有稳定增长的趋势。选择其中一张快照如Snapshot5,再选择Comparison,与Snapshot4比较,在面板的主要区域,列出了选择Snapshot5和选择Snapshot4两次内存快照间不同对象的增量数据。主要关注Size Delta的部分,这一列表示Snapshot5和Snapshot4之间字符串对象增加的内存,单击Size Delta旁边出现黑色向下三角形表示当前内存增量从大到小排列,排在第一位的是string对象。再点击string对象展开,可以发现多出来的内存主要是被一个超长字符串所占用了,这时候再返回代码之中检查就能比较容易发现问题了。
更多chrome浏览器内存分析工具的其他使用方法可以参考谷歌开发者网站中的详细手册。
云平台内存管理最佳实践
开发者往往会遇到这样的需求,在不同页面间切换的时候都需要加载云平台以适应不同功能。viewer的频繁加载也会导致内存泄漏的问题。对于这种问题,我们总结了一套关于加载Viewer的最佳实践。解决这个问题的办法是将viewer做成一个组件,在需要用到的页面引入它。
假设目前有这样一个需求:客户需要一个网页应用来展示跟工程进度相关的信息,这个应用中主要有以下几个部分:第一个页面A展示详细计划进度,第二个页面B展示模拟进度,第三个页面C展示实际进度,还有一个页面D需要在模型上将计划的内容可视化,点击A、B、C、D对应的按钮能查看不同的功能页面。在这样一个需求中,展示计划进度和实际进度的页面中不需要展示viewer,另外的模拟进度和进度可视化的功能需要加载viewer。
以Vue为例,这里仅提供一种思路,不包含实际代码,如使用其他前端框架React、angular或者只用js,思路与下文所述一致。Vue学习资料参考Vue官网。
从页面路由开始,vue是单页面应用,上述四个页面至少应该对应四个大的组件,加上导航组件共5个。有了这5个组件,页面路由也就确定下来了。接下来可对具体功能页面中进行细分,结合前面的分析,每个页面都要显示不同的数据内容,并且其中两个页面中需要展示viewer。对于具体的数据内容如何展示及有什么交互细节这里不做探讨,这里主要讨论模拟进度和可视化的页面,这两个页面都有各自的功能部分,相同点是都需要加载viewer。这里我们可以把viewer做成一个单独的组件,这个组件是不包含在需要加载它的父组件里面,而是在vue的路由之外,与父组件同级。在不同的页面通过v-show判断当前页面是否需要加载viewer组件(注意如果使用v-if会重新渲染整个组件,也就重新加载了整个viewer)。公用viewer的两个组件内部各自保留所需的内容就可以,或者将这些组件拆分成功能更细的单一组件也可以。
<template>
<div class="progress-manage">
<div class="progress-manage-nav">
<!--页面导航部分-->
</div>
<div class="viewer-show">
<viewer v-show="showViewer" ></viewer>
</div>
<div class="progress-manage-content">
<!--路由到下级组件-->
<router-view></router-view>
</div>
</div>
</template>
优化内存的第二步是避免Vue对云平台对象的数据监听。典型的MVVM框架都具有响应式系统,在代码中对数据进行更改后页面中的数据也会随之更新,实现的原理是Vue在初始化的过程中对收集的依赖项进行数据监听,在属性被访问和修改时通知变化,进而更新视图数据。加载模型后得到的obvApi对象是一个数据量庞大的对象,Vue尝试对obvApi上的属性都进行监听操作,这是造成内存占用量高的主要原因。将初始化viewer后的obvApi对象包装成一个闭包返回可以避免这一问题。
class ObvApiWrapper {
constructor(obvApi) {
this.getObvApi = () => {
return obvApi;
};
}
select(nodeIdArray) {
this.getObvApi() && this.getObvApi().select(nodeIdArray);
}
// 封装更多OBV相关的API...
}
initializeSampleViewer('your-obv-viewer', 'your-file-urn', true, function (obvApi) {
obvApi = new ObvApiWrapper(obvApi);
});
客户的进一步需求是在页面间切换的时候,当前页面的显示能恢复到上一次离开时的状态。这时我们需要做的是一个状态保存器,在监测到用户离开当前页面时,将当前页面中viewer的状态和页面中的交互数据都保存下来,在用户下一次回到这个页面时,读取状态保存器中的数据,并调用云平台相关的API以恢复之前的状态。这个状态器应该只保留小部分最近的记录,对于老旧数据及时清理,避免占用不必要的空间。
最后记得在不需要viewer的时候,将viewer销毁。