Vim 脚本的作用域与重复加载:配置为什么越 source 越慢
改完 vimrc 之后执行
:source %,让新配置立刻生效,这是很多人的习惯。用了一段时间,保存文件越来越慢;查下来,保存时触发的自动命令执行了十几遍。另一次,一个按键映射在自己的配置里好好的,装了一个插件之后行为全变了。
Vim 的配置文件本身就是一段脚本,每次 source 都会从头执行一遍。命令的执行结果有两类:一类是「设置」,重复执行结果不变(set number 执行十次还是一样);另一类是「注册」,重复执行会叠加(自动命令、某些映射)。写可以反复加载的配置,要知道每条命令属于哪一类,以及它定义的变量和函数放在哪个作用域里。
本文用 Vim 9.1 的 legacy Vim script 逐个验证。实验见文末。
一、自动命令:每 source 一次多注册一份
" bare.vim
autocmd BufWritePre * let g:n += 1" grouped.vim
augroup my_count
autocmd!
autocmd BufWritePre * let g:n += 1
augroup ENDbare.vim source 3 次后保存一次:自动命令执行了 3 次
grouped.vim source 3 次后保存一次:自动命令执行了 1 次autocmd 是注册命令:Vim 不检查是否已经有一条一样的,执行几次就注册几份。开头那个越保存越慢的配置,就是在 vimrc 里直接写了 autocmd,每次 :source 都多一份。
解决办法是把自动命令放进一个自己命名的 augroup,并在组的开头用 autocmd! 清空这个组。注意两点:
- 组名要唯一,用插件或配置自己的前缀。如果写成
augroup END之外的autocmd!(不在任何组里),清掉的是所有不在组里的自动命令,包括别的插件这样注册的。 - 删除一条自动命令后,组里旧的那条也会被
autocmd!清掉,配置与实际状态一致;不加组时,删掉配置里的一行,已经注册的那条仍然在。
二、函数:同一个脚本可以重新定义
同一个脚本里没有 ! 的 function,第二次 source:没有报错,Hello() = 1
另一个脚本定义同名函数:Vim(function):E122: Function Hello already exists, add ! to replace it在 Vim 9.1 里,重新 source 同一个脚本时,它之前定义的函数会被静默替换,不需要 function!;另一个脚本定义同名的全局函数则会报 E122。旧资料里「每个函数都要写 function!」的说法来自更早的版本,! 的真正作用是允许覆盖别处定义的同名函数,这通常不是你想要的。
更好的做法是根本不定义全局函数:
- 只在本脚本内使用的函数用
s:前缀(function s:Format()),不会和任何其他脚本冲突。 - 插件对外提供的函数放进
autoload/插件名.vim,命名为插件名#函数名,第一次调用时才加载(见Vim 的配置与插件怎样加载)。
三、变量的作用域
| 前缀 | 作用域 | 典型用途 |
|---|---|---|
g: | 全局 | 插件的用户配置项 |
s: | 当前脚本 | 脚本内部状态 |
b: | 当前缓冲区 | 某个文件的设置,如 b:undo_ftplugin |
w:、t: | 当前窗口、标签页 | 窗口或标签页级的状态 |
l:、a: | 函数局部变量、函数参数 | 函数内部 |
v: | Vim 预定义 | v:exception、v:count |
lib.vim 定义 s:secret 与 s:Get():另一个脚本里 exists("s:secret")=0,exists("g:shared")=1,exists("*s:Get")=0
a.txt 设 b:mode 后切到 b.txt:exists(b:mode)=0;切回 a.txt:b:mode=a状态放在哪一层,取决于它属于谁:一个文件是否已经格式化过,属于这个缓冲区(b:);插件的内部缓存属于插件脚本(s:);让用户覆盖的选项才放 g:。函数里不写前缀的变量默认是 l:,在函数外不写前缀则是 g:,这是很多「变量串了」问题的来源。
四、映射:右边会不会被再解释
先 nnoremap d x,再 nmap D d$,在行首按 D:ello world(d 被再解释成 x,$ 只是移动)
改成 nnoremap D d$:行内容「」(按内置的 d 删除到行尾)nmap 定义的映射,右边的按键会再按当前的映射解释一遍;nnoremap 则按 Vim 内置的含义执行。上面的例子里,D 的右边 d$ 被解释成了 x$:删一个字符、再移到行尾。开头那个「装了插件之后映射行为全变了」,就是自己的 nmap 右边用到了插件重新映射过的键。
规则很简单:除非确实要借用另一个映射(典型情况是 <Plug> 映射),一律用 noremap 系列(nnoremap、inoremap、xnoremap)。映射本身是「设置」类命令,同一个键重复定义会覆盖而不是叠加。
五、execute 拼命令:文件名要转义
在 a.txt 里 execute 'write ' . f,f = 'report#1.txt':写出的文件是 reporta.txt.txt(#1 被展开成 1 号缓冲区的文件名)
改用 fnameescape(f):写出的文件是 report#1.txt:execute 把字符串当成命令执行,字符串里的文件名会经过命令行的特殊字符展开:% 是当前文件名,# 是轮换文件名,#1 是 1 号缓冲区。拼接文件名时用 fnameescape();拼接要交给 shell 的参数时用 shellescape();拼接正则时用 escape() 处理特殊字符。这和 SQL 拼接、shell 拼接是同一个问题:数据进入了命令的语法。
:normal 与 :execute 常一起用(execute "normal! gg=G"),normal! 带感叹号同样表示不使用用户映射,理由和上一节相同。
六、作业:不要用固定等待判断结束
Vim 8 起可以用 job_start() 在后台运行外部命令,不阻塞编辑。判断它是否结束、退出码是什么,不能靠「等一会儿」:
启动一个 0.5 秒后以 3 退出的作业,固定等 100 毫秒:status=run,exitval=0(作业还没结束,这个 0 不是退出码)
等到 job_status 不再是 run(最多 5 秒):status=dead,exitval=3作业还在运行时,job_info().exitval 是 0,看起来像是成功。正确的做法是在 job_start() 的 exit_cb 回调里处理退出码;在脚本里同步等待时,循环检查 job_status() 并设一个上限。内置终端(:terminal)的作业也一样:窗口切走、切换到普通模式查看输出,都不会停止进程,要用 job_stop() 或在终端里退出程序。调试 C 程序可用 Vim 自带的 termdebug 包(:packadd termdebug),它把 GDB 命令、被调试程序的输入输出和源码窗口分开。
七、Vim9 script
Vim 9 引入了 Vim9 script(文件以 vim9script 开头,函数用 def 定义)。它的变量默认是脚本局部的,函数会被编译,执行快得多,语法也更接近常见的编程语言。本文的规则大多仍然适用(augroup、noremap、fnameescape),但作用域和重定义的细节不同。新写的插件可以直接用 Vim9 script;vimrc 里两者可以共存,以帮助文档 :help vim9 为准。
八、检查清单
- 所有
autocmd放进自己命名的 augroup,组的开头写autocmd!。 - 内部函数用
s:,对外函数放 autoload;避免定义全局函数。 - 状态按归属放进
b:、w:、s:,只有用户配置项用g:。 - 映射一律用
noremap系列,:normal用normal!。 execute拼接文件名用fnameescape(),拼接 shell 参数用shellescape()。- 后台作业在回调里处理退出码,不靠固定等待。
配套实验
- codesphere-labs/engineering/vim-script-reload:Vim 9.1:重复 source 与 augroup、函数重定义、s: 与 b: 的作用域、nmap 再解释、execute 与 fnameescape、作业的退出状态(验证记录)
参考资料
- Vim 帮助:
:help autocmd-groups、:help :function、:help internal-variables、:help recursive_mapping、:help fnameescape()、:help job-start、:help terminal-debug、:help vim9 - Vim 官方文档(在线版)