本文首發於公衆號:符合預期的CoyPanjavascript
在全部的編程語言中,咱們聲明一個變量時,須要系統爲咱們分配一塊內存。當咱們再也不須要這個變量時,須要將內存進行回收(這個過程稱之爲垃圾回收)。在C語言中,有malloc和free來協助咱們進行內存管理。在JS中,開發者不須要手動進行內存管理,JS引擎會爲咱們自動作這些事情。可是,這並不意味着咱們在使用JS進行編碼時,不須要關心內存問題。java
內存聲明週期以下:react
在JS中,這三步都是對開發者無感的,不須要咱們過多的關心。面試
咱們須要注意的是,當咱們聲明一個變量、獲得一塊內存時,須要正確區分一個變量究竟是一個基本類型仍是引用類型。算法
基本類型:String,Number,Boolean,Null,Undefined,Symbol編程
引用類型:Object,Array,Function數組
對於基本類型變量來講,系統會爲其分配一塊內存,這塊內存中保存的,就是變量的內容。瀏覽器
對於引用類型變量來講,其存儲的只是一個地址而已,這個地址指向的內存塊纔是是變量的真正內容。引用變量的賦值,也只是把地址進行傳遞(複製)。舉個例子:markdown
// a 和 b 指向同一塊內存 var a = [1,2,3]; var b = a; a.push(4); console.log(b); // [1,2,3,4] 複製代碼
還有一點須要注意,JS中的函數傳參,實際上是按值傳遞(按引用傳遞)。舉個例子:react-router
// 函數f的入參,實際上是把 a 的值複製了一份。注意 a 是一個引用類型變量,其保存的是一個指向內存塊的一個地址。 function f(obj) { obj.b = 1; } var a = { a : 1}; f(a); console.log(a); // { a: 1, b: 1} 複製代碼
在平時的開發中,徹底理解JS中變量的存儲方式是十分重要的。對於我本身來講,儘可能避免把引用類型變量處處傳遞,可能一不當心在某個地方修改了變量,另外一個地方邏輯沒有判斷好,很容易出Bug,特別是在項目複雜度較高,且多人開發時。這也是我比較喜歡使用純函數的緣由。
另外,根據我以前的面試經驗,有很多的小夥伴認爲下面的代碼會報錯,這也是對JS中變量存儲方式掌握不熟致使的。
// const 聲明一個不可改變的變量。 // a 存儲的只是數組的內存地址而已,a.push 並不會改變 a 的值。 const a = []; a.push('1'); console.log(a); // ['1'] 複製代碼
垃圾回收算法主要依賴於引用的概念。在內存管理的環境中,一個對象若是有訪問另外一個對象的權限(隱式或者顯式),叫作一個對象引用另外一個對象。例如,一個Javascript對象具備對它原型的引用(隱式引用)和對它屬性的引用(顯式引用)。
在這裏,「對象」的概念不只特指 JavaScript 對象,還包括函數做用域(或者全局詞法做用域)。當變量再也不須要時,JS引擎會把變量佔用的內存進行回收。可是怎麼界定【變量再也不須要】呢?主要有兩種方法。
把「對象是否再也不須要」簡化定義爲「對象有沒有其餘對象引用到它」。若是沒有引用指向該對象(零引用),對象將被垃圾回收機制回收。MDN上的例子:
var o = { a: { b:2 } }; // 兩個對象被建立,一個做爲另外一個的屬性被引用,另外一個被分配給變量o // 很顯然,沒有一個能夠被垃圾收集 var o2 = o; // o2變量是第二個對「這個對象」的引用 o = 1; // 如今,「這個對象」只有一個o2變量的引用了,「這個對象」的原始引用o已經沒有 var oa = o2.a; // 引用「這個對象」的a屬性 // 如今,「這個對象」有兩個引用了,一個是o2,一個是oa o2 = "yo"; // 雖然最初的對象如今已是零引用了,能夠被垃圾回收了 // 可是它的屬性a的對象還在被oa引用,因此還不能回收 oa = null; // a屬性的那個對象如今也是零引用了 // 它能夠被垃圾回收了 複製代碼
這種方法有一個侷限性,那就是沒法處理循環引用。在下面的例子中,兩個對象被建立,並互相引用,造成了一個循環。它們被調用以後會離開函數做用域,因此它們已經沒有用了,能夠被回收了。然而,引用計數算法考慮到它們互相都有至少一次引用,因此它們不會被回收。
// 這種狀況下,o和o2都沒法被回收。 function f(){ var o = {}; var o2 = {}; o.a = o2; // o 引用 o2 o2.a = o; // o2 引用 o return "azerty"; } f(); 複製代碼
這個算法假定設置一個叫作根(root)的對象(在Javascript裏,根是全局對象)。垃圾回收器將按期從根開始,找全部從根開始引用的對象,而後找這些對象引用的對象……從根開始,垃圾回收器將找到全部能夠得到的對象和收集全部不能得到的對象。
關於JS中的垃圾回收算法,網上已經有不少的文章講解,這裏再也不進行贅述。
儘管JS爲咱們自動處理內存的分配、回收問題,可是在某些特定的場景下,JS的垃圾回收算法並不能幫咱們去除已經再也不使用的內存。這種【因爲疏忽或錯誤形成程序未能釋放已經再也不使用的內存】的現象,被稱做內存泄露。
內存佔用愈來愈高,輕則影響系統性能,重則致使進程崩潰。
可能產生內存泄露的場景有很多,包括全局變量,DOM事件,定時器等等。
下面是一段存在內存泄露的示例代碼:
class Page1 extends React.Component { events= [] componentDidMount() { window.addEventListener('scroll', this.handleScroll.bind(this)); } render() { return <div> <div><Link to={'/page2'}>前往Page2</Link></div> <p>page1</p> .... </div> } handleScroll(e) { this.events.push(e); } } 複製代碼
當咱們點擊按鈕跳轉到Page2後,在page2不停進行滾動操做,咱們會發現內存佔用不斷的上漲:
產生這個內存泄露的緣由是:咱們在Page1被unmount的時候,儘管Page1被銷燬了,可是Page1的滾動回調函數經過eventListener依然可「觸達」,因此不會被垃圾回收。進入Page2後,滾動事件的邏輯依然生效,內部的變量沒法被GC。若是用戶在Page2進行長時間滑動等操做,頁面會逐漸變得卡頓。
上述的例子,在咱們開發的過程當中,並很多見。不只僅是事件綁定,也有多是定時上報邏輯等等。如何解決呢?記得在unmount的時候,進行相應的取消操做便可。
在平時的項目開發中,內存泄露還有不少其餘的場景。瀏覽器頁面還好,畢竟一直開着某個頁面的用戶不算太多,刷新就好。而Node.js發生內存泄露的後果就比較嚴重了,可能服務就直接崩潰了。掌握JS的變量存儲方式、內存管理機制,養成良好的編碼習慣,能夠幫助咱們減小內存泄露的發生。
前面咱們講到了JS的垃圾回收機制,若是咱們持有對一個對象的引用,那麼這個對象就不會被垃圾回收。這裏的引用,指的是強引用。
在計算機程序設計中,還有一個弱引用的概念: 一個對象若只被弱引用所引用,則被認爲是不可訪問(或弱可訪問)的,並所以可能在任什麼時候刻被回收。
在JS中,WeakMap 和 WeakSet 給咱們提供了弱引用的能力。
要說WeakMap,先來講一說Map。Map 對象保存鍵值對,而且可以記住鍵的原始插入順序。任何值(對象或者原始值) 均可以做爲一個鍵或一個值。
Map對對象是強引用:
const m = new Map(); let obj = { a: 1 }; m.set(obj, 'a'); obj = null; // 將obj置爲null並不會使 { a: 1 } 被垃圾回收,由於還有map引用了 { a: 1 } 複製代碼
WeakMap是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵必須是對象,而值能夠是任意的。WeakMap是對對象的弱引用:
const wm = new WeakMap(); let obj = { b: 2 }; wm.set(obj, '2'); obj = null; // 將obj置爲 null 後,儘管 wm 依然引用了{ b: 2 },可是因爲是弱引用,{ b: 2 } 會在某一時刻被GC。 複製代碼
正因爲這樣的弱引用,WeakMap 的 key 是不可枚舉的 (沒有方法能給出全部的 key)。若是key 是可枚舉的話,其列表將會受垃圾回收機制的影響,從而獲得不肯定的結果。
WeakSet能夠視爲 WeakMap 中全部值都是布爾值的一個特例,這裏就再也不贅述了。
JavaScript 的 WeakMap 並不是真正意義上的弱引用:實際上,只要鍵仍然存活,它就強引用其內容。WeakMap 僅在鍵被垃圾回收以後,才弱引用它的內容。這種關係更準確地稱爲 ephemeron 。
WeakRef是一個更高級的API,它提供了真正的弱引用。咱們直接藉助上文的內存泄露的例子來看一看WeakRef的效果:
import React from 'react'; import { Link } from 'react-router-dom'; // 使用WeakRef將回調函數「包裹」起來,造成對回調函數的弱引用。 function addWeakListener(listener) { const weakRef = new WeakRef(listener); const wrapper = e => { if (weakRef.deref()) { return weakRef.deref()(e); } } window.addEventListener('scroll', wrapper); } class Page1 extends React.Component { events= [] componentDidMount() { addWeakListener(this.handleScroll.bind(this)); } componentWillUnmount() { console.log(this.events); } render() { return <div> <div><Link to={'/page2'}>前往Page2</Link></div> <p>page1</p> .... </div> } handleScroll(e) { this.events.push(e); } } export default Page1; 複製代碼
咱們再來看看點擊按鈕跳轉到page2後的內存表現:
能夠很直觀的看到,在跳轉到page2後,持續滾動一段時間後,內存平穩。這是由於隨着page1被unmount,真正的滾動回調函數( Page1的 handleScroll 函數)被GC掉了。其內部的變量也最終被GC。
但其實,這裏還有一個問題,雖然咱們經過weakRef.deref()
拿不到 handleScroll 滾動回調函數了(已被GC),可是咱們的包裹函數 wrapper 依然會執行。由於咱們沒有執行removeEventListener。理想狀況是:咱們但願滾動監聽函數也被取消掉。
能夠藉助FinalizationRegistry來實現這個功能。看下面的示例代碼:
// FinalizationRegistry構造函數接受一個回調函數做爲參數,返回一個示例。咱們把實例註冊到某個對象上,當該對象被GC時,回調函數會觸發。 const gListenersRegistry = new FinalizationRegistry(({ window, wrapper }) => { console.log('GC happen!!'); window.removeEventListener('scroll', wrapper); }); function addWeakListener(listener) { const weakRef = new WeakRef(listener); const wrapper = e => { console.log('scroll'); if (weakRef.deref()) { return weakRef.deref()(e); } } // 新增這行代碼,當listener被GC時,會觸發回調函數。回調函數傳參由咱們本身控制。 gListenersRegistry.register(listener, { window, wrapper }); window.addEventListener('scroll', wrapper); } 複製代碼
WeakRef 和 FinalizationRegistry 屬於高級Api,在Chrome v84 和 Node.js 13.0.0 後開始支持。通常狀況下不建議使用。由於容易用錯,致使更多的問題。
本文從JS中的內存管理講起,說到了JS中的弱引用。雖然JS引擎幫咱們處理了內存管理問題,可是咱們在業務開發中並不能徹底忽視內存問題,特別是在Node.js的開發中。
關於V8的內存策略的更多細節,能夠移步我以前翻譯的一篇文章:
參考資料: