介紹 setTimeout 實現機制與原理,手寫一個實現

setTimeout 方法,就是一個定時器,用來指定某個函數在多少毫秒之後執行。它會返回一個整數,表示定時器的編號,同時你還可以通過該編號來取消這個定時器:

function showName(){ 
    console.log("Hello")
}
let timerID = setTimeout(showName, 1000);
// 在 1 秒後打印 “Hello”

setTimeout 的第一個參數是一個將被延遲執行的函數, setTimeout 的第二個參數是延時(多少毫秒)。

如果使用 setTimeout 延遲的函數需要攜帶參數,我們可以把參數放在 setTimeout 裏(放在已知的兩個參數後)來中轉參數給需要延遲執行的函數。

var timeoutID1 = setTimeout(function[, delay, arg1, arg2, ...]);
var timeoutID2 = setTimeout(function[, delay]);
var timeoutID3 = setTimeout(code[, delay]);

手寫一個簡單 setTimeout 函數

感謝 @coolliyong 的提醒,這裏調整一下

let setTimeout = (fn, timeout, ...args) ={
  // 初始當前時間
  const start = +new Date()
  let timer, now
  const loop = () ={
    timer = window.requestAnimationFrame(loop)
    // 再次運行時獲取當前時間
    now = +new Date()
    // 當前運行時間 - 初始當前時間 >= 等待時間 ===>> 跳出
    if (now - start >= timeout) {
      fn.apply(this, args)
      window.cancelAnimationFrame(timer)
    }
  }
  window.requestAnimationFrame(loop)
}

function showName(){ 
    console.log("Hello")
}
let timerID = setTimeout(showName, 1000);
// 在 1 秒後打印 “Hello”

注意:JavaScript 定時器函數像 setTimeoutsetInterval 都不是 ECMAScript 規範或者任何 JavaScript 實現的一部分。定時器功能由瀏覽器實現,它們的實現在不同瀏覽器之間會有所不同。 定時器也可以由 Node.js 運行時本身實現。

在瀏覽器裏主要的定時器函數是作爲 Window 對象的接口,Window 對象同時擁有很多其他方法和對象。該接口使其所有元素在 JavaScript 全局作用域中都可用。這就是爲什麼你可以直接在瀏覽器控制檯執行 setTimeout

在 node 裏,定時器是 global 對象的一部分,這點很像瀏覽器中的 Window 。你可以在 Node 裏看到定時器的源碼 這裏 ,在瀏覽器中定時器的源碼在 這裏 。

setTimeout 在瀏覽器中的實現

瀏覽器渲染進程中所有運行在主線程上的任務都需要先添加到消息隊列,然後事件循環系統再按照順序執行消息隊列中的任務。

在 Chrome 中除了正常使用的消息隊列之外,還有另外一個消息隊列,這個隊列中維護了需要延遲執行的任務列表,包括了定時器和 Chromium 內部一些需要延遲執行的任務。所以當通過 JavaScript 創建一個定時器時,渲染進程會將該定時器的回調任務添加到延遲隊列中。

源碼中延遲執行隊列的定義如下所示:

DelayedIncomingQueue delayed_incoming_queue;

當通過 JavaScript 調用 setTimeout 設置回調函數的時候,渲染進程將會創建一個回調任務,包含了回調函數 showName 、當前發起時間、延遲執行時間,其模擬代碼如下所示:

struct DelayTask{ 
    int64 id; 
    CallBackFunction cbf; 
    int start_time; 
    int delay_time;
};
DelayTask timerTask;
timerTask.cbf = showName;
timerTask.start_time = getCurrentTime(); //獲取當前時間
timerTask.delay_time = 200;//設置延遲執行時間

創建好回調任務之後,再將該任務添加到延遲執行隊列中,代碼如下所示:

delayed_incoming_queue.push(timerTask)

現在通過定時器發起的任務就被保存到延遲隊列中了,那接下來我們再來看看消息循環系統是怎麼觸發延遲隊列的。

void ProcessTimerTask(){
  //從delayed_incoming_queue中取出已經到期的定時器任務
  //依次執行這些任務
}

TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
  for(;;){
    //執行消息隊列中的任務
    Task task = task_queue.takeTask();
    ProcessTask(task);
    
    //執行延遲隊列中的任務
    ProcessDelayTask()

    if(!keep_running) //如果設置了退出標誌,那麼直接退出線程循環
        break; 
  }
}

從上面代碼可以看出來,我們添加了一個 ProcessDelayTask 函數,該函數是專門用來處理延遲執行任務的。這裏我們要重點關注它的執行時機,在上段代碼中,處理完消息隊列中的一個任務之後,就開始執行 ProcessDelayTask 函數。ProcessDelayTask 函數會根據發起時間和延遲時間計算出到期的任務,然後依次執行這些到期的任務。等到期的任務執行完成之後,再繼續下一個循環過程。通過這樣的方式,一個完整的定時器就實現了。

設置一個定時器,JavaScript 引擎會返回一個定時器的 ID。那通常情況下,當一個定時器的任務還沒有被執行的時候,也是可以取消的,具體方法是調用 clearTimeout 函數,並傳入需要取消的定時器的 ID 。如下面代碼所示:clearTimeout(timer_id) 其實瀏覽器內部實現取消定時器的操作也是非常簡單的,就是直接從 delayed_incoming_queue 延遲隊列中,通過 ID 查找到對應的任務,然後再將其從隊列中刪除掉就可以了。

來源:瀏覽器工程與實踐(極客時間):https://time.geekbang.org/column/article/134456

setTimeout 在 nodejs 中的實現

setTimeout 是在系統啓動的時候掛載的全局函數。代碼在 timer.js

function setupGlobalTimeouts() {
    const timers = NativeModule.require('timers');
    global.clearImmediate = timers.clearImmediate;
    global.clearInterval = timers.clearInterval;
    global.clearTimeout = timers.clearTimeout;
    global.setImmediate = timers.setImmediate;
    global.setInterval = timers.setInterval;
    global.setTimeout = timers.setTimeout;
  }

我們先看一下 setTimeout 函數的代碼。

function setTimeout(callback, after, arg1, arg2, arg3) {
  if (typeof callback !== 'function') {
    throw new errors.TypeError('ERR_INVALID_CALLBACK');
  }

  var i, args;
  switch (arguments.length) {
    // fast cases
    case 1:
    case 2:
      break;
    case 3:
      args = [arg1];
      break;
    case 4:
      args = [arg1, arg2];
      break;
    default:
      args = [arg1, arg2, arg3];
      for (i = 5; i < arguments.length; i++) {
        // extend array dynamically, makes .apply run much faster in v6.0.0
        args[i - 2] = arguments[i];
      }
      break;
  }
  // 新建一個對象,保存回調,超時時間等數據,是超時哈希隊列的節點
  const timeout = new Timeout(callback, after, args, false, false);
  // 啓動超時器
  active(timeout);
  // 返回一個對象
  return timeout;
}

其中 Timeout 函數在 lib/internal/timer.js 裏定義。

function Timeout(callback, after, args, isRepeat, isUnrefed) {
  after *= 1; // coalesce to number or NaN
  this._called = false;
  this._idleTimeout = after;
  this._idlePrev = this;
  this._idleNext = this;
  this._idleStart = null;
  this._onTimeout = null;
  this._onTimeout = callback;
  this._timerArgs = args;
  this._repeat = isRepeat ? after : null;
  this._destroyed = false;

  this[unrefedSymbol] = isUnrefed;

  this[async_id_symbol] = ++async_id_fields[kAsyncIdCounter];
  this[trigger_async_id_symbol] = getDefaultTriggerAsyncId();
  if (async_hook_fields[kInit] > 0) {
    emitInit(this[async_id_symbol],
             'Timeout',
             this[trigger_async_id_symbol],
             this);
  }
}

由代碼可知,首先創建一個保存相關信息的對象,然後執行 active 函數。

const active = exports.active = function(item) {
  // 插入一個超時對象到超時隊列
  insert(item, false);
}
function insert(item, unrefed, start) {
  // 超時時間
  const msecs = item._idleTimeout;
  if (msecs < 0 || msecs === undefined) return;
  // 如果傳了start則計算是否超時時以start爲起點,否則取當前的時間
  if (typeof start === 'number') {
    item._idleStart = start;
  } else {
    item._idleStart = TimerWrap.now();
  }
  // 哈希隊列
  const lists = unrefed === true ? unrefedLists : refedLists;
  var list = lists[msecs];
  // 沒有則新建一個隊列
  if (list === undefined) {
    debug('no %d list was found in insert, creating a new one', msecs);
    lists[msecs] = list = new TimersList(msecs, unrefed);
  }

 ...
  // 把超時節點插入超時隊列
  L.append(list, item);
  assert(!L.isEmpty(list)); // list is not empty
}

從上面的代碼可知,active一個定時器實際上是把新建的 timeout 對象掛載到一個哈希隊列裏。我們看一下這時候的內存視圖。

當我們創建一個 timerList 的是時候,就會關聯一個底層的定時器,執行 setTimeout 時傳進來的時間是一樣的,都會在一條隊列中進行管理,該隊列對應一個定時器,當定時器超時的時候,就會在該隊列中找出超時節點。下面我們看一下 new TimeWraper 的時候發生了什麼。

TimerWrap(Environment* env, Local<Object> object) : HandleWrap(env, object,reinterpret_cast<uv_handle_t*>(&handle_),AsyncWrap::PROVIDER_TIMERWRAP) {
    int r = uv_timer_init(env->event_loop()&handle_);
    CHECK_EQ(r, 0);
  }

其實就是初始化了一個 libuvuv_timer_t 結構體。然後接着 start 函數做了什麼操作。

static void Start(const FunctionCallbackInfo<Value>& args) {TimerWrap* wrap = Unwrap<TimerWrap>(args.Holder());

    CHECK(HandleWrap::IsAlive(wrap));

    int64_t timeout = args[0]->IntegerValue();
    int err = uv_timer_start(&wrap->handle_, OnTimeout, timeout, 0);
    args.GetReturnValue().Set(err);
  }

就是啓動了剛纔初始化的定時器。並且設置了超時回調函數是 OnTimeout 。這時候,就等定時器超時,然後執行 OnTimeout 函數。所以我們繼續看該函數的代碼。

const uint32_t kOnTimeout = 0;
  static void OnTimeout(uv_timer_t* handle) {
    TimerWrap* wrap = static_cast<TimerWrap*>(handle->data);
    Environment* env = wrap->env();
    HandleScope handle_scope(env->isolate());
    Context::Scope context_scope(env->context());
    wrap->MakeCallback(kOnTimeout, 0, nullptr);
  }

OnTimeout 函數繼續調 kOnTimeout ,但是該變量在 time_wrapper.c 中是一個整形,這是怎麼回事呢?這時候需要回 lib/timer.js 裏找答案。

const kOnTimeout = TimerWrap.kOnTimeout | 0;
// adds listOnTimeout to the C++ object prototype, as
// V8 would not inline it otherwise.
// 在TimerWrap中是0,給TimerWrap對象掛一個超時回調,每次的超時都會執行該回調
TimerWrap.prototype[kOnTimeout] = function listOnTimeout() {
  // 拿到該底層定時器關聯的超時隊列,看TimersList
  var list = this._list;
  var msecs = list.msecs;
  //
  if (list.nextTick) {
    list.nextTick = false;
    process.nextTick(listOnTimeoutNT, list);
    return;
  }

  debug('timeout callback %d', msecs);

  var now = TimerWrap.now();
  debug('now: %d', now);

  var diff, timer;
  // 取出隊列的尾節點,即最先插入的節點,最可能超時的,TimeOut對象
  while (timer = L.peek(list)) {
    diff = now - timer._idleStart;

    // Check if this loop iteration is too early for the next timer.
    // This happens if there are more timers scheduled for later in the list.
    // 最早的節點的消逝時間小於設置的時間,說明還沒超時,並且全部節點都沒超時,直接返回
    if (diff < msecs) {
      // 算出最快超時的節點還需要多長時間超時
      var timeRemaining = msecs - (TimerWrap.now() - timer._idleStart);
      if (timeRemaining < 0) {
        timeRemaining = 1;
      }
      // 重新設置超時時間
      this.start(timeRemaining);
      debug('%d list wait because diff is %d', msecs, diff);
      return;
    }

    // The actual logic for when a timeout happens.
    // 當前節點已經超時    
    L.remove(timer);
    assert(timer !== L.peek(list));

    if (!timer._onTimeout) {
      if (async_hook_fields[kDestroy] > 0 && !timer._destroyed &&
            typeof timer[async_id_symbol] === 'number') {
        emitDestroy(timer[async_id_symbol]);
        timer._destroyed = true;
      }
      continue;
    }
    // 執行超時處理
    tryOnTimeout(timer, list);
  }

由上可知, TimeWrapper.c 裏的 kOnTimeout 字段已經被改寫成一個函數,所以底層的定時器超時時會執行上面的代碼,即從定時器隊列中找到超時節點執行,直到遇到第一個未超時的節點,然後重新設置超時時間。再次啓動定時器。

參考:nodejs 之 setTimeout 源碼解析:https://zhuanlan.zhihu.com/p/60505970

來源:https://github.com/sisterAn/JavaScript-Algorithms

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-tSir-6zjHyy9Z8WLoyaKg