1. 程式人生 > >一個函式理解js的this和閉包——詳解debounce

一個函式理解js的this和閉包——詳解debounce

debounce應用場景模擬

debounce函式,俗稱防抖函式,專治input、resize、scroll等頻繁操作打爆瀏覽器或其他資源。前端面試幾乎必考,當然肯定會做一些變化。

<!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>Debounce Demo</title>
</head>

<body>
  <p>Input here:</p>
  <input type="text" id="input">
  <script>
    var handler = function () {
      console.log(this, Date.now());
    }
    document.getElementById('input').addEventListener('input', handler);
  </script>
</body>

</html>

現狀

使用者每次輸入操作都會觸發handler呼叫,效能浪費。

目標

使用者一直輸入並不觸發handler,直到使用者停止輸入500ms以上,才觸發一次handler。

前提是,不修改原有的業務程式碼,且儘量通用。

思路

  1. setTimeout實現計時
  2. 高階函式,即function作為引數並且返回function

程式碼實現過程

第一版

function debounce(fn, delay) {
  return function () {
    setTimeout(function () {
      fn();
    }, delay);
  }
}

給handler包上試試

document.getElementById('input').addEventListener('input', debounce(handler, 500));

明顯不可以!!這樣寫只不過將每次觸發都延時了500ms,並沒有減少觸發次數。不過我們至少實現了高階函式,不會破壞原有的業務程式碼了。那麼接下來就試著減少觸發次數。

思路就是每次觸發先clearTimeout把之前的計時器清掉,再重新setTimout。那麼問題來了,第2次進來時,怎麼獲取到第1次的計時器,並清除呢?

第二版

function debounce(fn, delay) {
  var timer;
  return function () { // 閉包
    clearTimeout(timer);
    timer = setTimeout(function () {
      fn();
    }, delay);
  }
}

試來試去,發現把timer放到“外面”最好(為什麼不放到更外面?),每次呼叫進來,大家用的都是一個timer,完美。同時,我們的第一個主角登場了——閉包。

閉包

閉包就是能夠讀取其他函式內部變數的函式。例如在javascript中,只有函式內部的子函式才能讀取區域性變數,所以閉包可以理解成“定義在一個函式內部的函式“。在本質上,閉包是將函式內部和函式外部連線起來的橋樑。——百度百科

電腦科學中,閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函式閉包(function closures),是引用了自由變數的函式。這個被引用的自由變數將和這個函式一同存在,即使已經離開了創造它的環境也不例外。所以,有另一種說法認為閉包是由函式和與其相關的引用環境組合而成的實體。閉包在執行時可以有多個例項,不同的引用環境和相同的函式組合可以產生不同的例項。——維基百科

網上可以找個很多關於閉包的概念與解釋,估計越看越蒙。認識事物需要一個從具象到抽象的過程,以目前的情況來看,我們只要知道,“定義在一個函式(外函式)內部的函式(內函式),並且內函式訪問了外函式的變數,這個內函式就叫做閉包”。

最關鍵的問題,閉包有什麼用?從debounce這個例子,我們可以看到,閉包可以讓每次觸發的handler共享一個變數,通常用到高階函式的地方,就會用到閉包。再舉幾個閉包的應用場景,比如給ajax請求加快取、加鎖,為一系列回撥設定初始值,防止汙染全域性或區域性變數等。可能這麼說大家還是若有若無的,沒關係,實踐出真知,現實當中肯定會碰到能夠應用閉包的地方的。我們繼續debounce。

終於解決了觸發頻率的問題了。但是!細心的同學肯定發現了。我們handler裡的console打印出來的this,是不一樣的!!!之前的this是input結點,現在的this是window物件。這絕對是不行的,比如我想要在handler裡列印input的value,現在怎麼做呢?

第三版

function debounce(fn, delay) {
  // 1
  var timer;
  return function () { // 閉包
    // 2
    var ctx = this; // this上下文
    clearTimeout(timer);
    timer = setTimeout(function () {
      // 3
      fn.apply(ctx); // this上下文呼叫
    }, delay);
  }
}

解決思路也簡單,就是先把正確的this儲存起來,我們在這裡把this稱為“上下文”,大家可以細細品味一下這個詞。然後用apply(或call)重新制定一下fn的上下文即可。

上下文this

js的this是很善變的,誰呼叫它,它就指向誰,所以“上下文”這個詞還是很貼切的。那麼,為什麼在2處能夠得到正確的this呢?涉及到上下文切換的地方,一共有3處,已在上面程式碼中標了出來。我總結了一個三步定位this法:

第一步,是否立即執行?如果是,跳過第二步!

第二步,如果不是立即執行,它一定會被轉交給某個物件保管,看它被掛在了哪,或者說轉交給了誰!

第三步,這個執行函式掛在誰身上,誰就是this!

我們來實踐一下。

第1處:

我們需要先簡單處理一下,debounce其實是掛在window全域性上的,寫全應該是window.debounce(handler, 500)。第一步,是立即執行的!跳過第二步!第三步,debounce掛在window上!所以this指向是window。

第2處:

先簡單處理下,debounce(handler, 500)的執行結果是返回一個函式,所以下面兩段程式碼基本上可以視為等價的

document.getElementById('input').addEventListener('input', debounce(handler, 500));

document.getElementById('input').addEventListener('input', function () { // 閉包
  // 2
  var ctx = this; // this上下文
  clearTimeout(timer);
  timer = setTimeout(function () {
    // 3
    fn.apply(ctx); // this上下文呼叫
  }, delay);
});

這麼一看,就具體多了。第一步,不是立即執行;第二步,addEventListener是掛在dom上的方法,所以addEventListener只能把回撥掛在dom上,可以理解成input.handler = function(){},等行為被觸發時才執行。所以它被轉交給了input;第三步,handler掛在input上,所以this指向了input!

第3處:

setTimeout是掛在window上的,所以在執行的時候,實際上是window.setTimeout()。我們用虛擬碼模擬下setTimeout的實現

window.setTimeout = function(fn, delay){
  // 因為不能立即執行,所以要找個地方掛fn,就只能把fn轉交給它的主子window
  // 假設window存fn的屬性叫setTimeoutHandler,與input.handler類似
  window.setTimeoutHandler = fn;
  // 等待delay毫秒……
  window.setTimeoutHandler(); // 執行
}

仔細理解一下,可以發現這裡跟dom的回撥非常像。第一步,不是立即執行;第二步,setTimeout是掛在window上的方法,所以只能轉交給window的某個方法保管(假設叫setTimeoutHandler,名字不重要);第三步,setTimeoutHandler掛在window上,所以this指向window。

穩妥起見,我們再加一個例子

var obj = {
  test: function(){
    console.log(this);
  }
}
obj.test(); // obj
setTimeout(obj.test,1000); // window

第一個很簡單,立即執行,不用轉交。直接可以定位this指向了obj!

第二個非立即執行,雖然傳進去的是obj.test,實際上需要轉交給window.setTimeoutHandler保管,即window.setTimeoutHandler = obj.test。所以this指向的是window!

總之,碰到非立即執行的函式,需要仔細分析一下。

debounce最終版

function debounce(fn, delay) {
  var timer;
  return function () { // 閉包
    var ctx = this; // this上下文
    var args = arguments;
    clearTimeout(timer);
    timer = setTimeout(function () {
      fn.apply(ctx, args); // this上下文呼叫
    }, delay);
  }
}

最後,我們再把傳參也解決一下(arguments是預設的儲存所有傳參的類陣列物件,時間關係這裡就不展開了),完成。

總結

debounce是一個很實用也很經典的功能函式,每一行程式碼都有豐富的內涵。與其類似的還有throttle,可以查查鞏固一下。本文主要是想借debounce這個實用的函式引出js當中的兩個比較難理解,的點this和閉包。說實話,這兩個點想講明白很難,更靠譜的辦法是用大量的實踐來消化。本文算是給各位同學種下一顆種子,以後碰到類似的情況時,能夠很快的想起本文的內容,幫助自己更好的理解與感悟。