Neovim 使用体验

我很喜欢 Vim 这种纯键盘的交互方式, 但是我却一直不怎么会用 Vim. 虽然熟悉 Vim 的键位, 但是不懂 Vimscript, 不知道怎么装插件, 怎么定制它. 因此我一直使用 VSCode 作为主力开发工具, 搭配 VSCodeVim 这个插件, 能同时享受到 Vim 和 VSCode 的特性. 但是我总觉得需要会用真正的 Vim.

直到我接触到了 Neovim, 作为 Vim 的改进版, 它使用 Lua (我最熟悉的语言之一) 作为配置文件, 有着很现代化的特性, 解决了 Vim 的很多遗留问题. 于是花时间学习了解了 Neovim, 并且调教成我喜欢的样子. 现在我使用 Neovim 作为主力开发工具近三个月了, 这篇文章谈谈我的一些配置和使用体会. 我的 Neovim 配置在仓库 luyuhuang/nvim.

插件

我使用的插件有很多, 无法一一介绍. 这里介绍几个我认为比较重要的.

插件管理器: Lazy.nvim

Neovim 可以在没有插件管理器的情况下安装插件. 但是插件管理器可以带来很多好处: 可以根据配置的插件名自动安装插件, 可以自动更新插件, 以及最重要的: 可以实现延迟加载. 如果安装了太多插件, 会导致 Neovim 启动缓慢. 使用插件管理器就可以在真正需要使用插件的时候加载它.

我目前使用的插件管理器是 lazy.nvim. 刚开始我也使用 packer.nvim, 但是在插件之间存在依赖时, 它对延时加载的支持并不很好. lazy.nvim 则很好地解决了这个问题. 当一个插件被加载的时候, 它所依赖的插件会自动加载. 也就是我只需要配置插件的依赖项, 以及何时需要加载这个插件, 而不需要关心依赖项什么加载.

Lazy.nvim 提供了一个管理界面, 可以展示插件的加载状态, 安装和更新插件, 分析插件加载耗时等.

nvim

Profile 功能非常好用, 它展示各个插件时如何加载的, 耗时情况, 有效帮助我们优化性能. 虽然我总共安装了 31 个插件, 但是启动时仅加载 3 个插件; 其他插件都是延迟加载的. 启动时间仅 35 毫秒.

模糊搜索: Telescope

没人喜欢输入完整的文件名来打开编辑文件. 我比较习惯 vscode 和 sublime 的方式, Ctrl-P 模糊查找文件. 我使用的插件是 telescope.nvim. 它由 Lua 实现, 支持扩展, 功能十分强大. 它几乎支持一切可以列在列表中的东西, 例如:

  • 文件内容模糊搜索
  • Tag 符号搜索
  • LSP 定义/引用搜索
  • 诊断信息预览
  • Treesitter 符号搜索
  • Git 提交记录搜索
  • Git 文件变动预览
nvim

Telescope 需要手动设置按键映射. 我将 leader + / 设置为模糊搜索当前文件内容, Ctrl-P 设置为查找文件, Ctrl-O 设置为打开当前文件的符号; gs 在普通模式下设置为使用全词匹配搜索光标下的词 (通过参数 word_match = '-w'), visual 模式下设置为不使用全词匹配搜索选定内容.

1
2
3
4
5
6
7
8
9
10
11
12
local builtin = require('telescope.builtin')

vim.keymap.set('n', '<leader>/', builtin.current_buffer_fuzzy_find)
vim.keymap.set('n', '<C-p>', builtin.find_files)
vim.keymap.set('n', '<C-o>', builtin.current_buffer_tags)
vim.keymap.set('n', 'gs', function()
builtin.grep_string({word_match = '-w'})
end)
vim.keymap.set('v', 'gs', function()
vim.cmd.normal('"fy')
builtin.grep_string({search = vim.fn.getreg('"f')})
end)

Telescope 可定制性很强. 例如全局搜索, 有的时候我们需要全词匹配, 有时需要区分大小写, 有时需要开启正则表达式. 我的设置是利用 vim 的 count 机制: 执行命令前可以先按一串数字表示这个命令重复多少遍. 对于自定义映射, 我们自然可以获取到这串数字. 不过我的定义不是表示重复多少遍, 而是用于设置选项: 如果按过 1, 则表示开启正则匹配; 如果按过 2, 则表示开启全词匹配; 如果按过 3, 则表示区分大小写.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function live_grep_opts(opts)
local flags = tostring(vim.v.count)
local additional_args = {}
local prompt_title = 'Live Grep'
if flags:find('1') then
prompt_title = prompt_title .. ' [.*]'
else
table.insert(additional_args, '--fixed-strings')
end
if flags:find('2') then
prompt_title = prompt_title .. ' [w]'
table.insert(additional_args, '--word-regexp')
end
if flags:find('3') then
prompt_title = prompt_title .. ' [Aa]'
table.insert(additional_args, '--case-sensitive')
end

opts = opts or {}
opts.additional_args = function() return additional_args end
opts.prompt_title = prompt_title
return opts
end

vim.keymap.set('n', '<leader>s', function() builtin.live_grep(utils.live_grep_opts{}) end)

改变页签的逻辑: Bufferline.nvim

Vim / Neovim 有个缓冲区 (Buffer) 的概念. 每个打开的文件都是一个 buffer, buffer 可以绑定在窗口上, 即使窗口关闭, 缓冲区依然存在. Bufferline.nvim 这个插件可以将 buffer 作为页签列出来, 显示在页签栏, 取代原生的页签栏.

nvim

这种做法改变了原生页签栏的逻辑, 但是却有很大的好处. 实际上我觉得 vim 的 buffer 就近似等于诸如 vscode 和 sublime 之类编辑器的页签. 首先, 打开文件时, 如果目标 buffer 不存在, 则会创建它, 否则切换到已存在的 buffer. 跳转定义等操作时也是如此: 文件已打开则跳转, 否则在新页签中打开. 这一操作逻辑与 vscode 相同.

由于窗格可以自由绑定 buffer, 这让多窗格的操作逻辑也很顺畅.

Bufferline 需要一些简单的配置. 我的配置为 leader 加数字键切换页签, leader 键为空格. 此外 leader + j 和 leader + k 用于切换相邻页签, Ctrl-j 和 Ctrl-k 用于移动页签. ZZ 关闭当前页签 (实际上是删除当前 buffer)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for i = 1, 9 do
vim.keymap.set('n', '<leader>' .. i, function() bufferline.go_to_buffer(i, true) end)
end

vim.keymap.set('n', '<leader>j', '<Cmd>BufferLineCyclePrev<CR>')
vim.keymap.set('n', '<leader>k', '<Cmd>BufferLineCycleNext<CR>')
vim.keymap.set('n', 'gT', '<Cmd>BufferLineCyclePrev<CR>')
vim.keymap.set('n', 'gt', '<Cmd>BufferLineCycleNext<CR>')
vim.keymap.set('n', '<C-j>', '<Cmd>BufferLineMovePrev<CR>')
vim.keymap.set('n', '<C-k>', '<Cmd>BufferLineMoveNext<CR>')
vim.keymap.set('n', 'ZZ', function()
if vim.bo.modified then
vim.cmd.write()
end
local buf = vim.fn.bufnr()
bufferline.cycle(-1)
vim.cmd.bdelete(buf)
end)

像个 IDE 一样: LSP 和补全

LSP (Language Server Protocol) 是个很棒的发明. 过去我们需要 C++ 的 IDE, Java 的 IDE, C++ 的 IDE… 不同的 IDE 操作都不同, 而我们需要使用的功能基本上就是跳转定义, 查找引用, 错误检测, 自动补全等. LSP 则将这两部分分开了. 诸如跳转到定义, 自动补全之内的功能, 由一个称为 language server 的独立进程完成, 它与编辑器之间使用 LSP 通信. 这样各种语言只需要实现自己的 language server, 开发者可以使用自己喜欢的编辑器开发.

Neovim 原生支持 LSP, 只需要简单几行配置就可以接入 language server. 不过如果你像我一样连这几行代码都不愿写, 就可以使用 nvim-lspconfig 这个插件. 它集成了两百多种主流 language server 的配置, 只需要一行代码就能接入.

1
lspconfig.clangd.setup{on_attach = on_attach}

on_attach 用于指定当这个 language server 接入时的回调函数. 在这里可以指定一些按键映射. 例如我将 gd 映射成跳转到定义, gr 映射成查找引用, F2 重命名等.

1
2
3
4
5
6
7
8
local function on_attach(client, bufnr)
local bufopts = {noremap=true, silent=true, buffer=bufnr}
vim.keymap.set('n', '<C-o>', function() builtin.lsp_document_symbols{symbol_width = 0.8} end, bufopts) -- 打开当前文件的符号
vim.keymap.set('n', 'gd', function() builtin.lsp_definitions{fname_width = 0.4} end, bufopts) -- 跳转定义
vim.keymap.set('n', 'K', vim.lsp.buf.hover, bufopts) -- 模拟鼠标悬停
vim.keymap.set('n', 'gr', function() builtin.lsp_references{fname_width = 0.4} end, bufopts) -- 查找引用
vim.keymap.set('n', '<F2>', vim.lsp.buf.rename, bufopts) -- 重命名
end

此外还可以设置光标移动时变量名高亮, 输入时显示函数签名提示等.

1
2
3
4
5
6
7
local function on_attach(client, bufnr)
...

vim.api.nvim_create_autocmd({'CursorHold', 'CursorHoldI'}, {callback = vim.lsp.buf.document_highlight, buffer = bufnr}) -- 光标不动时高亮变量名
vim.api.nvim_create_autocmd({'CursorMoved', 'CursorMovedI'}, {callback = vim.lsp.buf.clear_references, buffer = bufnr}) -- 移动光标时清除高亮
vim.api.nvim_create_autocmd({'TextChangedI', 'TextChangedP'}, {callback = vim.lsp.buf.signature_help, buffer = bufnr}) -- 输入时显示函数签名提示
end

补全

Neovim 原生的补全也能用, 不过插件可以带来更好的体验. 我使用的是 nvim-cmp, 它支持多种补全, 不同的补全能力由插件提供. 例如:

  • 根据 language server 语义分析补全, 可以由插件 hrsh7th/cmp-nvim-lsp 提供.
  • 根据 buffer 中的单词补全, 可以由插件 hrsh7th/cmp-buffer 提供.
  • 根据 tags 中的符号补全, 可以由插件 quangnguyen30192/cmp-nvim-tags 提供.

nvim-cmp 需要配合 snippet 插件实现 LSP 的代码片段补全. 我用的是 saadparwaiz1/cmp_luasnip. 只需要做一些简单的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
require('cmp').setup{
preselect = cmp.PreselectMode.None,
snippet = { -- snippet 插件设置
expand = function(args)
require('snippet').lsp_expand(args.body)
end,
},
mapping = cmp.mapping.preset.insert({ -- 快捷键
['<Tab>'] = cmp.mapping.select_next_item(),
['<S-Tab>'] = cmp.mapping.select_prev_item(),
['<CR>'] = cmp.mapping.confirm(),
}),
sources = { -- 补全优先级. LSP 优先级最高, 其次是 tags 和 buffer
{name = 'nvim_lsp'},
{name = 'tags'},
{name = 'buffer'},
},
}

LSP 和补全的效果可以参见本文开头的视频.

指哪打哪: Hop.nvim

最后再介绍一个我很喜欢的插件: hop.nvim. 使用它可以方便地跳转到屏幕中的任意位置:

视频中的例子是跳转单词的起始位置. 按下快捷键后, 屏幕中每个单词的起始位置都会出现若干个字母, 按下对应的字母即可跳转到对应的位置. Hop.nvim 支持多种跳转方式:

  • 跳转到全屏单词起始位置
  • 跳转到当前行单词起始位置
  • 跳转到每行起始位置
  • 跳转到全屏指定单字母的位置
  • 跳转到全屏指定双字母的位置
  • 跳转到当前列的任意位置

总之它的功能非常丰富. 对于我来说, 使用 “跳转全屏单词” 和 “跳转本行单词” 这两种跳转方式就已经非常够用了. 我将它们分别映射成 leader + h 和 leader + f.

实用配置

打通剪切板

Vim 有一套自己的剪切板系统, 复制的内容会放入 vim 的寄存器中. 但是编辑器的剪切板与系统的剪切板不互通给我们带来了很多麻烦. 好在 Neovim 对此有支持.

为了解耦, Neovim 并不直接提供剪切板支持, 而是依赖于独立的工具. 当数据写入 +* 寄存器时, Neovim 会寻找系统中的剪切板工具, 如果有就会把数据同步给它们. 类似地, 当读取 +* 的时候, Neovim 也会尝试从剪切板工具中读取数据. Neovim 原生支持 pbcopy, pbpaste, xclip win32yank tmux 等工具, 只要安装了这些工具就会自动启用.

在 Windows/WSL 中, 我们可以使用 win32yank. 它包含在 Windows 版的 Neovim 中, 只要确保它在 PATH 下即可. 也就是说, 基本上只要安装好 Windows 版的 Neovim, 剪切板就已经打通了. 我们只需要设置好按键映射, 让复制粘贴使用 +* 寄存器.

1
2
3
4
vim.keymap.set({'i', 'c'}, '<C-v>', '<C-r>+')
vim.keymap.set('v', '<C-v>', '"+p')
vim.keymap.set('t', '<C-v>', '<C-\\><C-n>"+pa')
vim.keymap.set('v', '<C-c>', '"+y')

我将 visual 模式的 Ctrl-C 映射成复制; 将插入模式, 命令模式, visual 模式, 终端模式的 Ctrl-V 映射成粘贴. 普通模式的 Ctrl-V 还是要保留原本的行为, 用于进入 blockwise visual 模式.

WSL 中可以直接执行 exe, 所以只需要让 win32yank 在 WSL 的 PATH 下即可打通剪切板.

对于 ssh 远程访问的 Neovim, 可以使用 tmux. tmux 也有一套自己的剪切板系统, 叫做 buffer. tmux load-buffer 用于从文件中加载内容到 buffer, 相当于设置剪切板内容; tmux save-buffer 用于将 buffer 中的内容保存到文件, 相当于读取剪切板内容. 最新版的 tmux (3.3a) 更是支持将 buffer 的内容发送到目标客户端的剪切板中, 只需要执行 load-buffer 时带上 -w 选项即可. 这样就打通了远程 Neovim 和本机的剪切板.

Neovim 原生支持 tmux 作为剪切板工具, 但是不支持 -w 参数. 不过 Neovim 支持用户自定义剪切板工具, 可以指定复制粘贴时访问寄存器的行为:

1
2
3
4
5
6
7
8
9
10
11
12
if vim.env.TMUX then
vim.g.clipboard = {
name = 'tmux-clipboard',
copy = {
['+'] = {'tmux', 'load-buffer', '-w', '-'},
},
paste = {
['+'] = {'tmux', 'save-buffer', '-'},
},
cache_enabled = true,
}
end

上面的配置表示当复制到 + 寄存器时, 会执行命令 tmux load-buffer -w - 将复制的内容以标准输入的形式传递给 tmux; 当从 + 寄存器粘贴内容时, 会执行命令 tmux save-buffer - 从标准输出读取要粘贴的内容. 命令末尾的 - 告诉 tmux 从标准输入/输出读写内容.

跳转到行开头

在 Vim 中按下 0 跳转到当前行开头, 而按下 ^ 则跳转到当前行第一个非空白字符处. 但是 ^ 键的位置正好处于双手中间, 又要按住 Shift 键, 按起来并不方便. 我觉得 vscode 的交互处理很好: 按下 Home 键时, 会回到该行第一个非空白字符处; 再次按 Home, 又会跳转到行首. 这样一个键就实现了两种操作, 很方便.

得益于 Neovim 的高度可定制性, 我们也可以写几行代码在 Neovim 中实现这个功能.

1
2
3
4
5
6
7
8
9
local function home()
local head = (vim.api.nvim_get_current_line():find('[^%s]') or 1) - 1
local cursor = vim.api.nvim_win_get_cursor(0)
cursor[2] = cursor[2] == head and 0 or head
vim.api.nvim_win_set_cursor(0, cursor)
end

vim.keymap.set({'i', 'n'}, '<Home>', home)
vim.keymap.set('n', '0', home)

实现起来很简单, 无非是判断下当前光标的位置, 然后再看情况跳转就行.

恢复上次会话

像 vscode 这样的编辑器可以在打开时恢复上次会话, 自动打开上次打开的文件, 光标也会跳转到上次编辑的位置. Neovim 也有这个功能, 不过它不会自动执行, 而是将执行的时机和方式交给用户, 以保证充分的定制性. 我们只需要写几行代码即可实现类似的效果.

Neovim 使用 mksession 命令将当前会话状态保存成一个 .vim 会话文件. 保存的内容由 sessionoptions 选项指定, 可以是缓冲区, 当前目录, 窗口大小, 页签, 当前会话设置的全局变量, 按键映射等. 默认保存路径为当前目录. 只需要 source 保存的 .vim 会话文件即可还原会话状态.

我的做法是设置一个自动命令, 当退出 neovim 的时候保存当前会话状态. 没有必要保存太多东西, 我设置 sessionoptions 只保存最必要的几项内容

1
vim.opt.sessionoptions = 'buffers,curdir,tabpages,winsize'

保存在当前目录可能对版本控制不太友好, 因此我选择统一保存在固定的位置. 我将当前路径的 sha1 值作为会话文件的文件名.

1
2
3
4
5
6
local path = vim.fn.expand(vim.fn.stdpath('state') .. '/sessions/')

vim.api.nvim_create_autocmd('VimLeavePre', {callback = function()
vim.fn.mkdir(path, 'p')
vim.cmd('mks! ' .. path .. vim.fn.sha256(vim.fn.getcwd()) .. '.vim')
end})

为了不拖慢启动速度, 并且也不是每次打开都想恢复上次会话, 我选择设置一个自定义命令手动恢复会话. 恢复时只需要用 source 命令加载会话文件即可.

1
2
3
4
5
6
vim.api.nvim_create_user_command('Resume', function()
local fname = path .. vim.fn.sha256(vim.fn.getcwd()) .. '.vim'
if vim.fn.filereadable(fname) ~= 0 then
vim.cmd.source(fname)
end
end, {})

文件的上次光标位置保存在 '" 标记中. 创建一个自动命令在打开文件的时候检查这个标记并跳转, 即可实现恢复光标的上次位置.

1
2
3
4
5
6
vim.api.nvim_create_autocmd('BufReadPost', {callback = function()
local line = vim.fn.line('\'"')
if line > 1 and line <= vim.fn.line('$') then
vim.cmd.normal('g\'"')
end
end})

总结和体验

我这几个月使用体验主要有这几点

  • 纯键盘交互, 使用方便. 使用 Neovim 之后, 我使用鼠标的频率再次大大降低了. 之前使用 vscode + vscode-vim, 时不时还是会用到鼠标的: 切换到终端, 切换不同的 vscode, 相互复制粘贴等. 而 Neovim 可以配合 tmux, 在编辑器和终端之间随心所欲地切换, 并且剪切板也是互通的. btw 我的键盘是 HHKB, 其键数少, 键位方便 (特别是Ctrl 键和 ESC 键), 很适合这种交互逻辑.

  • 可定制性极强. Neovim 的插件生态很丰富, 你总能找当各种插件满足你的需求. 然而这不是最吸引人的, 因为 vscode 的插件生态也非常丰富. 我认为 Vim/Neovim 最大的特点是它的配置文件不是 json, 不是 ini, 而是一个完备的编程语言, 你可以随时在配置文件中写代码, 以实现你的种种需求. 如果你要给 vscode 或者 sublime 添加功能, 就必须动手写插件; 而 Neovim 却只需要在配置文件中写几行代码. Neovim 的 API 极为丰富, 你甚至可以用它实现一个 web 服务器.

  • Neovim 是一款现代化的编辑器. 正在不断推陈出新的 Vim 尚未过时, 何况是 Neovim. 事实上如果你的终端模拟器太古老, 你可能甚至无法正常使用 Neovim, 因为需要特殊字体的渲染支持, 真彩色终端的支持等. Neovim 有很多现代化特性, 如 LSP, Treesitter, 异步任务, 多线程, 远程控制等等; 但是界面却是 TUI, 装作一个老古董的样子. 总之它的功能之强大, 令我刚接触时为之惊叹.

  • 客制化是灵魂. 要用好 Neovim, 不应该复制别人的配置, 或者找一些 “开箱即用” 的配置, 一定要自己理解配置. 我认为 Vim/Neovim 的定位就是高度可定制, 如果你追求开箱即用, 那应该直接去用 vscode. Neovim 有丰富的帮助文档, 善用 help 命令, 总能找到解答; 而一旦理解并上手了, 就会从中找到很多乐趣. 这就是 DIY 的乐趣, 类似于客制化键盘, 组装电脑, 业余无线电… 把刷抖音的时间用来做这个, 也未尝不可.


Neovim 使用体验
https://luyuhuang.tech/2023/03/21/nvim.html
Author
Luyu Huang
Posted on
March 21, 2023
Licensed under