沒穿方服

索引

顯示╱隱藏內文

事件處理的常見問題,短時間內事件發生太頻繁,接收端處理不了,卡死、噴錯或其他,要用一些手法來減壓。

日常使用 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 實作

所謂常見的實作,是指 LodashUnderscore.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 的 Function apply()。 (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 函數控制,增加一些變數(例如 busytimers)做狀態管理就好,這樣就能減少建立多餘函數。

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 實際寫起來會像:

(摘自 vim-lsp/documentation.vim
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 可以考慮一試。

以往在 Ubuntu 18.04 用 sudo vim 會吃到個人的 vimrc,但 Ubuntu 19 之後突然吃不到,變回裸體的 vim。

It works, I don't know why. It doesn't work, I don't know why.

下手診斷看 vim 裡面的 environ()、出來看 sudo sudo -Vsudo printenv HOME 發現 $HOME 有差異,Ubuntu 18 會沿用 sudo 之前的 HOME,19 則是 root。 以為自己有做什麼特殊設定,但找一陣子都沒東西,問 google 才知道原來是 Ubuntu 的 sudo 變更,見 How does sudo handle $HOME differently since 19.10? - Ask Ubuntu

連結的回答非常詳盡,以往 Ubuntu 的 sudo 一直是特殊 patch 版,會特別護送 HOME 這個變數到 sudo 後的世界。 其實是蠻奇怪的行為,甚至跟 man page 講的不一樣。


要維持 sudo vim 繼續用原本 user 的 HOME,原文也提供幾個解法(見 Preserving $HOME in Ubuntu 19.10 and later 段落),簡短摘要過來就是 sudo --preserve-env= 或改 /etc/sudoersDefaults env_keep +=,或者算了,改用 sudoedit

考量方便性我選擇改 sudoers 設定,但微調一下限制在特定 user(我自己)才生效,也比較沒有安全疑慮。

註:最好用 sudoedit /etc/sudoers.d/新檔案 動手,以免改壞重要檔案:

Defaults:bootleq env_keep += "HOME"

很少改 sudoers 檔案,讀 man sudoers 尤其 SUDOERS FILE FORMAT 章節與之一戰,每行設定大致會是 AliasesDefaultsUser specification 三類之一,這次就是改 Defaults,接冒號表示只影響特定 user,可惜無法同時限制只對 vim 生效。

也總算看懂 sudoers 格式,考慮加上 NOPASSWD 不再問密碼了(安全性會降低一些)。

bootleq ALL = (ALL:ALL) NOPASSWD: ALL

這行就是 User specification,定義為 User_Spec ::= User_List Host_List '=' Cmnd_Spec_List

  1. 首先 bootleqUser_List
  2. 第一個 ALLHost_List
  3. 等號後面整串是 Cmnd_Spec_List,它的定義是一或多組 Cmnd_Spec
    Cmnd_Spec 的定義又是 Cmnd_Spec ::= Runas_Spec? Option_Spec* Tag_Spec* Cmnd
    1. (ALL:ALL)Runas_Spec,冒號前後分別是 sudo 可以變成的 user / group
    2. 最後 NOPASSWD: ALL 則是 Tag_Spec

文件網站如 MDNMicrosoft Learn 有提供中文翻譯,有時候確實有幫助,像我就是看中文比較快。

但不是每個網頁都翻譯品質良好,差的時候,或者不確定差不差的時候就很尷尬,要切回原文確認,反而浪費時間。

有些領域則是已經習慣原文,但 google 搜到翻譯版連結,點進去反而陌生。

功能不親切的網站也是問題,例如 zh-hant.reactjs.org,要改語言就會離開目前主題(會跳去首頁)。

於是寫了一個瀏覽器套件 Delocalizer,它是 localize 的相反。

圖示是長得像丸子串的東西

提供一個工具列按鈕,「轉換目前頁面」會抓 URL 裡的 locale 部份(zh.xxx.comxxx.com/zh/yyy),把它拿掉,再把 accept-language 請求標頭改成 en,讓網站吐英文。

Firefox 版工具列按鈕

也可以設定 xx 網站自動轉換,或者從 google 點連結才自動轉換。 以下是選項畫面:

選項畫面

隨意感想:

重開電腦才發現 WSLg 終於下放給 Win 10 使用了(見 microsoft devblogs 文章),之前要跑 cypress 還要用 VcXsrv 搞個 X server 實在辛苦,我也是 Windows 支持者卻要受這等待遇。

那麼終於能用 Vim 的 sound_playfile('我的酷音效.wav') 了嗎? (自從 patch 8.1.1502 已經等了三年)

結果是可以發聲,但功能很有限。 我……很失望。

Vim help 舉的例子是播放 ubuntu 的系統音效,位置在 /usr/share/sounds/freedesktop/stereo,下面程式碼最後是能動的:

call sound_playfile('/usr/share/sounds/freedesktop/stereo/bell.oga')
call sound_playevent('bell')

但首先會遇上 wslg/issue #864,因為 WSLg 是在 PulseAudio 做整合而沒有打理 ALSA 底層,而 Vim +sound 看起來是跟 ALSA 溝通,所以會遇到沒有音效卡之類的問題: Vim has serveral errors when playing sound 解法如 issue 說的裝 libasound2 然後在 ~/.asoundrc 加入

pcm.default pulse
ctl.default pulse

重開 WSL 就能播放這兩個聲音了。

然而繼續試下去,發現只能播這些聲音啊,餵它別的音檔路徑是沒反應的。
原因應該是 +sound 在 ubuntu 的實作是基於 libcanberra,看它介紹是處理桌面音效的,所以不是系統主題的聲音,我可是不播的喔?

總之最後就放棄了,改用 PulseAudio 的 pactl,這個至少還堪用。

pactl upload-sample {PATH_TO_AUDIO_FILE} {SOME_ID}
pactl play-sample {SOME_ID}

104 會員中心的「工作」頁面(職缺搜尋),例如 pda.104.com.tw/work/mate/list,一堆公司不寫清楚在做什麼,看介紹才發現又是博奕,直接進黑名單。

但是內建的排除功能不太方便,所以寫了一支簡易自救;
將 bookmarklet 加進書籤 → 104 職缺 忽略特定公司

原始碼放在 bootleq/bookmarklets/blob/master/tw-104-mate-list.js
單純是用 MutationObserver,沒有很特別的地方。


其實 104 有提供排除功能(自訂配對條件才有),可以排除 20 個公司。 104 內建的排除功能