JavaScript内存优化

相对C/C++ 而言,咱们所用的JavaScript 在内存这一方面的处理已经让咱们在开发中更注重业务逻辑的编写。可是随着业务的不断复杂化,单页面应用、移动HTML5 应用和Node.js 程序等等的发展,JavaScript 中的内存问题所致使的卡顿、内存溢出等现象也变得再也不陌生。javascript

1. 语言层面的内存管理

1.1 做用域

做用域(scope)是JavaScript 编程中一个很是重要的运行机制,在同步JavaScript 编程中它并不能充分引发初学者的注意,但在异步编程中,良好的做用域控制技能成为了JavaScript 开发者的必备技能。另外,做用域在JavaScript 内存管理中起着相当重要的做用。php

在JavaScript中,能造成做用域的有函数的调用、with语句和全局做用域。如如下代码为例:html

var foo = function() {
  var local = {};
};
foo();
console.log(local); //=> undefined

var bar = function() {
  local = {};
};
bar();
console.log(local); //=> {}

这里咱们定义了foo()函数和bar()函数,他们的意图都是为了定义一个名为local的变量。但最终的结果却大相径庭。前端

foo()函数中,咱们使用var语句来声明定义了一个local变量,而由于函数体内部会造成一个做用域,因此这个变量便被定义到该做用域中。并且foo()函数体内并无作任何做用域延伸的处理,因此在该函数执行完毕后,这个local变量也随之被销毁。而在外层做用域中则没法访问到该变量。java

而在bar()函数内,local变量并无使用var语句进行声明,取而代之的是直接把local做为全局变量来定义。故外层做用域能够访问到这个变量。node

local = {};
// 这里的定义等效于
global.local = {};

1.2 做用域链

在JavaScript编程中,你必定会遇到多层函数嵌套的场景,这就是典型的做用域链的表示。 如如下代码所示:mysql

function foo() {
  var val = 'hello';

  function bar() {
    function baz() {
      global.val = 'world;'
    }
    baz();
    console.log(val); //=> hello
  }
  bar();
}
foo();

根据前面关于做用域的阐述,你可能会认为这里的代码所显示的结果是world,但实际的结果倒是hello。不少初学者在这里就会开始感到困惑了,那么咱们再来看看这段代码是怎么工做的。jquery

因为JavaScript 中,变量标识符的查找是从当前做用域开始向外查找,直到全局做用域为止。因此JavaScript 代码中对变量的访问只能向外进行,而不能逆而行之。web

baz()函数的执行在全局做用域中定义了一个全局变量val。而在bar()函数中,对val这一标识符进行访问时,按照从内到外厄德查找原则:在bar函数的做用域中没有找到,便到上一层,即foo()函数的做用域中查找。算法

然而,使你们产生疑惑的关键就在这里:本次标识符访问在foo()函数的做用域中找到了符合的变量,便不会继续向外查找,故在baz()函数中定义的全局变量val并无在本次变量访问中产生影响。

1.3 闭包

咱们知道JavaScript 中的标识符查找遵循从内到外的原则。但随着业务逻辑的复杂化,单一的传递顺序已经远远不能知足日益增多的新需求。

咱们先来看看下面的代码:

function foo() {
  var local = 'Hello';
  return function() {
    return local;
  };
}
var bar = foo();
console.log(bar()); //=> Hello

这里所展现的让外层做用域访问内层做用域的技术即是闭包(Closure)。得益于高阶函数的应用,使foo()函数的做用域获得『延伸』。

foo()函数返回了一个匿名函数,该函数存在于foo()函数的做用域内,因此能够访问到foo()函数做用域内的local变量,并保存其引用。而因这个函数直接返回了local变量,因此在外层做用域中即可直接执行bar()函数以得到local变量。

闭包是JavaScript 的高级特性,咱们能够借助它来实现更多更复杂的效果来知足不一样的需求。可是要注意的是由于把带有​​内部变量引用的函数带出了函数外部,因此该做用域内的变量在函数执行完毕后的并不必定会被销毁,直到内部变量的引用被所有解除。因此闭包的应用很容易形成内存没法释放的状况。

2. JavaScript 的内存回收机制

这里我将以Chrome 和Node.js 所使用的,由Google 推出的V8 引擎为例,简要介绍一下JavaScript 的内存回收机制,更详尽的内容能够购买个人好朋友朴灵的书《深刻浅出Node.js 》进行学习,其中『内存控制』一章中有至关详细的介绍。

在V8 中,全部的JavaScript 对象都是经过『堆』来进行内存分配的。

当咱们在代码中声明变量并赋值时,V8 就会在堆内存中分配一部分给这个变量。若是已申请的内存不足以存储这个变量时,V8 就会继续申请内存,直到堆的大小达到了V8 的内存上限为止。默认状况下,V8 的堆内存的大小上限在64位系统中为1464MB,在32位系统中则为732MB,即约1.4GB 和0.7GB。

另外,V8 对堆内存中的JavaScript 对象进行分代管理:新生代和老生代。新生代即存活周期较短的JavaScript 对象,如临时变量、字符串等;而老生代则为通过屡次垃圾回收仍然存活,存活周期较长的对象,如主控制器、服务器对象等。

垃圾回收算法一直是编程语言的研发中是否重要的​​一环,而V8 中所使用的垃圾回收算法主要有如下几种:

  1. Scavange 算法:经过复制的方式进行内存空间管理,主要用于新生代的内存空间;
  2. Mark-Sweep 算法和Mark-Compact 算法:经过标记来对堆内存进行整理和回收,主要用于老生代对象的检查和回收。

PS: 更详细的V8 垃圾回收实现能够经过阅读相关书籍、文档和源代码进行学习。

咱们再来看看JavaScript 引擎在什么状况下会对哪些对象进行回收。

2.1 做用域与引用

初学者经常会误认为当函数执行完毕时,在函数内部所声明的对象就会被销毁。但实际上这样理解并不严谨和全面,很容易被其致使混淆。

引用(Reference)是JavaScript 编程中十分重要的一个机制,但奇怪的是通常的开发者都不会刻意注意它、甚至不了解它。引用是指『代码对对象的访问』这一抽象关系,它与C/C++ 的指针有点类似,但并不是同物。引用同时也是JavaScript 引擎在进行垃圾回收中最关键的一个机制。

一下面代码为例:

// ......
var val = 'hello world';
function foo() {
  return function() {
    return val;
  };
}
global.bar = foo();
// ......

阅读完这段代码,你可否说出这部分代码在执行事后,有哪些对象是依然存活的么?

根据相关原则,这段代码中没有被回收释放的对象有valbar(),到底是什么缘由使他们没法被回收?

JavaScript 引擎是如何进行垃圾回收的?前面说到的垃圾回收算法只是用在回收时的,那么它是如何知道哪些对象能够被回收,哪些对象须要继续生存呢?答案就是JavaScript 对象的引用。

JavaScript 代码中,哪怕是简单的写下一个变量名称做为单独一行而不作任何操做,JavaScript 引擎都会认为这是对对象的访问行为,存在了对对象的引用。为了保证垃圾回收的行为不影响程序逻辑的运行,JavaScript 引擎就决不能把正在使用的对象进行回收,否则就乱套了。因此判断对象是否正在使用中的标准,就是是否仍然存在对该对象的引用。但事实上,这是一种妥协的作法,由于JavaScript 的引用是能够进行转移的,那么就有可能出现某些引用被带到了全局做用域,但事实上在业务逻辑里已经不须要对其进行访问了,应该被回收,可是JavaScript 引擎仍会死板地认为程序仍然须要它。

如何用正确的姿式使用变量、引用,正是从语言层面优化JavaScript 的关键所在。

3. 优化你的JavaScript

终于进入正题了,很是感谢你秉着耐心看到了这里,通过上面这么多介绍,相信你已经对JavaScript 的内存管理机制有了不错的理解,那么下面的技巧将会让你如虎添翼。

3.1 善用函数

若是你有阅读优秀JavaScript 项目的习惯的话,你会发现,不少大牛在开发前端JavaScript 代码的时候,经常会使用一个匿名函数在代码的最外层进行包裹。

;(function() {
  // 主业务代码
})();

有的甚至更高级一点:

;(function(win, doc, $, undefined) {
  // 主业务代码
})(window, document, jQuery);

甚至连如RequireJS, SeaJS, OzJS 等前端模块化加载解决方案,都是采用相似的形式:

// RequireJS
define(['jquery'], function($) {
  // 主业务代码
});

// SeaJS
define('m​​odule', ['dep', 'underscore'], function($, _) {
  // 主业务代码
});

若是你说不少Node.js 开源项目的代码都没有这样处理的话,那你就错了。Node.js 在实际运行代码以前,会把每个.js 文件进行包装,变成以下的形式:

(function(exports, require, module, __dirname, __filename) {
  // 主业务代码
});

这样作有什么好处?咱们都知道文章开始的时候就说了,JavaScript中能造成做用域的有函数的调用、with语句和全局做用域。而咱们也知道,被定义在全局做用域的对象,颇有多是会一直存活到进程退出的,若是是一个很大的对象,那就麻烦了。好比有的人喜欢在JavaScript中作模版渲染:

<?php
  $db = mysqli_connect(server, user, password, 'myapp');
  $topics = mysqli_query($db, "SELECT * FROM topics;");
?>
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>你是猴子请来的逗比么?</title>
</head>
<body>
  <ul id="topics"></ul>
  <script type="text/tmpl" id="topic-tmpl">
    <li class="topic">
      <h1><%=title%></h1>
      <p><%=content%></p>
    </li>
  </script>
  <script type="text/javascript">
    var data = <?php echo json_encode($topics); ?>;
    var topicTmpl = document.querySelector('#topic-tmpl').innerHTML;
    var render = function(tmlp, view) {
      var complied = tmlp
        .replace(/\n/g, '\\n')
        .replace(/<%=([\s\S]+?)%>/g, function(match, code) {
          return '" + escape(' + code + ') + "';
        });

      complied = [
        'var res = "";',
        'with (view || {}) {',
          'res = "' + complied + '";',
        '}',
        'return res;'
      ].join('\n');

      var fn = new Function('view', complied);
      return fn(view);
    };

    var topics = document.querySelector('#topics');
    function init()     
      data.forEach(function(topic) {
        topics.innerHTML += render(topicTmpl, topic);
      });
    }
    init();
  </script>
</body>
</html>

这种代码在新手的做品中常常能看获得,这里存在什么问题呢?若是在从数据库中获取到的数据的量是很是大的话,前端完成模板渲染之后,data变量便被闲置在一边。可由于这个变量是被定义在全局做用域中的,因此JavaScript引擎不会将其回收销毁。如此该变量就会一直存在于老生代堆内存中,直到页面被关闭。

但是若是咱们做出一些很简单的修改,在逻辑代码外包装一层函数,这样效果就大不一样了。当UI渲染完成以后,代码对data的引用也就随之解除,而在最外层函数执行完毕时,JavaScript引擎就开始对其中的对象进行检查,data也就能够随之被回收。

3.2 绝对不要定义全局变量

咱们刚才也谈到了,当一个变量被定义在全局做用域中,默认状况下JavaScript 引擎就不会将其回收销毁。如此该变量就会一直存在于老生代堆内存中,直到页面被关闭。

那么咱们就一直遵循一个原则:绝对不要使用全局变量。虽然全局变量在开发中确实很省事,可是全局变量所致使的问题远比其所带来的方便更严重。

  1. 使变量不易被回收;
  2. 多人协做时容易产生混淆;
  3. 在做用域链中容易被干扰。

配合上面的包装函数,咱们也能够经过包装函数来处理『全局变量』。

3.3 手工解除变量引用

若是在业务代码中,一个变量已经确切是再也不须要了,那么就能够手工解除变量引用,以使其被回收。

var data = { /* some big data */ };
// blah blah blah
data = null;

3.4 善用回调

除了使用闭包进行内部变量访问,咱们还可使用如今十分流行的回调函数来进行业务处理。

function getData(callback) {
  var data = 'some big data';

  callback(null, data);
}

getData(function(err, data) {
  console.log(data);
});

回调函数是一种后续传递风格(Continuation Passing Style, CPS)的技术,这种风格的程序编写将函数的业务重点从返回值转移到回调函数中去。并且其相比闭包的好处也很多:

  1. 若是传入的参数是基础类型(如字符串、数值),回调函数中传入的形参就会是复制值,业务代码使用完毕之后,更容易被回收;
  2. 经过回调,咱们除了能够完成同步的请求外,还能够用在异步编程中,这也就是如今很是流行的一种编写风格;
  3. 回调函数自身一般也是临时的匿名函数,一旦请求函数执行完毕,回调函数自身的引用就会被解除,自身也获得回收。

3.5 良好的闭包管理

当咱们的业务需求(如循环事件绑定、私有属性、含参回调等)必定要使用闭包时,请谨慎对待其中的细节。

循环绑定事件可谓是JavaScript 闭包入门的必修课,咱们假设一个场景:有六个按钮,分别对应六种事件,当用户点击按钮时,在指定的地方输出相应的事件。

var btns = document.querySelectorAll('.btn'); // 6 elements
var output = document.querySelector('#output');
var events = [1, 2, 3, 4, 5, 6];

// Case 1
for (var i = 0; i < btns.length; i++) {
  btns[i].onclick = function(evt) {
    output.innerText += 'Clicked ' + events[i];
  };
}

// Case 2
for (var i = 0; i < btns.length; i++) {
  btns[i].onclick = (function(index) {
    return function(evt) {
      output.innerText += 'Clicked ' + events[index];
    };
  })(i);
}

// Case 3
for (var i = 0; i < btns.length; i++) {
  btns[i].onclick = (function(event) {
    return function(evt) {
      output.innerText += 'Clicked ' + event;
    };
  })(events[i]);
}

这里第一个解决方案显然是典型的循环绑定事件错误,这里不细说,详细能够参照我给一个网友的回答;而第二和第三个方案的区别就在于闭包传入的参数。

第二个方案传入的参数是当前循环下标,然后者是直接传入相应的事件对象。事实上,后者更适合在大量数据应用的时候,由于在JavaScript的函数式编程中,函数调用时传入的参数是基本类型对象,那么在函数体内获得的形参会是一个复制值,这样这个值就被看成一个局部变量定义在函数体的做用域内,在完成事件绑定以后就能够对events变量进行手工解除引用,以减轻外层做用域中的内存占用了。并且当某个元素被删除时,相应的事件监听函数、事件对象、闭包函数也随之被销毁回收。

3.6 内存不是缓存

缓存在业务开发中的做用举足轻重,能够减轻时空资源的负担。但须要注意的是,不要轻易将内存看成缓存使用。内存对于任何程序开发来讲都是寸土寸金的东西,若是不是很重要的资源,请不要直接放在内存中,或者制定过时机制,自动销毁过时缓存。

4. 检查JavaScript 的内存使用状况

在平时的开发中,咱们也能够借助一些工具来对JavaScript 中内存使用状况进行分析和问题排查。

4.1 Blink / Webkit 浏览器

在Blink / Webkit 浏览器中(Chrome, Safari, Opera etc.),咱们能够借助其中的Developer Tools 的Profiles 工具来对咱们的程序进行内存检查。

Developer Tools - Profiles

4.2 Node.js 中的内存检查

在Node.js 中,咱们可使用node-heapdump 和node-memwatch 模块进​​行内存检查。

var heapdump = require('heapdump');
var fs = require('fs');
var path = require('path');
fs.writeFileSync(path.join(__dirname, 'app.pid'), process.pid);
// ...

在业务代码中引入node-heapdump 以后,咱们须要在某个运行时期,向Node.js 进程发送SIGUSR2 信号,让node-heapdump 抓拍一份堆内存的快照。

$ kill -USR2 (cat app.pid)

这样在文件目录下会有一个以heapdump-<sec>.<usec>.heapsnapshot格式命名的快照文件,咱们可使用浏览器的Developer Tools中的Profiles工具将其打开,并进行检查。

5. 小结

很快又来到了文章的结束,这篇分享主要向你们展现了如下几点内容:

  1. JavaScript 在语言层面上,与内存使用息息相关的东西;
  2. JavaScript 中的内存管理、回收机制;
  3. 如何更高效地使用内存,以致于让出产的JavaScript 能更有拓展的活力;
  4. 如何在遇到内存问题的时候,进行内存检查。
相关文章
相关标签/搜索