首頁  >  文章  >  web前端  >  JavaScript也談記憶體優化

JavaScript也談記憶體優化

高洛峰
高洛峰原創
2017-02-04 13:12:401315瀏覽

相對C/C++ 而言,我們所使用的JavaScript 在記憶體這一方面的處理已經讓我們在開發中更注重業務邏輯的編寫。但隨著業務的不斷複雜化,單頁應用、行動HTML5 應用程式和Node.js 程式等等的發展,JavaScript 中的記憶體問題所導致的卡頓、記憶體溢位等現像也變得不再陌生。

這篇文章將從JavaScript 的語言層面進行記憶體的使用和最佳化的探討。從大家熟悉或略有耳聞的方面,到大家大多時候不會注意到的地方,我們一一進行剖析。

1. 語言層面的記憶體管理

1.1 作用域

作用域(scope)是JavaScript 程式設計中一個非常重要的運作機制,在同步JavaScript 程式設計中它並不能充分吸引初學者的注意,但在非同步在程式設計中,良好的作用域控制技能成為了JavaScript 開發者的必備技能。另外,作用域在JavaScript 記憶體管理中扮演至關重要的角色。

在JavaScript中,能形成作用域的有函數的呼叫、with語句和全域作用域。

如以下程式碼為例:

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變數也隨之被銷毀。而在外層作用域中則無法存取到該變數。

而在bar()函數內,local變數並沒有使用var語句來宣告,取而代之的是直接把local當作全域變數來定義。故外層作用域可以存取到這個變數。

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

1.2 作用域鏈

在JavaScript程式設計中,你一定會遇到多層函數嵌套的場景,這就是典型的作用域鏈的表示。


如以下程式碼所示:

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

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

根據前面關於作用域的闡述,你可能會認為這裡的程式碼所顯示的結果是world,但實際的結果卻是hello。很多初學者在這裡就會開始感到困惑了,那我們再來看看這段程式碼是怎麼運作的。

由於JavaScript 中,變數標識符的查找是從目前作用域開始向外查找,直到全域作用域為止。所以JavaScript 程式碼中對變數的存取只能向外進行,而不能逆而行之。

JavaScript也談記憶體優化

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 物件都是透過『堆』來進行記憶體分配的。

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();
// ......

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

根据相关原则,这段代码中没有被回收释放的对象有val和bar(),究竟是什么原因使他们无法被回收?

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('module', ['dep', 'underscore'], function($, _) {
  // 主业务代码
});

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

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

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

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

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

      var fn = new Function(&#39;view&#39;, complied);
      return fn(view);
    };

    var topics = document.querySelector(&#39;#topics&#39;);
    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 = &#39;some big data&#39;;

  callback(null, data);
}

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

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

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

3.5 良好的闭包管理

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

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

var btns = document.querySelectorAll(&#39;.btn&#39;); // 6 elements
var output = document.querySelector(&#39;#output&#39;);
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 += &#39;Clicked &#39; + events[i];
  };
}

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

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

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

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

3.6 内存不是缓存

缓存在业务开发中的作用举足轻重,可以减轻时空资源的负担。但需要注意的是,不要轻易将内存当作缓存使用。内存对于任何程序开发来说都是寸土寸金的东西,如果不是很重要的资源,请不要直接放在内存中,或者制定过期机制,自动销毁过期缓存。

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

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

4.1 Blink / Webkit 浏览器

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

JavaScript也談記憶體優化

4.2 Node.js 中的内存检查

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

var heapdump = require(&#39;heapdump&#39;);
var fs = require(&#39;fs&#39;);
var path = require(&#39;path&#39;);
fs.writeFileSync(path.join(__dirname, &#39;app.pid&#39;), process.pid);
// ...
<span style="font-family: Georgia, &#39;Times New Roman&#39;, &#39;Bitstream Charter&#39;, Times, serif; font-size: 14px; line-height: 1.5em;">在业务代码中引入node-heapdump 之后,我们需要在某个运行时期,向Node.js 进程发送SIGUSR2 信号,让node-heapdump 抓拍一份堆内存的快照。</span>
$ kill -USR2 (cat app.pid)

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

5. 小结

很快又来到了文章的结束,这篇分享主要向大家展示了以下几点内容:

1.JavaScript 在語言層面上,與記憶體使用息息相關的東西;
2.JavaScript 中的記憶體管理、回收機制;
3.如何更有效率地使用內存,以至於讓出產的JavaScript 能更有拓展的活力;
4.如何在遇到記憶體問題的時候,進行記憶體檢查。

更多JavaScript也談記憶體優化相關文章請關注PHP中文網!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn