Neovim 或者 Vim 的插件管理器有很多,最开始的时候我使用的是 Vundle.vim, 后来尝试过 vim-plug 和 neobundle.vim, 目前使用的是 dein.vim。当然了,网上搜一下,其实还有很多其他的插件管理器, 然而并没有发现什么特别吸引我的功能,所以也就没有什么动力切换了。
相较于切换插件管理器,其实我更想好好研究一下插件管理器的实现逻辑,于是看了一些文档及插件管理器的源码,整理这篇文章。
插件的本质
我所理解的 Vim 或者 Neovim 的插件本质是一系列的 Vim 文件按照一定的目录结构组织起来的集合。
Neovim 中增加了 plugin/
、color/
等目录下 lua 文件的识别。
不管是 Vim 还是 Neovim,插件管理器添加一个插件的本质就是将插件所在的目录加入到 runtimepath
选项,
所谓的懒加载(lazy load)实际上是在适当的时机下触发前面这一步骤。
所以,很早以前,还没有插件管理器时,很多时候只是将插件下载到某一个目录,并在 vimrc
中将目录添加到 runtimepath
中。
set runtimepath+=/path/to/plugin_directory
目录结构及载入时机
阅读 :h runtimepath
可以看到标准的插件的目录结构包括:
filetype.lua filetypes
autoload/ automatically loaded scripts
colors/ color scheme files
compiler/ compiler files
doc/ documentation
ftplugin/ filetype plugins
indent/ indent scripts
keymap/ key mapping files
lang/ menu translations
lua/ lua modules
menu.vim GUI menus
pack/ packages
parser/ treesitter syntax parsers
plugin/ plugin scripts
queries/ treesitter queries
rplugin/ remote-plugin scripts
spell/ spell checking files
syntax/ syntax files
tutor/ tutorial files
在说明每一个目录的功能之前,我认为有必要了解一下 Vim 启动时的文件载入顺序。
假定,我们有一个 hello-vim 插件,其目录为 ~/wsdjeg/bundle/hello-vim
,示例 vimrc 文件:
set nocompatible
set rtp+=~/wsdjeg/bundle/hello-vim
filetype plugin indent on
syntax on
当启动 Vim 时,它将逐行读取并执行 vimrc
文件。其中 set rtp
这一行就是将插件 hello-vim 的目录加入到 runtimepath 内。
下一行 filetype plugin indent on
,这个命令包含了三层意思:
- 启动文件类型自动识别,比如打开
*.md
文件时,自动识别并设置filetype
为markdown
。 - 设置
filetype
时,根据&filetype
的值,自动读取并执行 runtimepath 中每一个目录下ftplugin/
子目录里对应的 vim 文件 - 设置
filetype
时,根据&filetype
的值,自动读取并执行 runtimepath 中每一个目录下indent/
子目录里对应的 vim 文件
关于 :filetype
命令的使用,:h :filetype-overview
显示如下帮助文档:
Overview: *:filetype-overview*
command detection plugin indent ~
:filetype on on unchanged unchanged
:filetype off off unchanged unchanged
:filetype plugin on on on unchanged
:filetype plugin off unchanged off unchanged
:filetype indent on on unchanged on
:filetype indent off unchanged unchanged off
:filetype plugin indent on on on on
:filetype plugin indent off unchanged off off
To see the current status, type: >
:filetype
The output looks something like this: >
filetype detection:ON plugin:ON indent:OFF
那么,使用以上的简单的 vimrc 启动 Vim 时,文件的载入顺序为:
- vimrc
- ~/wsdjeg/bundle/hello-vim/plugin/*.vim
如果 hello-vim/
目录下有 filetype.vim
文件,或者存在 ftdetect/
子目录,其内 *.vim
文件也会被执行。
autoload/
目录
关于 autoload/
内的 Vim 文件载入机制,我们来做这样一个测试:
文件名: ~/.SpaceVim.d/autoload/test_autoload.vim
,假定 ~/.SpaceVim.d/
已经加入 runtimepath。
echom "hi one"
function! test_autoload#hi()
echom "hi two"
endfunction
echom "hi three"
启动 Vim 后,发现上述三句 echom
语句都没有执行,这也说明了 autoload
内的文件默认是不会自动执行的。
此时如果执行函数 call test_autoload#hi()
。就会发现如下输出:
hi one
hi three
hi two
注意下输出的顺序,hi three
在 hi two
前面。
这是因为当调用 test_autoload#hi()
函数时,Vim 是根据函数的名称,确认下函数是否已定义,如果未定义,
则去找到对应的 Vim 文件读取并逐行执行,执行完了后才会调用函数。
前面执行过了 call test_autoload#hi()
函数后,如果再次调用。此时输出只会有一行:
hi two
那么问题来了,如果我再次调用一个不存在的函数 call test_autoload#no()
函数,会发生什么呢?
hi one
hi three
E117: Unknown function: test_autoload#no
也很好理解,这个 test_autoload#no()
函数未定义,
Vim 根据函数名称会再次去读取并逐行执行 Vim 文件,也就导致了 Vim 文件中的脚本被重复执行了。不受控的重复执行肯定不行的,那么如何规避呢?
可以像下面这样修改:
if exists('s:loaded')
finish
endif
" 有很多插件喜欢用 g:plugin_xxx_loaded, 个人感觉,非必要不使用暴露给脚本外部的变量跟函数。
let s:loaded = 1
echom "hi one"
function! test_autoload#hi()
echom "hi two"
endfunction
echom "hi three"
plugin/
目录
在 Vim 启动时默认的配置文件 vimrc
读取完成后, 此时会对 runtimepath
下每一个目录下的 plugin/
子目录进行遍历。其内所有的 Vim 文件会被逐一读取并执行。
Neovim 增加了 plugin/
目录下 lua 文件的支持。
ftplugin/
目录
这个目录就是 Vim 自带的 on_ft
加载机制,当 filetype
被设定时会自动载入 ftplugin/<filetype>.vim
,Neovim 还支持 Lua 格式文件 ftplugin/<filetype>.lua
。
懒加载的实现逻辑
很多插件管理器都实现了一个叫做 Lazy load(懒加载)
的功能,那么这个功能到底是如何实现,其本质逻辑是什么呢?
通过命令加载 on_cmd
当执行某个命令时加载,这种实现逻辑实际上新建一个新的命令,在命令中载入插件,并根据其参数再次调用命令。
比如 call dein#add('wsdjeg/FlyGrep.vim', {'on_cmd' : 'FlyGrep'})
此时会定义如下命令:
dein.vim/autoload/dein/parse.vim#L342
function! s:generate_dummy_commands(plugin) abort
let a:plugin.dummy_commands = []
for name in a:plugin.on_cmd
" Define dummy commands.
let raw_cmd = 'command '
\ . '-complete=customlist,dein#autoload#_dummy_complete'
\ . ' -bang -bar -range -nargs=* '. name
\ . printf(" call dein#autoload#_on_cmd(%s, %s, <q-args>,
\ expand('<bang>'), expand('<line1>'), expand('<line2>'))",
\ string(name), string(a:plugin.name))
call add(a:plugin.dummy_commands, [name, raw_cmd])
silent! execute raw_cmd
endfor
endfunction
在上述例子中,FlyGrep.vim 插件载入之前,执行的命令 :FlyGrep
实际上时调用 dein#autoload#_on_cmd
函数
根据函数加载 on_func
当调用插件的函数时动态载入插件,其实我感觉这个功能有点多余。因为 autoload/
目录下的 Vim 脚本文件在 Vim 启动时是不会自动执行的。
只有在调用对应的函数时才会载入并执行。
可能是为了更加极致的 Lazy load
, 让非载入的插件完全隐藏吧。那么 on_func
这个功能到底是如何实现的呢?
(Neo)Vim 有一个事件叫做 FuncUndefined
,是在调用不存在的函数时触发. 下面是一段简单的 on_func
实现。
let s:lazy_plugins = []
function! Plug(path, opt)
if !empty(a:opt)
" 直接 添加 rtp
return
endif
call add(s:lazy_plugins, [a:path, a:opt])
endfunction
function! s:on_func(f)
for [path, opt] in s:lazy_plugins
if opt.on_func =~# a:f
" 载入路径 path 的插件
endif
endfor
endfunction
augroup test_on_func
autocmd!
autocmd FuncUndefined * call s:on_func(expand('<afile>'))
augroup END
call Plug('~/.SpaceVim.d/hello-vim', {
\ 'on_func' : 'hello#'
\ })
根据事件加载 on_event
(Neo)Vim 有很多事件(event),这些事件在特定的时机会被触发。根据事件加载指的是设置某些插件在特定的事件触发时再载入系统。
相较于前面的 on_cmd
和 on_func
的实现,on_event
的实现相对简单一些,
仅仅是监控事件的发生与否,对于发生时的 <amatch>
及 <afile>
不做判断。
以下为简单的实现逻辑:
let s:lazy_plugins = []
augroup test_on_event
autocmd!
augroup END
function! s:load(path)
" 载入路径为 path 的插件
endfuntion
function! Plug(path, opt)
if !empty(a:opt)
call s:load(a:path)
return
endif
if has_key(a:opt, 'on_event')
for event in a:opt.on_event
exe printf('autocmd test_on_event %s call s:load("%s")', event, a:path)
endfor
endif
endfunction
call Plug('~/.SpaceVim.d/hello-vim', {
\ 'on_event' : ['InsertEnter', 'WinEnter']
\ })
根据文件类型加载 on_ft
根据文件类型加载插件,实际上可以理解为一种特殊的 on_event
懒加载模式。前面也说到 on_event
懒加载不判断触发事件时的 <amatch>
和 <afile>
值。
而 on_ft
模式的懒加载底层逻辑是只监听 FileType
这一种事件,并且在事件触发时判断 <amatch>
值。
on_ft
懒加载的简单实现代码如下:
let s:lazy_plugins = []
augroup test_on_ft
autocmd!
augroup END
function! s:load(path)
" 载入路径为 path 的插件
endfuntion
function! Plug(path, opt)
if !empty(a:opt)
call s:load(a:path)
return
endif
if has_key(a:opt, 'on_ft)
for ft in a:opt.on_ft
exe printf('autocmd test_on_ft FileType %s call s:load("%s")', ft, a:path)
endfor
endif
endfunction
call Plug('~/.SpaceVim.d/hello-vim', {
\ 'on_ft' : ['java', 'python']
\ })
根据按键加载 on_map
以下这段摘自 dein.vim 的源码:
function! s:generate_dummy_mappings(plugin) abort
let a:plugin.dummy_mappings = []
let items = type(a:plugin.on_map) == v:t_dict ?
\ map(items(a:plugin.on_map),
\ { _, val -> [split(val[0], '\zs'),
\ dein#util#_convert2list(val[1])]}) :
\ map(copy(a:plugin.on_map),
\ { _, val -> type(val) == v:t_list ?
\ [split(val[0], '\zs'), val[1:]] : [['n', 'x'], [val]] })
for [modes, mappings] in items
if mappings ==# ['<Plug>']
" Use plugin name.
let mappings = ['<Plug>(' . a:plugin.normalized_name]
if stridx(a:plugin.normalized_name, '-') >= 0
" The plugin mappings may use "_" instead of "-".
call add(mappings, '<Plug>(' .
\ substitute(a:plugin.normalized_name, '-', '_', 'g'))
endif
endif
for mapping in mappings
" Define dummy mappings.
let prefix = printf('dein#autoload#_on_map(%s, %s,',
\ string(substitute(mapping, '<', '<lt>', 'g')),
\ string(a:plugin.name))
for mode in modes
let raw_map = mode.'noremap <unique><silent> '.mapping
\ . (mode ==# 'c' ? " \<C-r>=" :
\ mode ==# 'i' ? " \<C-o>:call " : " :\<C-u>call ")
\ . prefix . string(mode) . ')<CR>'
call add(a:plugin.dummy_mappings, [mode, mapping, raw_map])
silent! execute raw_map
endfor
endfor
endfor
endfunction
不难看出,on_map
的实现机制跟 on_cmd
有点类似,实际上时定义了新的快捷键映射,触发时去调用 dein#autoload#on_map
函数。
再在这个函数内载入对应的插件,载入完成后使用 feedkey()
函数去模拟按下触发的快捷键映射。
总结
本文主要介绍了 (neo)vim 插件的基本目录结构以及各个目录下文件的载入机制。 充分利用好这些机制后,即便是没有使用懒加载机制的情况下也可以大大的提高插件的初始化体验。 除此之外,本文还简单介绍了目前常见的几种赖加载机制的实现底层原理。
最后,说明一下,文中示例代码仅限于体现功能实现的基本逻辑,并未做额外的条件判断及错误捕获,并不适用于直接使用。