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.nvim和catppuccin.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等. 這些插件大多被設置為BufReadPre或BufWinEnter加載, 隨文件打開和 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 本身的代碼, 瞭解這個編輯器最內部的實現細節. 當然, 這份後記不會是真的後記, 將來這個配置仍然會隨著需 求的改變而不斷迭代.