用 async 實作 debounce atomic 及其它使用技巧

最近碰了 indexed database,所以寫了很多非同步的函數。 在程式中因為 ui 和底層 api 的要求, 以非同步函數實作出了防彈跳、節流閥、原子操作, 也摸索了一些對非同步函數的使用技巧。 其實 async 和傳統的回呼函數的能力幾乎是相同的, 只是 async 讀寫都清楚、簡單很多。

從 sleep 開始的 async 生活

在 js 裡要將一個函數非同步執行,一切的起點就是 setTimeout 函數, 可以在時間到後,觸發回呼函數。 在 promise 和 async 的世界中,則可以實現一個類似的 sleep 函數, 會等待一定時間後再繼續。

function sleep(second) {
  return new Promise(wake => setTimeout(wake, second*1000))
}

sleep 就是簡單的把 setTimemout 包裝, 變成返回一個 promise 的函數。 我是習慣用秒記時,如果你比較喜歡原本的毫秒,也可以改回去。

debounce 防彈跳

debounce 是防彈跳,意思是如果有多個連續的小事件, 會等最後一個再一起送出。 像是使用者很急的時候,可能一個按鈕會按很多次, 如果沒做防彈跳的話,就會送出很多個事件, 防彈跳就是等到不再有連續事件後,再送出最後一個。 所以實作上防彈跳會設定一段延遲, 在延遲時間內不再有新事件後才送出。

例如做一個打字後即時搜尋的功能, 如果每打一個字就送出搜尋,會很浪費伺服器資源, 所以會等一段延時,確定你打完了才送出搜尋。

我的防彈跳函數,是在連續多次呼叫時, 只在最後一次實際執行。 用 async 實作的話,就是要保存一個時間戳記, 每次被呼叫時更新時間戳,並等待設定的延遲時間。 延遲時間過後,再比較儲存的時間戳和被呼叫時的時間戳是否一致, 不一致就代表在延遲等待的時間中,函數又被呼叫過了, 所以就不執行真正的操作。

let lastInputTime
const timeOut = 0.1 // second
async function handleInputDebounce() {
  const current = Date.now()
  lastInputTime = current
  await sleep(timeOut)
  if (lastInputTime != current) return
  search()
}

document.querySelector('#search').oninput = handleInputDebounce

throttle 節流閥

節流閥則是和防彈跳相反,只取第一個, 之後一段時間內的事件都阻擋掉。 像是一個按鈕按下去後,一定時間內重複按都無效。 像是一些表單為了防止重複提交, 在送出後會把按鈕設為 disabled。 這個函數其實可以不用 async,直接設一定時間後再把按鈕複原就好。

let isThrottle = false
function handleThrottleClick() {
  if (isThrottle) return
  isThrottle = true
  submit()
  setTimeout(() => isThrottle = false, timeOut)
  // or in async function
  // await sleep(timeOut); isThrottle = false
}

atomic lock

原本是要防止同時有二個函數對 indexdb 操作, 當時以為叫 atomic,後來想想這個作法應該叫 lock 才對。

由於 javascript 是單線程的, 應該不會有同時讀寫的問題,所以應該也不需要 lock。 但因為 callback 和 async 的存在, 所以可能一個函數托管了 callback 還沒被呼叫, 但另一個函數又開始執行了。 雖然 js 一次只能執行一側的程式, 但一側的 callback 還沒執行,另一側就開始執行時, 概念上就發生了重疊。

當時叫 atomic 是取同一個非同步函數要嘛還沒開始執行, 要嘛執行完了,不會有執行到一半, 另一個 async 函數也開始操作的情形。 因為保證沒有一半的狀態,所以叫 atomic。

let writeLock = false
async function handleWriteLock() {
  while (writeLock) {
    await sleep(0.1)
  }
  writeLock = true
  await write()
  writeLock = false
}

而且後來也發現,indexdb 內部對 write transaction 就有實現鎖了, 如果二個 write transaction 的範圍有重疊, 就只能照順序依序執行,二個 transaction 不會同時執行。 所以其實也不用擔心同時執行的問題。

while sleep 的無窮迴圈

上面的 lock 用到了一個不斷嘗試、等待的迴圈, 來在不同次呼叫間存取鎖。 其實這算是一個很原始的作法, 用在沒有事件傳遞或回呼函數時,監視狀態改變的作法。

雖然看起來是 while true 的迴圈, 會把單線程的 javascript 卡死, 但因為其中有一個 sleep,保證會用 setTimeout 交出控制權。 就算 timeout 設成 0,其實也不會馬上執行, 而是要等到下一個執行隊列。 造成一種不斷重複,卻又時時容許插入新任務的奇特情形, 猶如有理數的處處存在,卻又處處不連續。

除非你是用 Promise.resolve().then() , 因為 promise 的 then 規範是用 micro task 而不是 macro task, 就會被排在現在的執行隊列裡,造成執行隊列無法清空,無法結束迴圈。

async 函數與傳統回呼函數

上面雖然秀了很多花式的 async 函數寫法, 其它他們也都可以以傳統函數的方式達成, 只是一些 async 可以用迴圈判斷的部份, 在傳統函數就得改寫成遞迴。

var stop = false
async function loop() {
  while (!stop) {
    await sleep(0)
  }
  console.log('stop = true')
}

function recursion() {
  if (!stop) {
    setTimeout(recursion, 0)
  }
  else console.log('stop = true')
}

用遞迴的缺點,就是每碰到一個迴圈部份, 就得包成一個遞迴,所以程式的巢狀會越來越深。 也類似舊有的 callback hell。 而 async 就很好的消除了這點, 把 callback 壓平成單一的 statement。

而且不斷呼叫函數會造成 call stack 越來越長, 可能會 stack overflow, 雖然現在可以用 arrow function 保障消除尾端遞迴。

直接返回 promise 或顯式宣告為 async

就 javascript 的實作來說, async 函數就是返回一個 promise 的一般函數, 而 await 一個 async 函數也就是 await 該函數返回的 promise。 所以如果函數中要等待的 promise 只有一個, 那直接返回該 promise 即可,還可以少一層包裝, 至少我以前是這樣認為。

function fetchRoot() {
  return fetch('/')
}

但後來我有點改變想法,認為只要是返回 promise 的函數, 都應該顯示宣告為 async 函數。 因為這樣就能一眼看出一個函數是非同步的, 不然函數一多,很難看出哪些是一般函數, 但其實是返回 promise,需要 await。 就算是沒有返回值的 async 函數,也一樣 await。

async function fetchRoot() {
  return await fetch('/')
}

async function postJson(json) {
  await fetch('/api', {
    method: 'POST',
    body: json
  })
}

在 async 函數中非同步執行程式

有時候在 async 函數裡,因為執行順序, 不會想 await 每個 promise, 還是可以不用 await,而直接呼叫 promise 的 then 方法。 甚至把呼叫 then 後回傳的 promise 存起來, 在幾個控制結構後再 await。

而如果想在 then 裡的函數也使用 async await 語法, 可以直接把傳入 then 的函數也宣告成 async 函數, 這樣就能在 then 裡使用 async 語法了。 雖然這和 promise 在沒有 async 時的鏈式調用的寫法有點衝突, 但用起來是沒問題的。

async function handleMessage(message) {
  switch (message.type) {
  case 'save':
    const filename = message.filename || 'new-file.txt'
    const write = controller.read().then(async content => {
      if (await controller.check(content)) {
        await controller.post(content)
      }
    })
    return {filename, write}
  default:
    throw new Error('unknown message type: ' + message.type)
  }
}

另外,有時需要呼叫某個回呼形式的 api, 但只會用一次,另外包成一個非同步函數嫌麻煩, 當然可以直接在調用時宣告一個新的 promise, 然後直接 await 該結果。

async function communicatePort(port) {
  port.on('data', handleMessage)
  await new Promise(resolve => {
    port.on('close', resolve)
  })
}