Eric's Blog 时光荏苒,岁月如梭

Neovim 和 Vim 插件管理器的实现逻辑

2024-08-08
Eric Wong

Neovim 或者 Vim 的插件管理器有很多,最开始的时候我使用的是 Vundle.vim, 后来尝试过 vim-plugneobundle.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,这个命令包含了三层意思:

  1. 启动文件类型自动识别,比如打开 *.md 文件时,自动识别并设置 filetypemarkdown
  2. 设置 filetype 时,根据 &filetype 的值,自动读取并执行 runtimepath 中每一个目录下 ftplugin/ 子目录里对应的 vim 文件
  3. 设置 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 threehi 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_cmdon_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 插件的基本目录结构以及各个目录下文件的载入机制。 充分利用好这些机制后,即便是没有使用懒加载机制的情况下也可以大大的提高插件的初始化体验。 除此之外,本文还简单介绍了目前常见的几种赖加载机制的实现底层原理。

最后,说明一下,文中示例代码仅限于体现功能实现的基本逻辑,并未做额外的条件判断及错误捕获,并不适用于直接使用。


延生阅读

分享到:

评论

目前只支持使用邮件参与评论。