【源碼分析】給你幾個鬧鐘,或許用 10 分鐘就能寫出 lodash 中的 debounce & throttle

相比網上教程中的 debounce 函數,lodash 中的 debounce 功能更爲強大,相應的理解起來更爲複雜;css

解讀源碼通常都是直接拿官方源碼來解讀,不過此次咱們採用另外的方式:從最簡單的場景開始寫代碼,而後慢慢往源碼上來靠攏,按部就班來實現 lodash 中的 debounce 函數,從而更深入理解官方 debounce 源碼的用意前端

爲了減小純代碼帶來的晦澀感,本文以圖例來輔助講解,一方面這樣能減小源碼閱讀帶來的枯燥感,同時也讓後續回憶源碼內容更加的具體形象。(記住圖的內容,後續再寫出源碼也變得簡單些)git

在本文的末尾還會附上簡易的 debounce & throttle 的實現的代碼片斷,方便平時快速用在簡單場景中,免去引用 lodash 庫。github

本文屬於源碼解讀類型的文章,對 debounce 還不熟悉的讀者建議先經過參考文章(在文末)瞭解該函數的概念和用法。

一、用圖例解析 debounce 源碼

附源碼 debounce: https://github.com/boycgit/ts...

首先搬出 debounce(防抖)函數的概念:函數在 wait 秒內只執行一次,若這 wait 秒內,函數高頻觸發,則會從新計算時間面試

看似簡單一句話,內含乾坤。爲方便行文敘述,約定以下術語:npm

  • 假定咱們要對 func 函數進行 debounce 處理,經 debounced 後的返回值咱們稱之爲 debounced func
  • wait 表示傳入防抖函數的時間
  • time 表示當前時間戳
  • lastCallTime 表示上一次調用 debounced func 函數的時間
  • lastInvokeTime 表示上一次 func 函數執行的時間
  • result 是每次調用 debounced func 函數的返回值
  • time 表示當前時間

本文將搭配圖例 + 程序代碼的方式,將上述概念具象到圖中。segmentfault

二、最簡單的案例

以最簡單的情景爲例:在某一時刻點只調用一次 debounced func 函數,那麼將在 wait 時間後纔會真正觸發 func 函數。緩存

將這個情景造成一幅圖例,最終繪製出的圖以下所示:性能優化

簡單場景下的圖例

下面咱們詳細講解這幅圖的產生過程,其實不難,基本上看一遍就懂。微信

首先繪製在圖中放置一個黑色鬧鐘表示用戶調用 debounced func 函數:(同時用 lastCallTime 標示出最近一次調用 debounced func 的時間)

繪製黑色鬧鐘表示調用 debounced func

同時在距離該黑色鬧鐘 wait 處放置一個藍色鬧鐘,表示setTimout(..., wait),該藍色鬧鐘表示將來當代碼運行到該時間點時,須要作一些判斷:

放置一個藍色鬧鐘

爲了標示出表示程序當前運行的進度(當前時間戳),咱們用橙紅色滑塊來表示:

橙紅色表示當前時間戳

當紅色滑塊到達該藍色鬧鐘處的時候,藍色鬧鐘會進行判斷:由於當前滑塊距離最近的黑色鬧鐘的時間差爲 wait

判斷時間差爲 wait

故而作出判斷(依據 debounce 函數的功能定義):須要觸發一次 func 函數,咱們用紅色鬧鐘來表示 func 函數的調用,因此就放置一個紅色鬧鐘

放置紅色鬧鐘,表示 func 函數被調用

很顯然藍色和紅色鬧鐘重疊起來的。

同時咱們給紅色鬧鐘標上 lastInvokeTime,記錄最近一次調用 func 的時間:

給紅色鬧鐘標上 lastInvokeTime

注意 lastInvokeTimelastCallTime 的區別,二者含義是不同的

這樣咱們就完成了最簡單場景下 debounce 圖例的繪製,簡單易懂。

後續咱們會逐漸增長黑色鬧鐘出現的複雜度,不斷去分析紅色鬧鐘的位置。這樣就能將理解 debounce 源碼的問題轉換成「根據圖上黑色鬧鐘的位置,請畫出紅色鬧鐘位置」的問題,而分析紅色鬧鐘位置的過程當中也就是理解 debounce 源碼的過程;

用圖例方式輔助理解源碼的方式能夠減小源碼閱讀帶來的枯燥感,同時後續回憶源碼內容起來也更加具體形象。

爲避免後續寫文章處處解釋圖中元素的概念含義,這裏不妨先羅列出來,若是閱讀過程當中忘記到這裏回憶一下也會方便許多:

  • 橫線表明時間軸,橙紅色滑塊表明當前時間 time
  • 每一個黑色箭頭表示 debounced func 函數的調用
  • 黑色鬧鐘表示調用 debounced func 函數時的時間,最後一次黑色鬧鐘上標上 lastCallTime,表示最近一次調用的時間戳;
  • 紅色鬧鐘表示調用 func 函數的時間,最後一次紅色鬧鐘上標上 lastInvokeTime,表示最近一次調用的時間戳;
  • 此外還有一個藍色鬧鐘,表示 setTimeout 時間戳(用來規劃 func 函數執行的時間),每次時間軸上的橙紅色滑塊到這個時間點就要作判斷:是執行 func 或者推遲藍色鬧鐘位置

有關藍色鬧鐘,這裏有兩個注意點:

  1. 時間軸上最多同時只有一個藍色鬧鐘
  2. 只有在第一次調用 debounced func 函數時纔會在 wait 時間後放置藍色鬧鐘,後續鬧鐘的出現位置就由藍色鬧鐘本身決策(下文會舉例說明)

三、有 N 多個黑色鬧鐘的場景

如今咱們來一個稍微複雜的場景:

假如在 wait 時間內(記住這個前提條件)調用 n 次 debounced func 函數,以下所示:

調用 n 次codedebounced func/code 函數

第一次調用 debounced func 函數會在 wait 時間後放置藍色鬧鐘(只有第一次調用會放置藍色鬧鐘,後續鬧鐘的位置由藍色鬧鐘本身決策):

放置藍色鬧鐘

以上就是描述,那麼問題來了:請問紅色鬧鐘應該出如今時間軸哪一個位置?

3.一、分析紅色鬧鐘出現的位置

咱們只關注最後一個黑色鬧鐘,並假設藍色鬧鐘距離該黑色鬧鐘時間間隔爲 x

假設兩鬧鐘距離 x

那麼第一個黑色鬧鐘和最後一個黑色鬧鐘的時間間隔是 wait - x

兩個黑鬧鐘間距

接下來咱們關注橙紅色滑塊(即當前時間time)到達藍色鬧鐘的時,藍色鬧鐘開始作決策:計算可知 x < wait,此時藍色鬧鐘決定不放置紅色鬧鐘(即不觸發 func),而是將藍色鬧鐘日後挪了挪,挪動距離爲 wait - x,調整完以後的藍色鬧鐘位置以下:

調整後藍色鬧鐘位置

之因此挪 wait - x 的距離,是由於挪完後的藍色鬧鐘距離最後一個黑色鬧鐘剛好爲 wait 間隔(從而保證 debounce 函數至少間隔 wait 時間 才觸發的條件):

保證挪完後的藍色鬧鐘距離最後一個黑色鬧鐘剛好爲 codewait/code 間隔

從挪移以後開始,到下一次橙色鬧鐘再次遇到藍色鬧鐘這段期間,咱們暫且稱之爲 」藍色決策間隔期「(請忍耐這抽象的名稱,畢竟我想了很久),藍色鬧鐘基於此間隔期的內容來進行決策,只有兩種決策:

  1. 若是在」藍色決策間隔期「內沒有黑鬧鐘出現,那麼紅色滑塊達到藍色鬧鐘的時候,藍色鬧鐘計算獲知當前藍色鬧鐘距離上一個黑色鬧鐘的時間間隔很多於 waittime - lastCallTime >= wait),那就會放置紅色鬧鐘(即調用 func),目標達成;

」藍色決策間隔期「內沒有黑鬧鐘出現,則能夠直接放置紅色鬧鐘

  1. 若是在」藍色決策間隔期「內仍舊有黑鬧鐘出現,那麼當橙紅色滑塊到達藍色鬧鐘時,藍色鬧鐘又會從新計算與該間隔期內最後一隻黑色鬧鐘的距離 y,隨後 又會日後挪動位置 wait-y,再一次保證藍色鬧鐘距離最後一個黑色鬧鐘剛好爲 wait 間隔 —— 沒錯,又造成了新的 」藍色決策間隔期「;那接下去的分析就又回到了 這裏兩點(即遞歸決策),直到能放置到紅鬧鐘爲止。

從新造成」藍色決策間隔期「

從上咱們能夠看到,藍色鬧鐘一直保持 」紳士「 風範,隨着黑色鬧鐘的逼近,藍色鬧鐘一直保持」剋制「態度,不斷調整本身的位置,讓調整後的位置老是和最後一個黑色鬧鐘保持 wait 的距離。

3.二、用代碼描述圖例過程

咱們用代碼將上述的過程描述出來,就是下面這個樣子:

function debounce(func, wait, options) {
  var lastArgs, lastThis, result, timerId, lastCallTime, lastInvokeTime = 0, trailing = true;
  
  wait = toNumber(wait) || 0;  

  // 紅色滑塊達到藍色鬧鐘時,藍色鬧鐘根據條件做出決策
  function timerExpired() {
    var time = now();

    // 決策 1: 知足放置紅色鬧鐘的條件,則放置紅鬧鐘
    if (shouldInvoke(time)) {
      return trailingEdge(time);
    }
    // 不然,決策 2:將藍色鬧鐘再日後挪 `wait-x` 位置,造成  」藍色決策間隔期「
    timerId = setTimeout(timerExpired, remainingWait(time));
  }

  // === 如下是具體決策中的函數實現 ==== 
   // 作出 」應當放置紅色鬧鐘「 的決策的條件:藍色鬧鐘和最後一個黑色鬧鐘的間隔不小於 wait 間隔
  function shouldInvoke(time) {
    var timeSinceLastCall = time - lastCallTime;
    return (
      timeSinceLastCall >= wait
    );
  }

  // 具體函數:放置紅色鬧鐘
  function trailingEdge(time) {
    timerId = undefined;
    
    if (trailing && lastArgs) {
      return invokeFunc(time);
    }
    lastArgs = lastThis = undefined;
    return result;
  }
  // 具體函數 - 子函數:在時間軸上放置紅鬧鐘
  function invokeFunc(time) {
    var args = lastArgs,
      thisArg = lastThis;

    lastArgs = lastThis = undefined;
    lastInvokeTime = time;
    result = func.apply(thisArg, args);
    return result;
  }  
  
  // 具體函數:計算讓藍色鬧鐘日後挪 wait-x 位置
  function remainingWait(time) {
    var timeSinceLastCall = time - lastCallTime,
      timeWaiting = wait - timeSinceLastCall;

    return timeWaiting ;
  }  
  // ==============


 // 主流程:讓紅色滑塊在時間軸上前進(即 debounced func 函數的執行)
 function debounced() {
    var time = now();
    lastArgs = arguments;
    lastThis = this;
    lastCallTime = time;

    if (timerId === undefined) {
      timerId = setTimeout(timerExpired, wait);
    }
    return result;
  }
  return debounced;
}

這部分代碼還請不要略過,由於代碼是從debounce源碼中整理過來,除了函數順序略有調整外,源碼風格保持原有的,至關於直接閱讀源碼。每一個函數都有註釋,對比着圖例閱讀下來相信讀完會有收穫的。

上述這份代碼已經包含了 debounce 源碼的核心骨架,接下來咱們繼續擴展場景,將源碼內容豐滿起來。

四、豐富功能特性

4.一、支持 leading 特性

leading 功能簡單理解就是,在第一次(注意這個條件)放下黑色鬧鐘的時候:

  1. 當即放置紅鬧鐘,同時在
  2. 此後 wait 處放置方式藍色鬧鐘(注:第一次放下黑色鬧鐘的時候,按理說也會在 wait 處放下藍色鬧鐘,考慮既然 leading 也有這種操做,那麼就很少此一舉。記住:整個時間軸上最多隻能同時有一個藍色鬧鐘

用圖說話:

支持 leading 功能

第一次放置黑色鬧鐘的時候,會疊加上紅色鬧鐘(固然這個紅色鬧鐘上會標示 lastInvokeTime),另外在 wait 間隔後會有藍色鬧鐘。其餘流程和以前案例分析同樣。

在代碼層面,咱們給剛纔的 debounce 函數添加 leading 功能(經過 options.leading 開啓)、新增一個 leadingEdge 方法後,再微調剛纔的代碼:

function debounce(func, wait, options) {
  ...
  
  var leading = false; // 默認不開啓
  leading = !!options.leading; // 經過 options.leading 開啓
  
  ...
  
  // 首先:新增執行 leading 處的操做的函數
  function leadingEdge(time) {
    lastInvokeTime = time; // 設置 lastInvokeTime 時間標籤
    timerId = setTimeout(timerExpired, wait); // 同時在此後 `wait` 處放置一個藍色鬧鐘
    return leading ? invokeFunc(time) : result; // 若是開啓,直接放置紅色鬧鐘;不然直接返回 result 數值
  }
  ...
  
  // 其次:給放置紅色鬧鐘新增一種條件
   function shouldInvoke(time) {
    ...
    return (
      lastCallTime === undefined || // 初次執行時
      timeSinceLastCall >= wait // 或者前面分析的條件,兩次 `debounced func` 調用間隔大於 wait 
    );
  }
  
   // 注意:放置完紅色鬧鐘後,記得要清空 timerId,至關於清空時間軸上藍色鬧鐘;
  function trailingEdge(time) {
    timerId = undefined;
    ... 
  }
  
  // 最後:leading 邊界調用
  function debounced(){
    ...
    var isInvoking = shouldInvoke(time); //  判斷是否能夠放置紅色鬧鐘
    
    ...
    
    if (isInvoking) { // 若是能夠放置紅色鬧鐘
      
      if (timerId === undefined) { // 且當時間軸上沒有藍色鬧鐘
        // 執行 leading 邊界處操做(放置紅色鬧鐘 或 直接返 result)
        return leadingEdge(lastCallTime);
      }
      
    }
    
    ...
    return result;
  }

  return debounced;
}

4.二、支持 maxWait 特性

要理解這個 maxWait 特性,咱們先看一種特殊狀況,在 {leading: false} 下, 時間軸上咱們很密集地放置黑色鬧鐘:

按以前的所述規則,咱們的藍色鬧鐘一直保持紳士態度,隨着黑色鬧鐘的逼近,藍色鬧鐘將不斷將調整本身的位置,讓本身調整後的位置老是和最後一個黑色鬧鐘保持 wait 的距離:

密集的黑色鬧鐘將會讓藍色鬧鐘無處安放

那麼在這種狀況下,若是黑色鬧鐘一直保持這種密集放置狀態,理論上就紅色鬧鐘就沒有機會出如今時間軸上。

那在這種狀況下可否實現一個功能,不管黑色鬧鐘多麼密集,時間軸上最多隔 maxWait 時間就出現紅色鬧鐘,就像下圖那樣:

使用 maxWait 保證紅色鬧鐘能出現

有了這個功能屬性後,藍色鬧鐘今後 」變得堅強「,也有了 "底線",縱使黑色鬧鐘的不斷逼近,也會堅守 maxWait 底線,到點就放置紅色鬧鐘。

實現該特性的大體思路以下:

  1. maxWait 是與 lastInvokeTime 共同協做
  2. 在藍色鬧鐘計算後退距離時,maxWait 發揮做用;在沒有 maxWait 的時候,是按上一次黑色鬧鐘進行測距,保證調整後的藍色鬧鐘和黑色鬧鐘保持 wait 的距離;而在有了 maxWait 後,藍色鬧鐘調整距離還會考慮上一次紅色鬧鐘的位置,保持調整後鬧鐘的位置和紅色鬧鐘距離不能超過 maxWait,這就是底線了,到了必定程度,就算黑色鬧鐘在逼近,藍色鬧鐘也不會 」退縮「:

受到 maxWait 影響,藍色鬧鐘的位置有了 」底線「

從代碼層面上看, maxWait 具體是在 remainingWait 方法 和 shouldInvoke 中發揮做用的:

function debounce(func, wait, options) {
  ...
  
  var lastInvokeTime = 0; // 初始化
  var maxing = false; // 默認沒有底線
  
  maxing = 'maxWait' in options;
  maxWait = maxing
      ? nativeMax(toNumber(options.maxWait) || 0, wait)
      : maxWait; // 從 options.maxWait 中獲取底線數值
  
  ...
  // 首先,在在藍色鬧鐘決策後退多少距離時,maxWait 發揮了做用
  function remainingWait(time) {
    var timeSinceLastCall = time - lastCallTime,
      timeSinceLastInvoke = time - lastInvokeTime,
      timeWaiting = wait - timeSinceLastCall;

    // 在這裏發揮做用,保持底線
    return maxing
      ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting;
  }
  
  ...

  
  // 其次:針對 `maxWait`,給放置紅色鬧鐘新增一種可能條件
   function shouldInvoke(time) {
    ...
    var timeSinceLastInvoke = time - lastInvokeTime; // 獲取距離上一次紅色鬧鐘的時間間隔
    return (
      lastCallTime === undefined || // 初次執行時
      timeSinceLastCall >= wait ||  // 或者前面分析的條件,兩次 `debounced func` 調用間隔大於 wait 
      (maxing && timeSinceLastInvoke >= maxWait) // 兩次紅色鬧鐘間隔超過 maxWait
    );
  }
  
  
  // 最後:leading 邊界調用
  function debounced(){
    ...
    var isInvoking = shouldInvoke(time); //  判斷是否能夠放置紅色鬧鐘的條件
    
    ...
    
    if (isInvoking) { // 若是能夠放置紅色鬧鐘
      
      ...
      // 邊界狀況的處理,保證在緊 loop 中能正常保持觸發
      if (maxing) {
        timerId = setTimeout(timerExpired, wait);
        return invokeFunc(lastCallTime);
      }
      
    }
    
    ...
    return result;
  }

  return debounced;
}

所以,maxWait 可以讓紅色鬧鐘保證在 maxWait 間隔內至少出現 1 次;

4.三、支持 cancel / flush 方法

這兩個函數是爲了能隨時控制 debounce 的緩存狀態;

其中 cancel 方法源碼以下:

//  取消防抖
  function cancel() {
    if (timerId !== undefined) {
      clearTimeout(timerId);
    }
    lastInvokeTime = 0;
    lastArgs = lastCallTime = lastThis = timerId = undefined;
  }

調用該方法,至關於直接在時間軸上去除藍色鬧鐘,這樣紅色方塊(時間)就永遠碰見不了藍色鬧鐘,那樣也就不會有放置紅色鬧鐘的可能了。

其中 flush 方法源碼以下:

function flush() {
  return timerId === undefined ? result : trailingEdge(now());
}

很是直觀,調用該方法至關於直接在時間軸上放置紅色鬧鐘。

至此,咱們已經完整實現了 lodash 的 debounce 函數,也就至關於閱讀了一遍其源碼。

五、實現 throttle 函數

在完成上面 debounce 功能和特性後(尤爲是 maxWait 特性),就能借助 debounce 實現 throttle 函數了。

throttle 源碼 就能明白:

function throttle(func, wait, options) {
  var leading = true,
      trailing = true;
  // ...
  return debounce(func, wait, {
    'leading': leading,
    'maxWait': wait,
    'trailing': trailing
  });
}

因此在 lodash 中,只須要 debounce 函數便可,throttle 至關於 」充話費「 送的。

至此,咱們已經解讀完 lodash 中的 debounce & throttle 函數源碼;

最後附帶一張 lodash 實現執行效果圖,用來自測是否真的理解通透:

loadash 執行效果圖

注:此圖取自於文章《 聊聊lodash的debounce實現

六、小結

在前端領域的性能優化手段中,防抖(debounce)和節流(throttle)是必備的技能,網上隨便一搜就有不少文章去分析解釋,不乏優秀的文章使用 圖文混排 + 類比方式 深刻淺出探討這兩函數的用法和使用場景(見文末的參考文檔)。

那我爲何還要寫這一篇文章?

緣起前兩天手動將 lodash 中的 debouncethrottle 兩個函數 TS 化的需求,而平時我也只是使用並無在乎它們真正的實現原理,所以在遷移過程我順帶閱讀了一番 lodash 中這兩個函數的源碼。

具體緣由和遷移過程請移步《 技巧 - 快速 TypeScript 化 lodash 中的 throttle & debounce 函數

本文嘗試提供了另外一個視角去解讀,經過時間軸 + 鬧鐘圖例 + 代碼的方式來解讀 lodash 中的 debounce & throttle 源碼;
整個流程下來只要理解了黑色、藍色、紅色這 3 種鬧鐘的關係,那麼憑着理解力去實現簡版 lodashdebounce 函數並不是難事。

固然上述的敘述中,略過了不少細節和存在性的判斷(諸如 timeId 的存在性判斷、isInvoking的出現位置等),省略這主要是爲了下降源碼閱讀的難度;(實際中這些細節的處理有時候反而很重要,是代碼健壯性不可或缺的一部分)

但願本文能對讀者理解 lodash 中的 debounce & throttle 源碼有些許的幫助,歡迎隨時關注微信公衆號或者技術博客留言交流。

【附】代碼片斷

若是在你僅僅須要應付簡單的一些場景,也能夠直接使用下方的代碼片斷。

A. 簡易 debounce - 只實現 trailing 狀況

防抖函數的概念:函數在 n 秒內只執行一次,若這 n 秒內,函數高頻觸發,則會從新計算時間

將這段話翻譯成代碼,你會發現並不難:

//防抖代碼最簡單的實現
function debounce(func, wait) {
  let timerId, result;

  return function() {
    if(timerId){
      clearTimeout(timerId);  //  每次觸發 都清除當前timer,從新設置時間
    }
    
    timerId = setTimeout(function(){
     result = func.apply(this, arguments);
    }, wait);
    
    return result;
  }
}
  • debounce 返回閉包(匿名函數)
  • 假如調用該閉包兩次:

    • 若是調用兩次間隔 < wait 數值,先前調用會被 clearTimeout ,也就不執行;最終只執行 1 次調用(即第 2 次的調用)
    • 若是調用兩次間隔 > wait 數值,當執行 clearTimeout 的時候,前一次調用已經執行了;因此最終這兩次調用都會執行

不一樣間隔下調用 2 次最終觸發函數狀況不同

上述的實現,是最經典的 trailing 狀況,即以 wait 間隔結束點做爲函數調用計時點,是咱們平時用的最多的場景

B. 簡易 debounce - 只實現 leading 功能

另外用得比較多的就是以 wait 間隔開始點做爲函數調用計時點,即 leading 功能。

將上面代碼中最後的 setTimeout 內容改爲 timerId = undefined ,而將 fn.apply 提取出來加個 if 條件語句就行 ,修改後代碼以下:

//防抖代碼最簡單的實現
function debounce(func, wait) {
  let timerId, result;

  return function() {
    if(timerId){
      clearTimeout(timerId);  //  每次觸發 都清除當前timer,從新設置時間
    }
    
    if(!timerId){
      result = fn.apply(this, arguments);
    }
    
    timerId = setTimeout(function() {
        timerId = undefined;
    }, wait);
    
    return result;
  }
}
fn.apply(lastThis, lastArgs) 之因此用 if 條件包裹,是針對首次調用的邊界狀況
  • debounce 仍舊返回閉包(匿名函數)
  • timerId 是閉包變量,至關於標誌位,經過它能夠知道某個函數的調用是否在上一次函數調用的影響範圍內
  • 假如調用該閉包兩次:

    • 若是調用兩次間隔 < wait 數值,後調用由於仍在前一次的 wait 影響範圍內,因此會被 clearTimeout 掉;最終只執行 1 次調用(即第 1 次的調用)
    • 若是調用兩次間隔 > wait 數值,當執行第二次時 timerId 已是 underfined 的,因此會當即執行 函數,因此最終這兩次調用都會執行

不一樣間隔下調用 2 次最終觸發函數狀況不同

C. 簡易 throttle 函數

throttle 函數的概念:函數在 n 秒內只執行一次,若這 n 秒內還在有函數調用的請求都直接被忽略掉

實現原理也很簡單:定義開關變量 canRun,在定時開啓的這段時間內控制這個開關變量爲canRun = false上鎖),執行完後才讓 canRun = true 便可。

function throttle(func, wait) {
    let canRun = true
    return function () {
      if (!canRun) {
        return  // 若是開關關閉了,那就直接不執行下邊的代碼
      }
      canRun = false // 持續觸發的話,run一直是false,就會停在上邊的判斷那裏
      setTimeout(() => {
        func.apply(this, arguments)
        canRun = true // 定時器到時間以後,會把開關打開,咱們的函數就會被執行
      }, wait)
    }
  }

參考文章

下面的是個人公衆號二維碼圖片,歡迎關注交流。
我的微信公衆號

相關文章
相關標籤/搜索