[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

彭小呆發表於2020-03-14

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

1.什麼是閉包?

面試官問:瓦特is閉包?

A童鞋:I node,閉包is 幾拉呱啦,巴拉巴拉...

MDN:函式與對其狀態即詞法環境lexical environment)的引用共同構成閉包closure)。也就是說,閉包可以讓你從內部函式訪問外部函式作用域。在JavaScript,函式在每次建立時生成閉包。

直觀表現:

function father() {
    var name = "Dede"; // name 是一個被 father 建立的區域性變數
    function child() { // child() 是內部函式,一個閉包
        console.log('My father name is ' + name); // 使用了父函式中宣告的變數
    }
    child();
}
father();
// 輸出
// My father name is Dede
複製程式碼

2.閉包的作用?

巴拉巴拉巴拉....相信大家都耳熟能祥

主要用處:

  • 1.讓外部訪問函式內部變數成為可能;

  • 2.區域性變數會常駐在記憶體中;(節流、防抖等高階函式實現)(優點亦是缺點)

  • 3.可以避免使用全域性變數,防止全域性變數汙染;

3.閉包的優缺點?

巴拉巴拉巴拉.....相信你也可以說出來

  • 會造成記憶體洩漏(有一塊記憶體空間被長期佔用,而不被釋放)

4.分析

很多人在面試的時候都會可以或多或少回答出這些基本概念,可是其中又有多少人只是在背答案而沒有真正理解清楚其概念?

更別提如何去靈活運用了。

  • 許多高階函式中都涉及到了閉包的運用,例如:節流、防抖、promise等高階函式;
  • 另外一些外掛,框架中也運用到了閉包。

5.結論

閉包可以說在js這門語言學習生涯中第一個分水嶺。----來自‘偉’大的小呆

真正掌握閉包的工程師,才算是開始有突破的趨勢了。

閉包中包涵的概念有多少,來捋一捋?

  • 1.最基本函式的運用

  • 2.變數的作用域

  • 3.記憶體的分配,指標指向

  • 4.垃圾回收機制

作用域就可以延伸到this的指向等等...

記憶體的分配完後,垃圾回收機制緊隨其後...

等等,你以為真的結束了嗎,其實才剛剛開始!

6.一道經典的面試題

6.1 第一題

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

你能正確搞清楚,每一步都發生了什麼,最後的結果是什麼嗎?

這題考察的就是在JS中記憶體的分配,和變數引用(指標)的問題。

我習慣用指標來描述這個引用的概念,可能不是很正確,但很好理解。

6.2 第二題

好的,這題有點難,我們先來一個小demo練手:

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

這題,稍微懂點JS引用型別的童鞋,應該都知道:

  • 1.a實際上不是{name: lili}這個物件,只是該物件的地址引用。
  • 2.b=a,也就是指向了{name: lili}這個物件。
  • 3.a指向的物件的name屬性重新賦值為cat,現在該物件為{name: cat}
  • 4.列印a.name,會先找到a指向的那個記憶體空間,然後把資料取出來,最後輸出cat。
  • 5.列印b.name,因為b也指向了該物件,所以輸出的是cat。

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

你可以測試 a===b a是全等於b的,因為它們都是引用型別,只是一個指標,所引用的地址是一樣的。

這題沒有難度其實,稍微理解下引用型別的概念就可以掌握了。

6.3 解決第一題

回到這題

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

找這題就比較複雜了,我們要一步一步來解答:

  • 1.首先js會在記憶體中分配一塊空間用於存放{n: 1}這個資料(這裡叫明明),然後把該空間的地址引用賦值給a。
  • 2.變數b也指向這個物件{n: 1}明明
  • 3.a.x=a={n: 2},這一步操作好像爭議頗多,我也不是很清楚。
  • 4.我認為有一種解答還是比較有說服力,就是說不管是a.x=a={n: 2},還是a=a.x={n: 2},賦值的順序是有優先順序的,就像運算操作符乘除優先順序大於加減這樣的概念。這裡,物件屬性訪問操作的優先順序要選大於訪問物件。
  • 5.所有就是a.x會先做賦值運算,得到一個結果,a.x={n: 2},也就是a(或者b)指向的那個物件(明明)會多一個屬性x={n: 2}。
  • 6.接著繼續賦值,變數a會重新指向一個新的地址,該地址空間存放著{n: 2}這麼一個物件(叫二狗子)。
  • 7.到了這裡就清楚了,最後a是一個新的地址的引用,該地址存放的二狗子物件是{n: 2},那麼通過a.x訪問該物件的屬性x,但該物件只有一個屬性為n,那麼將列印出undefined。
a = {
    n: 2
}
a.x  === undefined
複製程式碼
  • 8.而b指標指向的物件明明裡面的資料結構是:
b = {
    n: 1,
    x: {
        n: 2
    }
}
複製程式碼
  • 9.並且可以嘗試b.x === a,將會輸出true。

我個人認為,這是最好理解的一種思路,同時我也在不斷學習中,如果有大佬指出概念不對的地方,我會虛心接受。

6.4 再來一題驗證思路

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

  • 1.首先分配一塊空間給物件{name: bibi},然後將該物件的地址引用賦值給obj1。
  • 2.通過getObj函式得到一個物件賦值給obj2。
  • 3.傳入函式的引數是obj1,也就是傳入了一個指標,然後在函式內部,將該指標指向的物件的name屬性修改為jock。
  • 4.重新分配一塊空間給物件{name: lili},將該物件的地址引用賦值給形參obj,最後return 該指標obj。
  • 5.變數obj2將得到一個值為物件{name: lili}的地址引用,也就是新物件的指標。

最後列印obj1,將輸出的是name=jcok。列印obj2,將輸出的是name=lili。 並且使用 obj1 === obj2 將得到false的結果。

好了,這部分就是記憶體分配,指標地址引用的部分,不再深入。

6.4 垃圾回收機制

那麼閱讀到這裡,你應該對引用型別的概念更清楚一些了,腦海裡有了指標的概念後,就容易多了。

講到記憶體的分配,自然有記憶體的釋放。而垃圾回收機制就是記憶體釋放的一個過程。JS中是自動回收那些分配的空間,它有一些相應的策略。

最簡單的來說,就是分配了一個記憶體空間,該空間使用後若無地址引用,也就是沒有任何一個從根開始的指標指向該地址空間,那麼JS就會派部隊去清除該地址空間的資料,然後釋放這塊空間佔用的記憶體。

這裡指出,應該是沒有一個從根節點開始訪問的那個值,才會被刪除。有可能一個物件它有一個或者多個指標指向它,但是沒有從根開始的引用或者是指向,這個物件也將被回收。專業名詞叫做:可達性,也就是可以被訪問到的意思。

JS的垃圾回收機制一般可分為標記清除和引用計數這兩大塊,具體不細講。

6.5 總算明白了a = null,並不是將原先a指向的物件給刪除掉

很多時候,總認為我給a賦值一個null,那麼就意味著我把原先a指向的物件中的資料給刪除了,其實並沒有。你只是讓該物件失去了地址引用,並不是你在刪除它,是JS會去監視那些不可達的值,然後通過垃圾回收機制去刪除它的。

也就是說,你a=null後,之前的物件並沒有馬上被刪除,應該是J會在一個合適的時機去做這件事。而且,a=null並不意味著該物件就真的失去了地址引用,有可能你在別的地方還用其他變數保留了該物件的地址引用,那麼你想要的效果將會不奏效。

7.再講閉包,重新認識

說了那麼多好像跟閉包無關的概念,但是恰恰是這些概念,才能使你充分理解閉包的精髓,並且能夠去運用閉包解決一些實際問題。

7.1 變數常駐記憶體

根據MDN提供的術語,在js中,閉包生成的時機是函式建立的時候。但是我還是不太懂這什麼意思啊,所有我只能去看看別人的理解,然後找到一句我認為我可以理解的話:

閉包就是可以建立一個獨立的環境,每個閉包裡面的環境都是獨立的,互不干擾。閉包會發生記憶體洩漏,每次外部函式執行的時候,外部函式的引用地址不同,都會重新建立一個新的地址。但凡是當前活動物件中有被內部子集引用的資料,那麼這個時候,這個資料不刪除,保留一根指標給內部活動物件。

原文出處連結

我彭大師從不抄襲,也不會打妄語。

這句話告訴我們,在內部函式引用了外部函式的資料時,這個資料不會被刪除,會保留一個指標引用給內部物件,從而這個資料的記憶體空間就不會被回收機制幹掉,也就有了閉包會讓變數常駐記憶體這個特點。

7.2 高階函式:節流、防抖的實現。

從而我們來分析一個我們經常被問到的兩個函式:節流於防抖。

這兩個函式是會一起被提及的,它們的概念呢也經常會被混淆,有時候傻傻分不清節流和防抖的區別,以及它們使用的場景。

  • 場景1:只是在腦子裡有一個概念,我要優化一些操作的時候,就會想起節流和防抖,但是具體用哪一個,我並不是很清楚,需要再百度一下,然後再選擇一個用。
  • 場景2:面試的時候,面試官總愛讓你手寫節流、防抖這兩個高階函式。你在面試前總是背的半死,到了筆試的時候,總是會想不起來怎麼寫,或者總是會寫錯一些步驟。

所以,本節我們就一起徹底的弄清楚節流和防抖的概念,以及在真實程式碼中的運用和如何自己手寫一個這樣的函式。

我們不從什麼是節流和防抖這兩個概念入手,這樣還是生搬概念,無法讓人簡單去理解。

我們從實際的運用中去把這兩個概念提取出來,這樣就成為你自己的東西了。

7.3 搜尋框優化

第一輪:

某一天,你開發了一個搜尋框的功能,這個功能用於關鍵詞檢索。

你仔細檢查,自測無誤後,提交程式碼,滿心歡喜讓測試開始測試,自認為天衣無縫,沒有任何bug。

不久測試小姐姐說,你這個頁面有bug啊。

???

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

你心想,怎麼可能,我明明自己測了很多次,根本沒有任何問題,你說說看,哪裡會有問題??

測試小姐姐:你這個輸入框,我才輸入了一個字,我還沒輸入完,這頁面怎麼就開始有搜尋結果了呢??

First Blood!!

嗯??一定是小姐姐打字太慢了,我去看看怎麼回事。

你一看程式碼,原來你用的是input,change事件,當input繫結的value的值發生變化的時候,就會觸發此事件,然後去呼叫搜尋介面。

但是好像,不需要一有值就馬上去搜尋,這樣第一個問題就是,使用者沒有輸入完,就去查詢結果。第二個問題,就是一直頻繁的請求介面,造成了沒必要的請求,造成了效能的損耗。

然後,作為一個合格的程式設計師,你會去思考,該怎麼去優化這個搜尋功能。按照常規的思路走,要不我在輸入框旁邊加個按鈕,點選才搜尋,這樣就不會有沒輸入完就開始搜尋的問題了,也不會一直頻繁的請求介面了。

第二輪:

於是你,開始了新的一輪修水管的任務了。噼裡啪啦,半小時後,你又滿心歡喜的提交了程式碼,然後到了小姐姐手上。

沒過多久,小姐姐就跟你說,又有問題,你這個互動邏輯很繁瑣啊,使用者輸入完,還要去點選那個醜醜的按鈕,很不便捷。其二,我壓力測試,我可以一直去點那個按鈕,這樣一直在傳送http請求,造成了網路擁堵,要是有人故意這樣搞破破壞,一直請求後臺,會對伺服器的效能和頻寬帶來嚴重的影響。

此刻,你的內心是崩潰的,我明明優化了,怎麼好像問題更多了。

Double kill!!!

經過一段自我懷疑自我調整的過程,你又有了新的優化方案,我去限制你使用者請求介面的頻率不就好了嗎,我讓你延遲一會,才會去觸發呼叫介面,豈不妙哉!

第三輪:

你開始在呼叫介面的地方,寫了一個setTimeout函式,延時1秒去呼叫介面。你測了幾遍感覺沒什麼問題,小心翼翼的檢查幾次後,提交程式碼到測試小姐姐手上。

經過小姐姐一番揉虐,小姐姐說:你這個還是不行啊,雖然第一次是延遲了1秒請求,但是我點了100次,但是過1秒後,馬上就有100個請求出現了。

Triple kill!!!

第四輪:

你思考的方向是對的,但是還是沒有起到作用。首先確定,使用延時這個方法是可行的,但是怎麼限制在一段時間內,只能有一次的請求。比如設定一個時間為2秒鐘,在這個期間,可能會觸發10次的chang事件,那麼怎麼把10次事件只觸發一次呢?

思考點:10次請求如何只觸發一次?

思路,從定時器本身下手。什麼是定時器?

setTimeout(() => {
    console.log('螢幕前的你,若是男生,則帥;女生,則膚白貌美!')
}, 1000)
複製程式碼

上述程式碼執行後,過1秒鐘左右是不是會輸出:

螢幕前的你,若是男生,則帥;女生,則膚白貌美!

這句話。

如果我們將其用一個函式包裹起來:

function sayToYou() {
    setTimeout(() => {
        console.log('螢幕前的你,若是男生,則帥;女生,則膚白貌美!')
    }, 1000)
}
sayToYou()
複製程式碼

是不是結果跟上面的一樣。

那如果我點選了十次按鈕,就會觸發十次sayToYou函式,只不過是延時1秒後,馬上發輸出10句。

那麼我想要的是連續快速的點選十次後,只有一次的sayToYou函式中的定時器的回撥能夠被觸發,那該怎麼辦呢??

是不是可以用一個變數將定時器先儲存起來,如果在一段時間內(第一個定時器定時的時間範圍內)又有新的函式被觸發了,那麼先判斷這個變數是不是存在了,如果存在上一個(第一個)定時器變數,那麼我就將上一個(第一個)定時器清除掉,不讓它觸發回撥任務。這樣之前的點選事件是不是就無效了。只有當前的點選事件繼續進入定時器內,等待這段時間過去,才會觸發回撥事件。

那麼我們就這樣做吧,改造一下我們的sayToYou函式:

let timer = null
function sayToYou() {
    if(timer) {
        clearTimeout(timer)
    }
    timer = setTimeout(() => {
        console.log('螢幕前的你,若是男生,則帥;女生,則膚白貌美!')
        clearTimeout(timer)
    }, 1000)
}
// 用迴圈 模擬高頻點選按鈕100次
for(let i = 0; i < 100; i++) {
    sayToYou()
}
// 過一秒鐘左右,只輸出一次。
複製程式碼

執行次程式碼,你即將熱淚盈眶,因為和你設想的結果一毛一樣。

首先我們遮蔽清除定時器的那行,將得到100句的輸出:

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

接著不遮蔽那句清除判斷:

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

是不是和我們預期一樣 只會輸出一句!!!

那麼我們接著優化一下我們的程式碼

現在雖然是可以在高頻觸發的情況下,只會觸發一次回撥,但是我們還要額外去宣告一個變數,那如果我有很多地方都需要呼叫這個函式怎麼辦??

難道要去多個地方都定義一個變數這樣,豈不是很麻煩??

那麼,說了這麼多,引入了這麼多js概念,終於到了重頭戲了,我們在前面討論閉包的時候,是不是有說到閉包的優點,我們們是不是可以利用起來。

現在想想閉包有什麼優點(特點)?

回答:使變數常駐記憶體中

ok,那麼我們將閉包的特性引入進來,改造一下:

  • 1.首先,我們們要建立一個閉包
  • 2.內部函式使用外部函式中的變數
  • 3.返回這個函式

改造後的程式碼:


function debounce() {
    let timer = null
    
    function sayToYou() {
        if(timer) {
            clearTimeout(timer)
        }
        timer = setTimeout(() => {
            console.log('螢幕前的你,若是男生,則帥;女生,則膚白貌美!')
            clearTimeout(timer)
        }, 1000)
    }
    return sayToYou
}

// 使用一個變數得到防抖的返回函式
const sayToYou = debounce()

// 用迴圈 模擬高頻點選按鈕100次
for(let i = 0; i < 100; i++) {
    // 執行此函式
    sayToYou()
}
複製程式碼

分析:仔細看,我們的sayToYou函式是不是裡面邏輯一行程式碼都沒有改動,我們改動的是什麼?

  • 1.另起一個叫做debounce的函式,裡面定義了一個timer變數。
  • 2.一個內部函式sayToYou,也就是我們之前那個sayToYou函式。(無更改)
  • 3.最後return此函式。
  • 4.引用的方式變化,外部呼叫的sayToYou函式是通過呼叫debounce函式獲得的返回值。

上面debounce我們們給它稱為父神,那麼裡面的sayToYou我們們給它叫做造娃,由於父神的記憶不是很好(老年痴呆?),父神需要在手臂上(timer)劃一道痕來標記那個娃。造娃的過程很簡單,父神抓一把泥巴開始捏娃,然後在手臂上劃一下。(造娃需要1秒鐘),造完後,父神手一揮,手臂上的痕跡就不見了。

//當父神執行的時候,也就是父神開始造娃的時候了,失手將催娃口訣遺失在人間
const sayToYou 催娃口訣 = debounce()

// 這個時候有一些愚蠢的凡人,一直在向上天祈禱(催娃)
// 要讓催娃口訣生效,必須加兩個神祕的符號()
for(let i = 0; i < 100; i++) {
    //催娃
    催娃口訣()
    sayToYou()
}
// 雖然這個時候被催娃了100遍,但是父神一看手上的痕跡還在啊,你催個蛋蛋娃,給本神等著。
// 最後,娃造完後,父神大手一揮,手臂的痕跡不見了,娃就落地了,呱呱大叫:你個大丑逼!
複製程式碼

執行結果

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

這就是結合閉包的用法,優化了我們的函式。

7.4 徹底要理解閉包

其實大家可能還是不能體會到下面這句話的意思。

“每次外部函式執行的時候,外部函式的引用地址不同,都會重新建立一個新的地址。但凡是當前活動物件中有被內部子集引用的資料,那麼這個時候,這個資料不刪除,保留一根指標給內部活動物件。”

這句話太長,我們要分成兩句話:

  • 1.父函式每執行一次,都會建立一個新的引用地址。
  • 2.會保留一根資料指標給內部函式使用。

結合到一起,意思就是,同一個引用地址的內部函式所引用的那個變數是同一根指標。

為了更直觀顯示出區別,我們要改下程式碼:

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

執行過程:a的到了父函式執行的一個地址引用,同時,獲得了本次父函式執行後的i的指標。

執行3次a函式:

    1. i = 0 => i++ => 1
    1. i = 1 => i++ => 2
    1. i = 2 => i++ => 3

那麼接著來一個例子:

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

我們多寫了一個b函式,然後執行的地方改成執行兩次a,一次b。

得到的結果是 1 2 1

這說明了什麼?

說明了每次外部函式執行的時候,外部函式的引用地址不同,都會重新建立一個新的地址。

那既然a和b引用的地址不同,那麼它們就會各自擁有一個閉包變數i(是不同的地址)

所以當執行完兩次a函式後,a所引用的地址中的內部變數i為2,接著執行b函式,不要天真的認為此時i是2,其實這個時候b所引用的地址中的內部變數i是0,所以執行完b函式將要輸出的是1。

思考1 console.log(a === b)

那麼,我們將上面程式碼小小的改變一下:

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

這個時候,控制檯要輸出什麼?你知道答案嗎?

思考2 console.log(a === b)

7.5 那你的防抖函式好像和市面上的不同啊!?

那麼這一小節,我們將繼續優化我們們的防抖函式,努力讓它稱為你想要的樣子!

市面上的防抖函式好像是這樣用的嘛:

const sayHello() {
    console.log('hello')
}
const a = debounce(sayHello, 1000)
// 輸出 hello
a()
複製程式碼

這有何難?我們們把閉包的本質都理解的差不多了,傳兩個引數進去而已,不在話下!

來,讓我們看下,第一個引數為一個函式的地址,第二個引數為延時的時間。

// 火影1代
function debounce(fn, delay) {
    let timer = 0

    return function () {
        if(timer) {
            clearTimeout(timer)
        }
        timer = setTimeout(fn, delay)
    }
}

const sayHello = () => console.log('hello, every body!' )
const a = b = debounce(sayHello, 1000)

a()
a()
b()
複製程式碼

我們通過設定兩個引數,就貌似達到了市面版的效果,執行後也只有一句歡迎資訊。

但是,如果我的sayHello函式並不是直接輸出一句話,而是在呼叫的時候傳一個name進去,這個時候怎麼辦呢?

嗯?好像可以繼續優化:

// 火影2代
function debounce(fn, delay) {
    let timer = 0

    return function () {
        if(timer) {
            clearTimeout(timer)
        }
        timer = setTimeout(() => {
            fn(...arguments)
            clearTimeout(timer)
        }, delay)
    }
}

const sayHello = name => console.log(`hello, ${name}!`)
const a = b = debounce(sayHello, 1000)

a('jcok')
a('lili')
b('cat')
複製程式碼

改動點:

  • 1.這裡我們將sayHello改造了一番,讓它可以支援傳入一個name,並且輸出歡迎資訊。
  • 2、在防抖函式內部,fn執行的地方,通過解構arguments這個全域性變數拿到sayHello傳入的變數。(可能有萌新對這個arguments很陌生,還有對...arguments這樣的語法感到疑惑,這裡推薦你去練個手,多列印幾次arguments就大概明白了。至於解構也就是三個點號...這部分內容屬於ES6,自行檢視)

最後我們高頻抖動了三次,分別傳了jock,lili,cat三個名字進去,最後只會列印出在1000毫秒內最後被觸發的那一個,也就是列印hello, cat!

讓我們執行一下:

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

事實上,結果跟我們想要的完全一致。

做到這裡,你以為就完了嗎??no,實際上跟市面上的版本還少了一個this的指向。

我們這裡的sayHello函式預設是掛載在window物件身上的,所以沒有在fn中修正指向,也是可以正常執行的。

但是,防抖函式是用於很多地方,如果你沒有正確的修正this的指向,那麼可能會有bug產生。

來個實際的例子:

function debounce(fn, delay) {
    let timer = 0

    return function () {
        if(timer) {
            clearTimeout(timer)
        }
        timer = setTimeout(() => {
            fn(...arguments)
            clearTimeout(timer)
        }, delay)
    }
}
const obj = {
    name: 'god',
    sayHello: function () {
        console.log(`hello, ${this.name}!`)
    }
}
const a = debounce(obj.sayHello, 1000)
a() // hello, undefined!
複製程式碼

修改點:

  • 1.將sayHello函式寫入到一個物件obj身上。(這裡注意,不能再用箭頭函式了)

你會發現,執行程式後,輸出的竟然是hello, undefined!(這裡測試方便是在NodeJs 環境中執行的,可實際上NodeJS中沒有document和window。)

而你不相信,單獨執行一次obj.sayHello(),得到的輸出是hello, god!

這裡就是因為在debounce中呼叫fn的時候,this的指向預設是window,而window物件沒有name的屬性,所以,將列印的是hello, undefined!

如果你不相信,那麼我們將在window身上掛載一個name屬性叫做john,然後執行程式試試。

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

保持debounce和obj的程式碼不變:

1.先執行一次debounce(obj.sayHello, 1000)()

得到的結果居然是hello, ! 我愣了兩秒,反應過來,可能window物件上已經有了一個name屬性為'', 所以,我把window列印了一下:

[非搬運]一個JS初級工程師的閉包自我理解---閉包雖老,我亦猶新!

果然,和我推測的一樣,那就沒毛病了,如果我們在sayhHello中列印的不是name,而是一個window身上沒有的屬性,將會輸出的應該是我們之前推測的hello, undefined!

2.接著修改window.name屬性為'john'

繼續呼叫一下debounce(obj.sayHello, 1000)(),得到的結果是和我們預測的一致:hello, john!

所以,得出的結論與我們推測的一致,最後我們還需要去修改this的指向。

7.6 較為完整一個防抖函式誕生

function debounce(fn, delay, _this) {
    let timer = 0
    return function () {
        if(timer) {
            clearTimeout(timer)
        }
        // 這一句很關鍵
        const that = _this ? _this : this
        timer = setTimeout(() => {
            fn.apply(that, arguments)
            clearTimeout(timer)
        }, delay)
    }
}
const obj = {
    name: 'god',
    sayHello: function () {
        console.log(`hello, ${this.name}!`)
    }
}
const a = debounce(obj.sayHello, 1000, obj)

a() // hello, god!
複製程式碼

這裡注意我們必須要主動傳入這個obj這個作為當前上下文給fn去apply這個this,不然即使我們使用fn.apply(this, arguments)這樣去修正this指向,但是得到的結果仍然和你想要的不一致。

const that = _this ? _this : this
複製程式碼

如果你不顯式地傳入一個上下文,那麼預設使用當前上下文。

換句話說就是debounce的return最終執行的環境的當前上下文會作為預設上下文傳入該that中,然後去修正fn的this指向。(沒有主動傳入一個上下文的情況下)

這裡很多小夥伴會疑惑了,大家不妨可以試一下。

7.7 一個更為完整的測試案例

function debounce(fn, delay, _this) {
    let timer = 0
    return function () {
        if(timer) {
            clearTimeout(timer)
        }
        const that = _this ? _this : this
        timer = setTimeout(() => {
            fn.apply(that, arguments)
            clearTimeout(timer)
        }, delay)
    }
}

const obj1 = {
    name: 'obj1',
    sayHello: function () {
        console.log(`hello, ${this.name}!`)
    }
}
const obj2 = {
    name: 'obj2',
    a: debounce(obj1.sayHello, 1000)
}
const obj3 = {
    name: 'obj3',
    a: debounce(obj1.sayHello, 1000, obj1)
}
const a = debounce(obj1.sayHello, 1000)
const b = debounce(obj1.sayHello, 1000, obj1)

obj1.sayHello() // 沒有使用防抖優化

obj2.a() // 使用了防抖優化,預設上下文為obj2

obj3.a() // 顯式的改變上下文為obj1

a() // 使用了防抖優化 預設上下文為window(在瀏覽器環境中)

b() // 顯式的改變上下文為obj1

複製程式碼

大家可以測試下上面的程式碼,看看輸出情況和我上面分析的對不對。

8總結

一句話概括:JS博大精深,豈是我等宵小之輩可以覬覦!?

後話

我是小呆,歡迎你與我討論技術。

相關文章