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 在其中只是充當編輯器的位置, 因此我不會引入終端複用(tmux 負 責) 或者調試集成(大多數調試都是在終端跑測試腳本, print 調試, 或者使用 gdb 等調試器查看核文件等). 另外, nvim 配置應該儘量 輕, 以方便頻繁的重啟. 我不會在 nvim 中切換工作區, 而是由 tmux 配合新開 shell, 啟動其他工作區的實例. 這樣能避免由工作區 路徑引起的 bug.

  • 環境變量控制特性

由於需要兼顧各種系統, 比如受限制非常多的 windows, 上面沒有 posix shell, 性能也普遍差. 以及開發需求較少, 但是需要編寫各種 配置文件的 Nas. 因此我使用一系列環境變量指定相關設置. 這極大的提高了配置的動態性, 在不需要修改配置文件的情況下可以方便的 開關各種特性. 但是環境變量仍然受影響很多, 而且其配置依賴於 shell 的配置文件. 因此將來的一個 TODO 就是實現一套新的配置系 統, 從文件加載配置選項.

  • 語言配置集中控制

環境變量選項逐漸變多之後為了在其他機器上關閉一些 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 lsp? ToolSpec
---@field formatter? ToolSpec
---@field treesitter string|string[]|true
---@field plugins? LazyPluginSpec|LazyPluginSpec[]

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

後記…?

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