Skip to content
Go back
Table of Contents

從零開始的 neovim 配置3

Info

我的配置至今仍然積極的開發中, 時隔將近一年依舊頻繁的更改重構. 前幾篇博客裡的部分內容稍有過時, 請以最新代碼為準.

加載速度優化

nvim 的啟動速度比 vscode 等編輯器快得多, 但是在插件較多而且配置寫的不好的情況下很容易超過 100ms, 這也會有稍微的卡頓. 我做了不少工作使得 nvim 啟動幾乎不卡頓(在我的機器上為 20-40ms, 這個時間與機器有關, 也不穩定).

nvim 初始化時序

在完成 c 層初始化, lua 解釋器初始化和編輯器內置運行時初始化後, 配置入口被同步阻塞的執行. 然後進入事件循環, 從此開始異步執行. 在我的配置文件中大致邏輯是:

-- 最遲執行, 在 nvim 加載所有插件之後才運行.
vim.api.nvim_create_autocmd('User', {
    pattern = 'LazyVimStarted',
    callback = function()
        vim.schedule(function()
            require('modules.keymaps').setup()
            -- ...
            require('modules.patch').setup()
        end)
    end,
})

-- 最早執行.
require('modules.preload').setup()

-- 在 init.lua 同步阻塞執行的最後加載啟動 lazy.
local lazypath = vim.fn.stdpath 'data' .. '/lazy/lazy.nvim'
---@diagnostic disable: undefined-field
if not vim.uv.fs_stat(lazypath) then
    local lazyrepo = 'https://github.com/folke/lazy.nvim.git'
    local out = vim.fn.system { 'git', 'clone', '--filter=blob:none', '--branch=stable', lazyrepo, lazypath }
    if vim.v.shell_error ~= 0 then
        error('Error cloning lazy.nvim:\n' .. out)
    end
end ---@diagnostic disable-next-line: undefined-field
vim.opt.rtp:prepend(lazypath)

require('lazy').setup {
    -- ...
}

這三個階段中:

  • Preload 階段解析環境變量(見下文), 只進行一些數據的設置, filetype 註冊, 特殊的 filetype 解析等需要早期設置的內容.

  • Lazy 加載階段, Lazy 解析 Spec, 並加載必須早期加載的插件, 如 catppuccin.nvim(配色主題), alpha.nvim 等和 ui 相關的插件.

  • Lazy 加載後階段, 大部分自己實現的部分都可以延遲加載, 因此他們被放置在這裡, 並使用 vim.schedule 調度, 異步執行.

插件懶加載

插件懶加載是優化啟動速度的核心. 大部分啟動時間過長的原因都是在啟動階段加載的插件太多. 因此我們需要想盡辦法讓插件延遲到需要 的時候再加載. 當然, 不是所有的插件都可以儘量延遲的. 我把插件分成幾類, 分別設置不同的優先級.

  • 無延遲立即加載

    這些插件大多與 UI 相關, 比如 alpha.nvimcatppuccin.nvim. Dashboard 在 VimEnter 階段就需要加載, 而顏色主題插件 則必須在最早期加載, 因為幾乎所有的 UI 相關代碼都需要依賴顏色主題插件提供配色數據. 他們會在 startup, VimEnter, UIEnter 等階段加載, 此時 lazy 剛剛完成插件 spec 的解析.

  • VeryLazy

    這些插件大多都需要早期初始化, 但是優先級不如前者高. 例如 mason.nvim, which-key.nvim, nvim-treesitter 等. 另外 此時 nvim 已經進入異步執行階段, UI 循環也已經啟動. 因此可以安排初始化耗時較高的插件.

  • 文件加載驅動

    這些插件只有在 buffer 內才有作用, 例如 git-signs, blink.cmp, todo-comments.nvim 等. 這些插件大多被設置為 BufReadPreBufWinEnter 加載, 隨文件打開和 buffer 創建加載.

  • 第一次 require 啟動

    這些插件被設置為 lazy = true, 只有在第一次被 require 的時候才會被加載初始化. 常見於通過鍵位觸發的交互插件, 例如 fzf-lua.nvim, neo-tree.nvim, toggleterm.nvim 等.

Warning

初始化時序問題: 過於激進的懶加載優化會導致出現很多問題

涉及改變 Buffer 或窗口表現的插件(如 statuscol.nvim, oil.nvim 等)必須在啟動早期直接加載. 當通過命令行參數直接打開文件時, Neovim 建立窗口與打開 Buffer 的速度極快, 觸發事件非常早. 若將這些插件依賴於 BufReadPre 或 FileType 事件觸發會導致 「插件尚未註冊回調,事件就已結束」的時序錯誤,造成插件功能失效.

Mason 會注入運行時的 PATH, 如果在 BufReadPre 的時候才初始化, 就來不及添加 PATH, 導致一開始找不到 lsp, 需要手動啟動.

Note

oil.nvim 只有在打開目錄的時候需要極早的加載, 其他時候可以 lazy = true. 可以偷懶通過下面的代碼來延遲加載, 雖然只 在 nvim 沒有其他命令行參數啟動的時候才會懶加載, 但是基本足夠日常了.

  -- if dry startup, don't load it in early stage
  lazy = vim.fn.argc(-1) == 0,

延遲 call 和參數綁定

keymap 在聲明的時候不需要引入插件, 對相關插件的引用完全可以等到第一次觸發的時候. 因此需要一些巧妙的手段來延遲加載. 我們考慮 一個可以構造閉包的函數. 它能夠接受字符串作為 require 的模塊和需要調用的對象, 返回閉包. 這應對大部分場景足夠了.

function M.select(module, func)
    return function()
        require(module)[func]()
    end
end

lua 的符號定位機制由 require, [] 運算符組成, . 運算符是語法糖, : 運算符是針對 oop 自動處理 self 的語法糖. 其中 require 傳入的參數是文件路徑, 其使用 . 來分割路徑. [] 運算符負責從表裡取出字段, 例如:

-- 順序表
local li = { 1, 1, 4, 5, 1, 4 }
assert(li[1] == 1)
assert(li[4] == 5)

-- k-v 表
local tb = {
    foo = 'a',
    bar = 'b',
    ['foo-bar'] = 'baz',
}

assert(tb.foo == 'a')
assert(tb['bar'] == 'b')
assert(tb['foo-bar'] == 'baz')

因此我們可以對比下面幾種寫法:

require('foo.bar').baz()

require('foo').bar.baz()

require('foo')['bar']['baz']()

第一種寫法要求存在 foo/baz.lua, 或者為 foo/init.lua 設置 metatable. 第二種是在 foo 返回的表中尋找 bar.baz. 最後 一種與第二種等價, 但是使用字面常量尋找.

通常 require(‘foo.bar’) 和 require(‘foo’).bar 是不等效的. 前者是找文件, 後者是找 Table 裡的字段. 只有在 foo.lua 裡寫了 M.bar = require(‘foo.bar’) 時, 它們的行為才會看起來一致.

為了應對不同的需求, 尋找的參數可能是可變的. 因此改成了下面的變參形式.

function M.select(module, ...)
    local fields = { ... }
    return function()
        local mod = require(module)
        for i = 1, #fields - 1 do
            mod = mod[fields[i]]
        end
        mod[fields[#fields]]()
    end
end

但是依舊無法解決某些函數需要帶參數調用, 以及返回值問題. 而且 select 這個名字與 lua 本身的內置函數衝突. 最終的版本是這樣的

function M.thunk(module, ...)
    local fields = { ... }
    local n = #fields

    -- Early return when only one param is in the pack
    if n == 1 then
        local f = fields[1]
        return function(...)
            return require(module)[f](...)
        end
    end

    return function(...)
        local t = require(module)
        for i = 1, n - 1 do
            t = t[fields[i]]
        end
        return t[fields[n]](...)
    end
end

local unpack_impl = table.unpack or unpack

function M.bind(fn, ...)
    local args = { ... }
    local n = select('#', ...)

    return function()
        return fn(unpack_impl(args, 1, n))
    end
end

thunk 用於返回延遲加載調用的閉包, 這個函數接受參數, 並能正常的返回值. bind 用於綁定參數, 生成帶有指定參數的閉包(事實 上就是柯里化過程). 使用示例如下, 它可以生成對 neotree 插件內函數的延遲調用, 並傳遞一些參數.

vim.keymap.set(
    'n',
    '<leader>o',
    bind(thunk('neo-tree.command', 'execute'), { action = 'show', source = 'document_symbols', toggle = true }),
    {
        desc = 'Toggle [O]utline',
        noremap = true,
        silent = true,
    }
)

設計理念和架構

為了實現更多的功能, 我閱讀了不少不同的 nvim 配置. 其中比較吸引我的有 kicamon, Abel2333, lingshix 以及 lxymahatma 的配置. 看了很多配置之後我認為還是有必要給 nvim 配置劃定一個清晰的職責邊界和設計準則, 否則總是會考慮加進去過多無用或用的太少的 功能.

低開銷重啟, 初始化即不可變: 在剛接觸 nvim, 使用 astronvim 的時候我就發現稍微複雜一些 nvim 配置就會偶爾崩壞, 必須重啟 才能恢復. 而且涉及熱重載的過程幾乎總是出問題. 在研究了 nvim 的源碼之後我發現這個 nvim lua 運行時, 還有所有的插件和代碼 都運行在同一個 lua 虛擬機上, 共享所有的狀態, 毫無沙箱隔離. 因此在寫新配置的時候我就不考慮熱重載的功能, 必須重新啟動. 為了實現低開銷重啟, 我引入會話管理插件 persistence.nvim, 可以恢復對應 cwd 上次打開的會話, 加上高度優化的啟動速度, 修改 配置之後重載的開銷就非常小了.

設計

我有複雜的跨平臺和跨設備需求, 且建立了相對完善的工作流. nvim 在其中只是充當編輯器的位置, 因此不會引入終端複用(tmux 負 責) 或者調試集成(大多數調試都是在終端跑測試腳本, print 調試, 或者使用 gdb 等調試器查看核文件等). 另外, 也不會在 nvim 中切換工作區, 而是由 tmux 配合新開 shell, 啟動其他工作區的實例. 這樣能避免由工作區路徑引起的 bug.

  • Local 配置文件與環境變量

開始時我使用環境變量來控制 nvim 的行為, 比如是否開啟行尾診斷信息, 是否使用 blink.nvim 的 binary 而非自行構建等, 這樣做 的初衷是便於從外部控制 nvim 的行為, 在調試的時候尤其有用. 但是後來 發現環境變量並不穩定, 並且得把 nvim 配置相關的環境 變量聲明在 shell 配置文件裡顯得極不自然. 因此我使用 json 來持久化配置, 不再受到環境變量易失性影響. 而且 json 文件可以 很好的和我的 dotfiles 管理器 相互配合. 當然環境變量的便捷性給調試和臨時改變行為帶來了極大的便利, 這也是不太能直接拋棄的. 因此實現的時候我使用環境變量以高優先級覆蓋配置文件, 全部缺省就 fallback 到 assets 裡的默認選項.

  • 語言配置集中控制

環境變量選項逐漸變多之後為了在其他機器上關閉一些 lsp 的安裝, 我添加了不少新的環境變量選項單獨控制, 比如 NVIM_DISABLE_JDTLS. 但是這樣在需要控制的語言多了之後會非常混亂, 並且也不優雅. 最好的方式當然是用一個配置選項統一配置. 這就要求語言的配置聲明 被集中起來, 並在加載的早期進行解析. 因此我實現了一個 lang 模塊, 解析 spec 之後生成 Mason, Treesitter 的安裝列表, 以及 需要 enable 的 lsp 列表.

---@class ToolSpec
---@field name string
---@field bin? string
---@field source? "sys"|"mason"
---@field packname? string

---@class LangSpec
---@field ft? string|string[]
---@field lsp? ToolSpec
---@field formatter? ToolSpec|ToolSpec[]
---@field treesitter string|string[]|boolean
---@field plugins? string|string[]

---@class LangOpt
---@field blacklist string
---@field whitelist string
---@field levels string

後記…?

這份自己從頭開始編寫的 nvim 在過去一年多的時間裡消耗了我相當多的業餘時間, 也收穫頗多. 我逐漸從只掌控配置文件內所有的代碼到 逐步瞭解各個插件的實現, 並嘗試排除故障, 提交 issue, 或直接修復並提交補丁. 同時, 我在不斷優化打磨這份配置的同時接觸到版本管 理的不少坑, 大量複雜的版本管理場景都在合併各種新的配置特性時出現. 同時也在深度優化啟動加載時間的過程中深入瞭解了 nvim 加載 的時序, 並開始閱讀 nvim 本身的代碼, 瞭解這個編輯器最內部的實現細節. 當然, 這份後記不會是真的後記, 將來這個配置仍然會隨著需 求的改變而不斷迭代.