首頁 web前端 js教程 JavaScript也談記憶體優化

JavaScript也談記憶體優化

Feb 04, 2017 pm 01:12 PM

相對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

熱AI工具

Undresser.AI Undress

Undresser.AI Undress

人工智慧驅動的應用程序,用於創建逼真的裸體照片

AI Clothes Remover

AI Clothes Remover

用於從照片中去除衣服的線上人工智慧工具。

Undress AI Tool

Undress AI Tool

免費脫衣圖片

Clothoff.io

Clothoff.io

AI脫衣器

AI Hentai Generator

AI Hentai Generator

免費產生 AI 無盡。

熱門文章

R.E.P.O.能量晶體解釋及其做什麼(黃色晶體)
3 週前 By 尊渡假赌尊渡假赌尊渡假赌
R.E.P.O.最佳圖形設置
3 週前 By 尊渡假赌尊渡假赌尊渡假赌
R.E.P.O.如果您聽不到任何人,如何修復音頻
3 週前 By 尊渡假赌尊渡假赌尊渡假赌
WWE 2K25:如何解鎖Myrise中的所有內容
4 週前 By 尊渡假赌尊渡假赌尊渡假赌

熱工具

記事本++7.3.1

記事本++7.3.1

好用且免費的程式碼編輯器

SublimeText3漢化版

SublimeText3漢化版

中文版,非常好用

禪工作室 13.0.1

禪工作室 13.0.1

強大的PHP整合開發環境

Dreamweaver CS6

Dreamweaver CS6

視覺化網頁開發工具

SublimeText3 Mac版

SublimeText3 Mac版

神級程式碼編輯軟體(SublimeText3)

如何創建和發布自己的JavaScript庫? 如何創建和發布自己的JavaScript庫? Mar 18, 2025 pm 03:12 PM

文章討論了創建,發布和維護JavaScript庫,專注於計劃,開發,測試,文檔和促銷策略。

如何在瀏覽器中優化JavaScript代碼以進行性能? 如何在瀏覽器中優化JavaScript代碼以進行性能? Mar 18, 2025 pm 03:14 PM

本文討論了在瀏覽器中優化JavaScript性能的策略,重點是減少執行時間並最大程度地減少對頁面負載速度的影響。

前端熱敏紙小票打印遇到亂碼問題怎麼辦? 前端熱敏紙小票打印遇到亂碼問題怎麼辦? Apr 04, 2025 pm 02:42 PM

前端熱敏紙小票打印的常見問題與解決方案在前端開發中,小票打印是一個常見的需求。然而,很多開發者在實...

如何使用瀏覽器開發人員工具有效調試JavaScript代碼? 如何使用瀏覽器開發人員工具有效調試JavaScript代碼? Mar 18, 2025 pm 03:16 PM

本文討論了使用瀏覽器開發人員工具的有效JavaScript調試,專注於設置斷點,使用控制台和分析性能。

如何使用源地圖調試縮小JavaScript代碼? 如何使用源地圖調試縮小JavaScript代碼? Mar 18, 2025 pm 03:17 PM

本文說明瞭如何使用源地圖通過將其映射回原始代碼來調試JAVASCRIPT。它討論了啟用源地圖,設置斷點以及使用Chrome DevTools和WebPack之類的工具。

誰得到更多的Python或JavaScript? 誰得到更多的Python或JavaScript? Apr 04, 2025 am 12:09 AM

Python和JavaScript開發者的薪資沒有絕對的高低,具體取決於技能和行業需求。 1.Python在數據科學和機器學習領域可能薪資更高。 2.JavaScript在前端和全棧開發中需求大,薪資也可觀。 3.影響因素包括經驗、地理位置、公司規模和特定技能。

開始使用Chart.js:PIE,DONUT和BUBBLE圖表 開始使用Chart.js:PIE,DONUT和BUBBLE圖表 Mar 15, 2025 am 09:19 AM

本教程將介紹如何使用 Chart.js 創建餅圖、環形圖和氣泡圖。此前,我們已學習了 Chart.js 的四種圖表類型:折線圖和條形圖(教程二),以及雷達圖和極地區域圖(教程三)。 創建餅圖和環形圖 餅圖和環形圖非常適合展示某個整體被劃分為不同部分的比例。例如,可以使用餅圖展示野生動物園中雄獅、雌獅和幼獅的百分比,或不同候選人在選舉中獲得的投票百分比。 餅圖僅適用於比較單個參數或數據集。需要注意的是,餅圖無法繪製值為零的實體,因為餅圖中扇形的角度取決於數據點的數值大小。這意味著任何占比為零的實體

初學者的打字稿,第2部分:基本數據類型 初學者的打字稿,第2部分:基本數據類型 Mar 19, 2025 am 09:10 AM

掌握了入門級TypeScript教程後,您應該能夠在支持TypeScript的IDE中編寫自己的代碼,並將其編譯成JavaScript。本教程將深入探討TypeScript中各種數據類型。 JavaScript擁有七種數據類型:Null、Undefined、Boolean、Number、String、Symbol(ES6引入)和Object。 TypeScript在此基礎上定義了更多類型,本教程將詳細介紹所有這些類型。 Null數據類型 與JavaScript一樣,TypeScript中的null

See all articles