事件處理的常見問題,短時間內事件發生太頻繁,接收端處理不了,卡死、噴錯或其他,要用一些手法來減壓。
日常使用 Vim 會碰到嗎?幾乎不會,多半 plugin 會自己照顧自己,其次是症狀不夠嚴重,所以無感;最後是真的爆了,也懶得追怎麼回事,quit 重開復原,那些殺不死我的,我就不修。
然而 autocmd 越寫越多,各種自動化疊來疊去,看似無害的事件(例如 User xxx
)也可能在跑某個 script 時(例如 :source $VIMRUNTIME/syntax/hitest.vim
)突然爆發——幸好要解決也不難,就是用 timer_*
系列函數。
辨別 debounce / throttle
不就是要抑制事件的觸發嗎?為何變成只有 debounce / throttle 兩個選項呢?
其實根據需求,會有多樣的操作才對,例如 ReactiveX 定義的各種 filter 運算就可以參考(只是借用,概念不盡相同); 而一般應用上,最直觀以「一段時間」為限,降低頻率的作法,就能歸納為 throttle。
至於 debounce 就比較難翻譯,或說看字面難以想像,先來考察用語來源:
- 最早來自電路開關的彈跳 (bounce) 現象,要判斷是實際輸入,還是零件振動的雜訊,解法不只一種。 wikipedia: Switch
- 承上,一種解法:訊號發生後,短時間內的訊號先不理會,然後就能正確判定了——常見例子是鍵盤。 wikipedia: Keyboard
-
JavaScript 開發者的引用,意思是將多個連續發生的訊號「合併」為一個訊號,見 Debouncing Javascript Methods | John Hann:
Debouncing means to coalesce several temporally close signals into one signal.
具體實作會設定一段時間,期間內再度發生事件的話,就重新計時,直到超過這段時間都沒有發生,才認定整個事件完成。
我自己看這個詞,在程式領域的用法已經特化了——主要概念仍是消除不穩定,而作法是等,特徵是等的時間可以延長,描述為一個意象的話,是等一個振動的東西穩定下來。
這個認知下,即便 Lodash 的 throttle 是用 debounce 做的(採 maxWait
= wait
的設定),也不會說 throttle 是 debounce 的一種,反而應該說不能延長 wait 的用法其實指的是 throttle。
模仿常見 JavaScript 實作
所謂常見的實作,是指 Lodash、Underscore.js 等 library 提供的 higher-order function,接受函數為參數,回傳另一個 debounced 版函數的這種設計,以下用 Vim script 實作。
要拿來測試用的隨意 timeout 和 Say 函數:
let g:TEST_TIMEOUT = 5000 function! Say(...) echomsg printf('=> Saying: %s (at %s)', string(a:000), localtime()) endfunction
實際 call Say('my name')
會輸出
=> Saying: ['my name'] (at 1675687185)
Vim 的 Throttle
function! Throttle(fn, timeout) let o = {} let busy = 0 function o.unlock(id) closure let busy = 0 echomsg 'T: unlocked' endfunction function o.run(...) closure if !busy call timer_start(a:timeout, o.unlock) let busy = 1 call call(a:fn, a:000) else echomsg 'T: throttled' endif endfunction return o.run endfunction
實際用用看
let ThrottledSay = Throttle('Say', g:TEST_TIMEOUT) " For manual test nnoremap <F5> <Cmd>call ThrottledSay('my name', 'Throttle')<CR>
第一次執行會輸出
=> Saying: ['my name', 'Throttle'] (at 1675688797)
立刻再執行會輸出 T: throttled
(第一次執行的)五秒後會輸出 T: unlocked
然後回到一開始的狀態。
解釋
-
timer_start({time}, {callback})
Vim 的 timer 功能,最早在 2016 年的 Vim 7.4.1578 開始支援,會在
time
毫秒之後執行callback
。 (doc) -
o.unlock
這邊 callback 特別用
dict
function 形式來寫,以避開一個煩人限制,就是函數名稱必須以大寫字母或s:
開頭,導致很難寫出 scope 只在function
內的函數變數,解法之一就是這個也蠻怪異的 dictionary function,這裡的o
只是一個 wrapper dictionary,專門用來放 run 和 unlock 函數而已。 -
function closure
JavaScript 的版本是用 closure 來保存狀態,這裡也一樣,不過 Vim 的函數要加上
closure
關鍵字才會有 closure 性質。 -
call call
call()
就是 Vim 的 Functionapply()
。 (doc)
Vim 的 Debounce
function! Debounce(fn, timeout) let o = {} let timer = 0 function o.start(args, id) closure call call(a:fn, a:args) endfunction function! o.run(...) closure if timer call timer_stop(timer) echomsg 'D: debounced' else echomsg 'D: waiting' endif let timer = timer_start(a:timeout, function(o.start, [a:000])) endfunction return o.run endfunction
實際用用看
let DebouncedSay = Debounce('Say', g:TEST_TIMEOUT) " For manual test nnoremap <F6> <Cmd>call DebouncedSay('my name', 'Debounce')<CR>
第一次執行會輸出 D: waiting
立刻再執行會輸出 D: debounced 且五秒重新計算
五秒不動後會輸出
=> Saying: ['my name', 'Debounce'] (at 1675688903)
然後回到一開始的狀態。
解釋
-
timer_stop({timer})
停止之前用
timer_start()
開始的 timer,也就是取消掉,時間到了也不會做任何事。 (doc) -
function(o.start, [a:000])
function({name}, {arglist})
有給第二個參數的話,會形成一個 partial function,類似 functional programming 中 curry 的用法(參考 doc), 這邊用來把使用者給的實際參數a:000
綁到動態產生的o.start
上去。
實戰考量
這組函數的優點是用法類似 JavaScript 的常見 API,但本質上是動態生成函數,需要再建立變數存起來(又會碰到變數名稱一定要大寫開頭的問題)才能餵給 autocmd,因為 autocmd 後面只能接 {cmd}
而不能用匿名函數,這是比較麻煩的。
還有就是 vim script 動態建立 function 的效率是不好的,我自己測試生 20,000 個函數大概需要 0.5 秒的時間(通常不會一下建這麼大量就是了)。
其他方案
直接使用 timer 函數改實作
如果只有少數主要功能要處理(例如 Say()
),那可以不要用 higher-order function,直接在 Say
裡頭用 timer 函數控制,增加一些變數(例如 busy
、timers
)做狀態管理就好,這樣就能減少建立多餘函數。
Neovim / lua
Neovim API 已經有 uv module(底層是 libuv,跟 nodejs 做到 event loop 用的是一樣的東西),另外也可以用 lua 直接塞匿名函數給 autocmd,所以不理會 Vim 相容性的話,直接用 lua 寫就好了。 有看到一個 gist Neovim throttle & debounce 給了完整範例。
vim9script
不建議,雖然新的 function compile 也許會改善效能,但很多舊的限制都還在,更重要的是目前(vim 9.0.1291)還不是進場時機,語言本身還沒實作完(class、type 等),也會直接失去 Neovim 相容性。如果想用新語言,又想支援 Vim / Neovim,也許研究 denops.vim 還比較實在。
Callbag
這個我沒有深入研究,但看到 vim-lsp 在用 callbag.vim,也同時處理掉 debounce 需求。
Callbag 是一個比較輕量的 reactive / iterable programming 規範,引用 github callbag/callbag:
A standard for JS callbacks that enables lightweight observables and iterables
有了按規範實作的 debounceTime()
之後,在 vim-lsp 實際寫起來會像:
let s:Dispose = lsp#callbag#pipe( \ lsp#callbag#merge( \ lsp#callbag#pipe( \ lsp#callbag#fromEvent('CompleteChanged'), \ lsp#callbag#filter({_->g:lsp_completion_documentation_enabled}), \ lsp#callbag#map({->copy(v:event)}), \ lsp#callbag#debounceTime(g:lsp_completion_documentation_delay), \ lsp#callbag#switchMap({event-> \ lsp#callbag#pipe( \ s:resolve_completion(event), \ lsp#callbag#tap({managed_user_data->s:show_floating_window(event, managed_user_data)}), \ lsp#callbag#takeUntil(lsp#callbag#fromEvent('CompleteDone')) \ ) \ }) \ ), \ lsp#callbag#pipe( \ lsp#callbag#fromEvent('CompleteDone'), \ lsp#callbag#tap({_->s:close_floating_window(v:false)}), \ ) \ ), \ lsp#callbag#subscribe(), \ )
其實底層 debounce 的部分也是用 timer 函數去做,但這樣包裝就能用 functional、宣告式的寫法,在大型 plugin 可以考慮一試。