看完離編寫高性能的JavaScript又近了一步
什么是內存泄露
內存泄漏指由于疏忽或錯誤造成程序未能釋放已經不再使用的內存。內存泄漏并非指內存在物理上的消失,而是應用程序分配某段內存后,由于設計錯誤,導致在釋放該段內存之前就失去了對該段內存的控制,從而造成了內存的浪費。
內存泄漏通常情況下只能由獲得程序源代碼的程序員才能分析出來。然而,有不少人習慣于把任何不需要的內存使用的增加描述為內存泄漏,即使嚴格意義上來說這是不準確的。
————wikipedia
意外的全局變量
JavaScript對未聲明變量的處理方式:在全局對象上創(chuàng)建該變量的引用(即全局對象上的屬性,不是變量,因為它能通過delete刪除)。如果在瀏覽器中,全局對象就是window對象。
如果未聲明的變量緩存大量的數(shù)據(jù),會導致這些數(shù)據(jù)只有在窗口關閉或重新刷新頁面時才能被釋放。這樣會造成意外的內存泄漏。
- function foo(arg) {
- bar = "this is a hidden global variable with a large of data";
- }
等同于:
- function foo(arg) {
- window.bar = "this is an explicit global variable with a large of data";
- }
另外,通過this創(chuàng)建意外的全局變量:
- function foo() {
- this.variable = "potential accidental global";
- }
- // 當在全局作用域中調用foo函數(shù),此時this指向的是全局對象(window),而不是'undefined'
- foo();
解決方法:
在JavaScript文件中添加'use strict',開啟嚴格模式,可以有效地避免上述問題。
- function foo(arg) {
- "use strict" // 在foo函數(shù)作用域內開啟嚴格模式
- bar = "this is an explicit global variable with a large of data";// 報錯:因為bar還沒有被聲明
- }
如果需要在一個函數(shù)中使用全局變量,可以像如下代碼所示,在window上明確聲明:
- function foo(arg) {
- "use strict" // 在foo函數(shù)作用域內開啟嚴格模式
- bar = "this is an explicit global variable with a large of data";// 報錯:因為bar還沒有被聲明
- }
這樣不僅可讀性高,而且后期維護也方便
談到全局變量,需要注意那些用來臨時存儲大量數(shù)據(jù)的全局變量,確保在處理完這些數(shù)據(jù)后將其設置為null或重新賦值。全局變量也常用來做cache,一般cache都是為了性能優(yōu)化才用到的,為了性能,***對cache的大小做個上限限制。因為cache是不能被回收的,越高cache會導致越高的內存消耗。
console.log
console.log:向web開發(fā)控制臺打印一條消息,常用來在開發(fā)時調試分析。有時在開發(fā)時,需要打印一些對象信息,但發(fā)布時卻忘記去掉console.log語句,這可能造成內存泄露。
在傳遞給console.log的對象是不能被垃圾回收 ♻️,因為在代碼運行之后需要在開發(fā)工具能查看對象信息。所以***不要在生產環(huán)境中console.log任何對象。
實例------>demos/log.html
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <meta http-equiv="X-UA-Compatible" content="ie=edge">
- <title>Leaker</title>
- </head>
- <body>
- <input type="button" value="click">
- <script>
- !function () {
- function Leaker() {
- this.init();
- };
- Leaker.prototype = {
- init: function () {
- this.name = (Array(100000)).join('*');
- console.log("Leaking an object %o: %o", (new Date()), this);// this對象不能被回收
- },
- destroy: function () {
- // do something....
- }
- };
- document.querySelector('input').addEventListener('click', function () {
- new Leaker();
- }, false);
- }()
- </script>
- </body>
- </html>
這里結合Chrome的Devtools–>Performance做一些分析,操作步驟如下:
⚠️注:***在隱藏窗口中進行分析工作,避免瀏覽器插件影響分析結果
- 開啟【Performance】項的記錄
- 執(zhí)行一次CG,創(chuàng)建基準參考線
- 連續(xù)單擊【click】按鈕三次,新建三個Leaker對象
- 執(zhí)行一次CG
- 停止記錄
可以看出【JS Heap】線***沒有降回到基準參考線的位置,顯然存在沒有被回收的內存。如果將代碼修改為:
- !function () {
- function Leaker() {
- this.init();
- };
- Leaker.prototype = {
- init: function () {
- this.name = (Array(100000)).join('*');
- },
- destroy: function () {
- // do something....
- }
- };
- document.querySelector('input').addEventListener('click', function () {
- new Leaker();
- }, false);
- }()
去掉console.log("Leaking an object %o: %o", (new Date()), this);語句。重復上述的操作步驟,分析結果如下:
從對比分析結果可知,console.log打印的對象是不會被垃圾回收器回收的。因此***不要在頁面中console.log任何大對象,這樣可能會影響頁面的整體性能,特別在生產環(huán)境中。除了console.log外,另外還有console.dir、console.error、console.warn等都存在類似的問題,這些細節(jié)需要特別的關注。
closures(閉包)
當一個函數(shù)A返回一個內聯(lián)函數(shù)B,即使函數(shù)A執(zhí)行完,函數(shù)B也能訪問函數(shù)A作用域內的變量,這就是一個閉包——————本質上閉包是將函數(shù)內部和外部連接起來的一座橋梁。
- function foo(message) {
- function closure() {
- console.log(message)
- };
- return closure;
- }
- // 使用
- var bar = foo("hello closure!");
- bar()// 返回 'hello closure!'
在函數(shù)foo內創(chuàng)建的函數(shù)closure對象是不能被回收掉的,因為它被全局變量bar引用,處于一直可訪問狀態(tài)。通過執(zhí)行bar()可以打印出hello closure!。如果想釋放掉可以將bar = null即可。
由于閉包會攜帶包含它的函數(shù)的作用域,因此會比其他函數(shù)占用更多的內存。過度使用閉包可能會導致內存占用過多。
實例------>demos/closures.html
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <meta http-equiv="X-UA-Compatible" content="ie=edge">
- <title>Closure</title>
- </head>
- <body>
- <p>不斷單擊【click】按鈕</p>
- <button id="click_button">Click</button>
- <script>
- function f() {
- var str = Array(10000).join('#');
- var foo = {
- name: 'foo'
- }
- function unused() {
- var message = 'it is only a test message';
- str = 'unused: ' + str;
- }
- function getData() {
- return 'data';
- }
- return getData;
- }
- var list = [];
- document.querySelector('#click_button').addEventListener('click', function () {
- list.push(f());
- }, false);
- </script>
- </body>
- </html>
這里結合Chrome的Devtools->Memory工具進行分析,操作步驟如下:
⚠️注:***在隱藏窗口中進行分析工作,避免瀏覽器插件影響分析結果
- 選中【Record allocation timeline】選項
- 執(zhí)行一次CG
- 單擊【start】按鈕開始記錄堆分析
- 連續(xù)單擊【click】按鈕十多次
- 停止記錄堆分析
上圖中藍色柱形條表示隨著時間新分配的內存。選中其中某條藍色柱形條,過濾出對應新分配的對象:
查看對象的詳細信息:
從圖可知,在返回的閉包作用鏈(Scopes)中攜帶有它所在函數(shù)的作用域,作用域中還包含一個str字段。而str字段并沒有在返回getData()中使用過。為什么會存在在作用域中,按理應該被GC回收掉, whyquestion
原因是在相同作用域內創(chuàng)建的多個內部函數(shù)對象是共享同一個變量對象(variable object)。如果創(chuàng)建的內部函數(shù)沒有被其他對象引用,不管內部函數(shù)是否引用外部函數(shù)的變量和函數(shù),在外部函數(shù)執(zhí)行完,對應變量對象便會被銷毀。反之,如果內部函數(shù)中存在有對外部函數(shù)變量或函數(shù)的訪問(可以不是被引用的內部函數(shù)),并且存在某個或多個內部函數(shù)被其他對象引用,那么就會形成閉包,外部函數(shù)的變量對象就會存在于閉包函數(shù)的作用域鏈中。這樣確保了閉包函數(shù)有權訪問外部函數(shù)的所有變量和函數(shù)。了解了問題產生的原因,便可以對癥下藥了。對代碼做如下修改:
- function f() {
- var str = Array(10000).join('#');
- var foo = {
- name: 'foo'
- }
- function unused() {
- var message = 'it is only a test message';
- // str = 'unused: ' + str; //刪除該條語句
- }
- function getData() {
- return 'data';
- }
- return getData;
- }
- var list = [];
- document.querySelector('#click_button').addEventListener('click', function () {
- list.push(f());
- }, false);
getData()和unused()內部函數(shù)共享f函數(shù)對應的變量對象,因為unused()內部函數(shù)訪問了f作用域內str變量,所以str字段存在于f變量對象中。加上getData()內部函數(shù)被返回,被其他對象引用,形成了閉包,因此對應的f變量對象存在于閉包函數(shù)的作用域鏈中。這里只要將函數(shù)unused中str = 'unused: ' + str;語句刪除便可解決問題。
查看一下閉包信息:
DOM泄露
在JavaScript中,DOM操作是非常耗時的。因為JavaScript/ECMAScript引擎獨立于渲染引擎,而DOM是位于渲染引擎,相互訪問需要消耗一定的資源。如Chrome瀏覽器中DOM位于WebCore,而JavaScript/ECMAScript位于V8中。假如將JavaScript/ECMAScript、DOM分別想象成兩座孤島,兩島之間通過一座收費橋連接,過橋需要交納一定“過橋費”。JavaScript/ECMAScript每次訪問DOM時,都需要交納“過橋費”。因此訪問DOM次數(shù)越多,費用越高,頁面性能就會受到很大影響。了解更多ℹ️
為了減少DOM訪問次數(shù),一般情況下,當需要多次訪問同一個DOM方法或屬性時,會將DOM引用緩存到一個局部變量中。但如果在執(zhí)行某些刪除、更新操作后,可能會忘記釋放掉代碼中對應的DOM引用,這樣會造成DOM內存泄露。
實例------>demos/dom.html
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <meta http-equiv="X-UA-Compatible" content="ie=edge">
- <title>Dom-Leakage</title>
- </head>
- <body>
- <input type="button" value="add" class="add">
- <input type="button" value="remove" class="remove" style="display:none;">
- <div class="container">
- <pre class="wrapper"></pre>
- </div>
- <script>
- // 因為要多次用到pre.wrapper、div.container、input.remove、input.add節(jié)點,將其緩存到本地變量中
- var wrapper = document.querySelector('.wrapper');
- var container = document.querySelector('.container');
- var removeBtn = document.querySelector('.remove');
- var addBtn = document.querySelector('.add');
- var counter = 0;
- var once = true;
- // 方法
- var hide = function(target){
- target.style.display = 'none';
- }
- var show = function(target){
- target.style.display = 'inline-block';
- }
- // 回調函數(shù)
- var removeCallback = function(){
- removeBtn.removeEventListener('click', removeCallback, false);
- addBtn.removeEventListener('click', addCallback, false);
- hide(addBtn);
- hide(removeBtn);
- container.removeChild(wrapper);
- }
- var addCallback = function(){
- wrapper.appendChild(document.createTextNode('\t' + ++counter + ':a new line text\n'));
- // 顯示刪除操作按鈕
- if(once){
- show(removeBtn);
- once = false;
- }
- }
- // 綁定事件
- removeBtn.addEventListener('click', removeCallback, false);
- addBtn.addEventListener('click', addCallback, false);
- </script>
- </body>
- </html>
這里結合Chrome瀏覽器的Devtools–>Performance做一些分析,操作步驟如下:
⚠️注:***在隱藏窗口中進行分析工作,避免瀏覽器插件影響分析結果
- 開啟【Performance】項的記錄
- 執(zhí)行一次CG,創(chuàng)建基準參考線
- 連續(xù)單擊【add】按鈕6次,增加6個文本節(jié)點到pre元素中
- 單擊【remove】按鈕,刪除剛增加6個文本節(jié)點和pre元元素
- 執(zhí)行一次CG
- 停止記錄堆分析
從分析結果圖可知,雖然6次add操作增加6個Node,但是remove操作并沒有讓Nodes節(jié)點數(shù)下降,即remove操作失敗。盡管還主動執(zhí)行了一次CG操作,Nodes曲線也沒有下降。因此可以斷定內存泄露了!那問題來了,如何去查找問題的原因呢?這里可以通過Chrome瀏覽器的Devtools–>Memory進行診斷分析,執(zhí)行如下操作步驟:
⚠️注:***在隱藏窗口中進行分析工作,避免瀏覽器插件影響分析結果
- 選中【Take heap snapshot】選項
- 連續(xù)單擊【add】按鈕6次,增加6個文本節(jié)點到pre元素中
- 單擊【Take snapshot】按鈕,執(zhí)行一次堆快照
- 單擊【remove】按鈕,刪除剛增加6個文本節(jié)點和pre元元素
- 單擊【Take snapshot】按鈕,執(zhí)行一次堆快照
- 選中生成的第二個快照報告,并將視圖由"Summary"切換到"Comparison"對比模式,在[class filter]過濾輸入框中輸入關鍵字:Detached
從分析結果圖可知,導致整個pre元素和6個文本節(jié)點無法別回收的原因是:代碼中存在全局變量wrapper對pre元素的引用。知道了產生的問題原因,便可對癥下藥了。對代碼做如下就修改:
- // 因為要多次用到pre.wrapper、div.container、input.remove、input.add節(jié)點,將其緩存到本地變量中
- var wrapper = document.querySelector('.wrapper');
- var container = document.querySelector('.container');
- var removeBtn = document.querySelector('.remove');
- var addBtn = document.querySelector('.add');
- var counter = 0;
- var once = true;
- // 方法
- var hide = function(target){
- target.style.display = 'none';
- }
- var show = function(target){
- target.style.display = 'inline-block';
- }
- // 回調函數(shù)
- var removeCallback = function(){
- removeBtn.removeEventListener('click', removeCallback, false);
- addBtn.removeEventListener('click', addCallback, false);
- hide(addBtn);
- hide(removeBtn);
- container.removeChild(wrapper);
- wrapper = null;//在執(zhí)行刪除操作時,將wrapper對pre節(jié)點的引用釋放掉
- }
- var addCallback = function(){
- wrapper.appendChild(document.createTextNode('\t' + ++counter + ':a new line text\n'));
- // 顯示刪除操作按鈕
- if(once){
- show(removeBtn);
- once = false;
- }
- }
- // 綁定事件
- removeBtn.addEventListener('click', removeCallback, false);
- addBtn.addEventListener('click', addCallback, false);
在執(zhí)行刪除操作時,將wrapper對pre節(jié)點的引用釋放掉,即在刪除邏輯中增加wrapper = null;語句。再次在Devtools–>Performance中重復上述操作:
小試牛刀------>demos/dom_practice.html
再來看看網上的一個實例,代碼如下:
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <meta http-equiv="X-UA-Compatible" content="ie=edge">
- <title>Practice</title>
- </head>
- <body>
- <div id="refA"><ul><li><a href="#"></a></li><li><a href="#"></a></li><li><a href="#" id="refB"></a></li></ul></div>
- <div></div>
- <div></div>
- <script>
- var refA = document.getElementById('refA');
- var refB = document.getElementById('refB');
- document.body.removeChild(refA);
- // #refA不能GC回收,因為存在變量refA對它的引用。將其對#refA引用釋放,但還是無法回收#refA。
- refA = null;
- // 還存在變量refB對#refA的間接引用(refB引用了#refB,而#refB屬于#refA)。將變量refB對#refB的引用釋放,#refA就可以被GC回收。
- refB = null;
- </script>
- </body>
- </html>
整個過程如下圖所演示:
有興趣的同學可以使用Chrome的Devtools工具,驗證一下分析結果,實踐很重要~~~high_brightness
timers
在JavaScript常用setInterval()來實現(xiàn)一些動畫效果。當然也可以使用鏈式setTimeout()調用模式來實現(xiàn):
- setTimeout(function() {
- // do something. . . .
- setTimeout(arguments.callee, interval);
- }, interval);
如果在不需要setInterval()時,沒有通過clearInterval()方法移除,那么setInterval()會不停地調用函數(shù),直到調用clearInterval()或窗口關閉。如果鏈式setTimeout()調用模式沒有給出終止邏輯,也會一直運行下去。因此再不需要重復定時器時,確保對定時器進行清除,避免占用系統(tǒng)資源。另外,在使用setInterval()和setTimeout()來實現(xiàn)動畫時,無法確保定時器按照指定的時間間隔來執(zhí)行動畫。為了能在JavaScript中創(chuàng)建出平滑流暢的動畫,瀏覽器為JavaScript動畫添加了一個新API-requestAnimationFrame()。關于setInterval、setTimeout與requestAnimationFrame實現(xiàn)動畫上的區(qū)別➹猛擊😊
實例------>demos/timers.html
如下通過setInterval()實現(xiàn)一個clock的小實例,不過代碼存在問題的,有興趣的同學可以先嘗試找一下問題的所在~😎
操作:
- 單擊【start】按鈕開始clock,同時web開發(fā)控制臺會打印實時信息
- 單擊【stop】按鈕停止clock,同時web開發(fā)控制臺會輸出停止信息
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <meta http-equiv="X-UA-Compatible" content="ie=edge">
- <title>setInterval</title>
- </head>
- <body>
- <input type="button" value="start" class="start">
- <input type="button" value="stop" class="stop">
- <script>
- var counter = 0;
- var clock = {
- start: function () {
- setInterval(this.step.bind(null, ++counter), 1000);
- },
- step: function (flag) {
- var date = new Date();
- var h = date.getHours();
- var m = date.getMinutes();
- var s = date.getSeconds();
- console.log("%d-----> %d:%d:%d", flag, h, m, s);
- }
- }
- document.querySelector('.start').addEventListener('click', clock.start.bind(clock), false);
- document.querySelector('.stop').addEventListener('click', function () {
- console.log('----> stop <----');
- clock = null;
- }, false);
- </script>
- </body>
- </html>
上述代碼存在兩個問題:
- 如果不斷的單擊【start】按鈕,會斷生成新的clock。
- 單擊【stop】按鈕不能停止clock。
輸出結果:
針對暴露出的問題,對代碼做如下修改:
- var counter = 0;
- var clock = {
- timer: null,
- start: function () {
- // 解決***個問題
- if (this.timer) {
- clearInterval(this.timer);
- }
- this.timer = setInterval(this.step.bind(null, ++counter), 1000);
- },
- step: function (flag) {
- var date = new Date();
- var h = date.getHours();
- var m = date.getMinutes();
- var s = date.getSeconds();
- console.log("%d-----> %d:%d:%d", flag, h, m, s);
- },
- // 解決第二個問題
- destroy: function () {
- console.log('----> stop <----');
- clearInterval(this.timer);
- node = null;
- counter = void(0);
- }
- }
- document.querySelector('.start').addEventListener('click', clock.start.bind(clock), false);
- document.querySelector('.stop').addEventListener('click', clock.destroy.bind(clock), false);
EventListener
做移動開發(fā)時,需要對不同設備尺寸做適配。如在開發(fā)組件時,有時需要考慮處理橫豎屏適配問題。一般做法,在橫豎屏發(fā)生變化時,需要將組件銷毀后再重新生成。而在組件中會對其進行相關事件綁定,如果在銷毀組件時,沒有將組件的事件解綁,在橫豎屏發(fā)生變化時,就會不斷地對組件進行事件綁定。這樣會導致一些異常,甚至可能會導致頁面崩掉。
實例------>demos/callbacks.html
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <meta http-equiv="X-UA-Compatible" content="ie=edge">
- <title>callbacks</title>
- </head>
- <body>
- <div class="container"></div>
- <script>
- var container = document.querySelector('.container');
- var counter = 0;
- var createHtml = function (n, counter) {
- var template = `${(new Array(n)).join(`<div>${counter}: this is a new data <input type="button" value="remove"></div>`)}`
- container.innerHTML = template;
- }
- var resizeCallback = function (init) {
- createHtml(10, ++counter);
- // 事件委托
- container.addEventListener('click', function (event){
- var target = event.target;
- if(target.tagName === 'INPUT'){
- container.removeChild(target.parentElement)
- }
- }, false);
- }
- window.addEventListener('resize', resizeCallback, false);
- resizeCallback(true);
- </script>
- </body>
- </html>
頁面是存在問題的,這里結合Devtools–>Performance分析一下問題所在,操作步驟如下:
⚠️注:***在隱藏窗口中進行分析工作,避免瀏覽器插件影響分析結果
- 開啟Performance項的記錄
- 執(zhí)行一次CG,創(chuàng)建基準參考線
- 對窗口大小進行調整
- 執(zhí)行一次CG
- 停止記錄
如分析結果所示,在窗口大小變化時,會不斷地對container添加代理事件。
同一個元素節(jié)點注冊了多個相同的EventListener,那么重復的實例會被拋棄。這么做不會讓得EventListener被重復調用,也不需要用removeEventListener手動清除多余的EventListener,因為重復的都被自動拋棄了。而這條規(guī)則只是針對于命名函數(shù)。對于匿名函數(shù),瀏覽器會將其看做不同的EventListener,所以只要將匿名的EventListener,命名一下就可以解決問題:
- var container = document.querySelector('.container');
- var counter = 0;
- var createHtml = function (n, counter) {
- var template = `${(new Array(n)).join(`<div>${counter}: this is a new data <input type="button" value="remove"></div>`)}`
- container.innerHTML = template;
- }
- //
- var clickCallback = function (event) {
- var target = event.target;
- if (target.tagName === 'INPUT') {
- container.removeChild(target.parentElement)
- }
- }
- var resizeCallback = function (init) {
- createHtml(10, ++counter);
- // 事件委托
- container.addEventListener('click', clickCallback, false);
- }
- window.addEventListener('resize', resizeCallback, false);
- resizeCallback(true);
在Devtools–>Performance中再重復上述操作,分析結果如下:
在開發(fā)中,開發(fā)者很少關注事件解綁,因為瀏覽器已經為我們處理得很好了。不過在使用第三方庫時,需要特別注意,因為一般第三方庫都實現(xiàn)了自己的事件綁定,如果在使用過程中,在需要銷毀事件綁定時,沒有調用所解綁方法,就可能造成事件綁定數(shù)量的不斷增加。如下鏈接是我在項目中使用jquery,遇見到類似問題:jQuery中忘記解綁注冊的事件,造成內存泄露➹猛擊😊
總結
本文主要介紹了幾種常見的內存泄露。在開發(fā)過程,需要我們特別留意一下本文所涉及到的幾種內存泄露問題。因為這些隨時可能發(fā)生在我們日常開發(fā)中,如果我們對它們不了解是很難發(fā)現(xiàn)它們的存在。可能在它們將問題影響程度放大時,才會引起我們的關注。不過那時可能就晚了,因為產品可能已經上線,接著就會嚴重影響產品的質量和用戶體驗,甚至可能讓我們承受大量用戶流失的損失。作為開發(fā)的我們必須把好這個關,讓我們開發(fā)的產品帶給用戶***的體驗。
參考文章: