第三章 Vim 常用命令
3.2 快捷键重映射
几乎每个初窥门径的 vimer 都曾为它的键映射欣喜若狂吧,因为它定制起来实在是太简 洁了,却又似能搞出无尽的花样。
快捷键,或称映射,在 Vim 文档中的术语叫 "map",它的基本用法如下:
map {lhs} {rhs}
map 快捷键 相当于按下的键序列
其中快捷键 {lhs}
不一定是单键,也可能是一个(较短的)按键序列,然后 vim 将其 解释为另一个(可能较长较复杂的)的按键序列 {rhs}
。为方便叙述,我们将 {lhs}
称为“左参数”,而将 {rhs}
称为“右参数”。左参数是源序列,也可叫被映射键,右参 数是目标序列,也可叫映射键。
例如,在 vim 的默认解释下,普通模式下大写的 Y
与两个小写的 yy
是完全相同的 功能,就是复制当前行。如果你觉得这浪费了快捷键资源,可将 Y
重定义为复制当前 行从当前光标列到列尾的部分,用下面这个映射命令就能实现:
: map Y y$
然而,映射虽然初看起来简单,其中涉及的门道还是很曲折的。让我们先回顾一下 Vim 的模式。
Vim 的主要模式
模式是 Vim 与其他大多数编辑器的一个显著区别。在不同的模式下,vim 对用户按键的 响应意义有根本的差别。Vim 支持很多种模式,但最主要的模式是以下几种:
- 普通模式,这是 Vim 的默认模式,在其他大多模式下按
<Esc>
键都将回到普通模式 。在该模式下按键被解释为普通命令用以完成快速移动、查找、复制粘贴等操作。 - 插入模式,类似其他“正常”编辑的模式,键盘上的字母、数字、标点等可见符号当作直 接的字符插入到当前缓冲文件中。从普通模式进入插件模式的命令有:
aAiIoO
a
在当前光标后面开始插入,i
在当前光标之前开始插入,A
在当前行末尾开始插入,I
在当前行末首开始插入,o
在当前行下面打开新的一行开始插入,o
在当前行上面打开新的一行开始插入。
- 可视模式(visual),非正式场合下也可称之为“选择”模式。在该模式下原来的移动命 令变成改变选区。选区文本往往有不同的高亮模式,使用户更清楚地看到后续命令将要 操作的目标文本区域。从普通模式下,有三个键分别进入三种不同的可视模式:
v
(小写 v)字符可视模式,可以按字符选择文本,V
(大写 V)行可视模式,按行选择文本(jk有效,hl无效),Ctrl-v
列块可视模式,可选择不同行的相同一列如几列。 (Vim 还另有一种 "select" 模式,与可视模式的选择意义不同,按键输入直接覆盖替 换所选择的文本)
- 命令行模式。就是在普通模式时按冒号
:
进入的模式,此时 Vim 窗口最后一行将变 成可编辑输入的命令行(独立于当前所编辑的缓冲文件),按回车执行该命令行后回到 普通模式。 本教程所说的 VimL 语言其实不外也是可以在命令行中输入的语句。此外还有一种“Ex 模式”,与命令行模式类似,不过在回车执行完后仍停留在该模式,可继续输入执行命 令,不必每次再输入冒号。在“Ex模式”下用:vi
命令才回到普通模式。
大部分初、中级 Vim 用户只要掌握这四种模式就可以了。对应不同模式,就有不同的映 射命令,表示所定义的快捷键只能用于相应的模式下:
- 普通模式:nmap
- 插入模式:imap
- 可视模式:vmap (三种不同可视模式并不区分,也包括选择模式)
- 命令模式:cmap
如果不指定模式,直接的 map
命令则同时可作用于普通模式与可视选择模式以及命令 后缀模式(Operator-pending,后文单独讲)。而 map!
则同时作用于插入模式与命令 行模式,即相当于 imap
与 cmap
的综合体。其实 vmap
也是 xmap
(可视模式 )与 smap
(选择模式)的综合体,只是 smap
用得很少,vmap
更便于记忆(v
命令进入可视模式),因此我在定义可视选择模式下的快捷键时倾向于用 vmap
。
在其他情况下,建议用对应模式的映射命令,也就是将模式简名作为 map
的限定前缀。 而不建议用太过宽泛的 map
或 map!
命令。
特殊键表示
在 map
系列命令中,{lhs}
与 {rhs}
部分可直接表示一般字符,但若要映射(或 被映射)的不可打印字符,则要特殊的标记(<>
尖括号内不分大小写):
- 空格:
<Space>
。映射命令之后的各个参数要用空格分开,所以若正是要重定义空格 键意义,就得用<Space>
表示。同时映射命令尽量避免尾部空格,因为有些映射会 把尾部空格当作最后一个参数的一部分。始终用<Space>
是安全可靠的。 - 竖线:
<BAR>
。|
在命令行中一般用于分隔多条语句,因此要重定义这个键要用<BAR>
表示。 - 叹号:
<Bang>
。!
可用于很多命令之后,用以修饰该命令,使之做一些相关但不同 的工作,相当于特殊的额外参数。映射中要用到这个符号最好也以<Bang>
表示。 - 制表符:
<Tab>
,回车:<CR>
- 退格:
<BS>
,删除键:<DEL>
,插入键:<Ins>
- 方向键:
<UP>
<DOWN>
<LEFT>
<RIGHT>
- 功能键:
<F1>
<F2>
等 - Ctrl 修饰键:
<C-x>
(这表示同时按下 Ctrl 键与 x 键) - Shift 修饰键:
<S->
,对于一般字母,直接用大写字母表示即可,如A
即可,不 必有<S-a>
。一般对特殊键可双修饰键时才用到,如<C-S-a>
。 - Alt
<A->
或 Meta<M->
修饰键。在 term 中运行的 vim 可能不方便映射这个修 饰键。 - 小括号:
<lt>
,大括号<gt>
- 直接用字符编码表示:
<Char->
,后面可接十进制或十六进制或八进制数字。如<Char-0x7f>
表示编码为127
那个字符。这种方法虽然统一,但如有可能,优先 使用上述意义明确方便识记的特殊键名表示法。
此外,还有几个特殊标记并不是特指哪个可从键盘输入的按键:
<Leader>
代表mapleader
这个变量的值,一般叫做快捷键前缀,默认是\
。同 时还有个<LocalLeader>
,它取的是maplocalleader
的变量值,常用于局部映射 。<SID>
当映射命令用于脚本文件中(应该经常是这种情况),<SID>
用于指代当前 脚本作用域的函数,故一般用于{rhs}
部分。当 vim 执行映射命令时,实际会把<SID>
替换为<SNR>dd_
样式,其中dd
表示当前脚本编号,可用:scriptnames
查看所有已加载的脚本,同时也列出每个脚本的编号。<Plug>
一种特殊标记,可以避免与用户能从键盘输入的任何按键冲突。常用于插件 中,表示该映射来自某插件。与<SID>
关联某一特定脚本不同,<Plug>
并不关联 特定插件的脚本文件。它的意义请继续看下一节。
键映射链的用途与陷阱
键映射是可传递的,例如若有以下映射命令:
: map x y
: map y z
当用户按下 x
,vim 首先将其解释为相当于按下 y
,然后发现 y
也被映射了,于 是最终解释为相当于按下 z
。
这就是键映射的传递链特性。那这有什么用呢,为什么不直接定义为 :map x z
呢?假 如 z
是个很复杂的按键命令,比如 LongZZZZZZZ
,那么就可先为它定义一个简短的 映射名,如 y
:
: map y LongZZZZZZZ
: map x1 y
: map x2 y
然后再可以将其他多个键如 x1
与 x2
都映射为 y
,不必重复多次写 LongZZZZZZZ
了。然而,这似乎仍然很无趣,真正有意义的是用于 <Plug>
。
假设在某个插件文件中有如下映射命令:
: map <Plug>(do_some_funny_thing) :call <SID>ActualFunction()<CR>
: map x <Plug>(do_some_funny_thing)
: map <C-x> <Plug>(do_some_funny_thing)
: map <Leader>x <Plug>(do_some_funny_thing)
在第一个映射命令中,其 {lhs}
部分是 <Plug>(do_some_funny_thing)
,这也是一 个“按键序列”,不过第一键是 <Plug>
(其实不可能从键盘输入的键),然后接一个左 括号,接着是一串普通字符按键,最后还是个右括号。其中左右括号不是必须的,甚至 可以不必配对,中间也不一定只能普通字符,加一些任意特殊字符也是允许的。不过当前许 多优秀的插件作者都自觉遵守这个范式:<Plug>(mapping_name)
。
该命令的 {rhs}
部分是 :call <SID>ActualFunction()<CR>
,表示调用当前脚本中 定义的一个函数,用以完成实际的工作。然而 <Plug>...
是不可能由用户按出来的键 序列,所以需要再定义一个映射 :map x <Plug>...
,让一个可以方便按出的键 x
来 触发这个特殊键序列 <Plug>...
,并最终调用函数工作。当然了,在普通模式的下几乎 每个普通字母 vim 都有特殊意义(不一定是 x
,而x
表示删除一个字符),你可能不 应该重定义这个字母按键,可加上 <Leader>
前缀修饰或其他修饰键。
那么为何不直接定义 :map x :call <SID>ActualFunction()<CR>
呢?一是为了封装隐 藏实现,二是可为映射取个易记的映射名如 <Plug>(mapping_name)
。这样,插件作者 只将 <Plug>(mapping_name)
暴露给用户,用户也可以自己按需要喜好重定义触发键映 射,如 :map y <Plug>(mapping_name)
。
因此,<Plug>
不过是某个普通按键序列的特殊前缀而已,特殊得让它不可能从键盘输 入,主要只用于映射传递,同时该中间序列还可取个意义明确好记的名字。一些插件作者 为了进一步避免这个中间序列被冲突的可能性,还在序列中加入插件名,比如改长为: <Plug>(plug_name_mapping_name)
。
不过,映射传递链可能会引起另一个麻烦。例如请看如下这个映射:
: map j gj
: map k gk
在打开具有长文本行的文件时,如果开启了折行显示选项(&wrap
),则 gj
或 gk
命令表示按屏幕行移动,这可能比按文件行的 j
k
移动更方便。所以这两个键的重 映射是有意义的,可惜残酷的事实是这并没有达到想要的效果。作了这两个映射命令之后 ,若试图按 j
或 k
时,vim 会报错,指出循环定义链太长了。因为 vim 试图作以 下解释:
j --> gj --> ggj --> gggj --> ...
无尽循环了,当达到一些深度限制后,vim 就不干了。
为了避免这个问题, vim 提供了另一套命令,在 map
命令之前加上 nore
前缀改为 noremap
即可,表示不要对该命令的 {rhs}
部分再次解析映射了。
: noremap j gj
: noremap k gk
当然,前面还提到,良好的映射命令习惯是显示限定模式,模式前缀还应在 nore
前缀 之前,如下表示只在普通模式下作此映射命令:
: nnoremap j gj
: nnoremap k gk
结论就是:除了有意设计的 <Plug>
映射必须用 :map
命令外,其他映射尽量习惯用 :noremap
命令,以避免可能的循环映射的麻烦。例如对本节开始提出的示例规范改写 如下:
: nnoremap <Plug>(do_some_funny_thing) :<C-u>call <SID>ActualFunction()<CR>
: nmap x <Plug>(do_some_funny_thing)
: nmap <C-x> <Plug>(do_some_funny_thing)
: nmap <Leader>x <Plug>(do_some_funny_thing)
其中,:<C-u>
并不是什么特殊语法,只不过表示当按下冒号刚进入行时先按个 <C-u>
, 用以先清空当前命令行,确保在执行后面那个命令时不会被其他可能的命令行字符干扰。 (比如若不用 nnoremap
而用 noremap
时,在可视模式选了一部分文本后,按冒号 就会自己加成 :'<,'>
,此时在命令行中先按 <C-u>
就能把前面的地址标记清除。在 很小心地用了 nnoremap
时,还会不会有情况情况导致干扰字符呢,也不好说,反正加 上 <C-u>
没坏处。但若你的函数本就设计为允许接收行地址参数,则最好额外定义 :vnoremap
,不用 <C-u>
的版本。)
各种映射命令
前面讲了最基础的 :map
命令,还有更安全的 :noremap
命令,以及各种模式前缀限 定的命令 :nnoremap
:inoremap
等。这已经能组合出一大群映射命令了,不过它们 仍只算是一类映射命令,就是定义映射的命令。此外,vim 还提供了其他几个映射相关的 命令。
- 退化的映射定义命令用于列表查询。不带参数的
:map
裸命令会列出当前已重定义的 所有映射。带一个参数的:map {lhs}
会列出以{lhs}
开头的映射。同样支持模 式前缀缩小查询范围,但由于只为查询,没有nore
中缀的必要。定义映射的命令, 至少含{lhs}
与{rhs}
两个参数。 - 删除指定映射的命令
:unmap {lhs}
,需要带一个完全匹配的左参数(不像查询命令 只要求匹配开头,毕竟删除命令比较危险)。可以限定模式前缀,如nunmap {lhs}
只删除普通模式下的映射{lhs}
。注意,模式前缀始终是在最前面,如果你把un
也视为map
命令的中缀的话。 - 清除所有映射的命令
:mapclear
。因为清除所有,所以不需要参数了。当然也可限定 模式前缀,如:nmapclear
,表示只清除普通模式下的映射。另外还可以有个<buffer>
参数,表示只清除当前 buffer 内的局部映射。这类特殊参数在下节继续 讲解。
特殊映射参数
映射命令支持许多特殊参数,也用 <>
括起来。但它们不同于特殊键标记,并不是左 参数或右参数序列的一部分。同时必须紧跟映射命令之后,左参数 {lhs}
之前,并用 空格分隔参数。
<buffer>
表示只影响当前 buffer 的映射,:map
:unmap
与:mapclear
都可 接收这个局部参数。<nowait>
字面意思是不再等待。较短的局部映射将掩盖较长的全局映射。
<nowait>
这个参数很少用到。但其中涉及到的一个映射机制有必要了解。假设有如下 两个映射定义:
* nnoremap x1 something
* nnoremap x2 another-thing
因为定义的是两个按键的序列,当用户按下 x
键时,vim 会等待一小段时间,以判断 用户是否想用 x1
或 x2
快捷键,然后触发相应的映射定义。如果超过一定时间后用 户没有按任何键,就按默认的 x
键意义处理了。当然如果后面接着的按键不匹配任何 映射,也是按实际按键解释其意义。
因此,若还定义单键 x
的映射:
: nnoremap x simple-thing
当用户想通过按 x
键来触发该映射时,由于 x1
与 x2
的存在,仍然需要等待一 小段时间才能确定用户确实是想用 x
键来触发 simple-thing
这件事。这样的迟滞 效应可不是个好体验。
于是就提出 <nowait>
参数,与 <buffer>
参数联用,可避免等待:
: nnoremap <buffer> <nowait> x local-thing
这样,在当前 buffer 中按下 x
键时就能直接做 local-thing
这件事了。
尽管有这个效用,但 <nowait>
在实践中还是用得很少。用户在自行设定快捷键时,最 好还是遵循“相同前缀等长快捷键”的原则。也就说当定义 x1
或 x2
快捷键后,就最好 不要再定义 x
或 x123
这样的变长快捷键了。规划整齐点,体验会好很多。当然, 如实在想为某个功能定义更方便的快捷键快,可定义为重复按键 xx
,因为重复按键 的效率会比按不同键快一点。(想想 vim 内置的 dd
与 yy
命令)
: nnoremap xx most-used-thing
另一方面,局部映射参数 <buffer>
却是非常常用,鼓励多用。局部映射会覆盖相同的 全局映射,而且当 <nowait>
存在时,会进一步隐藏全局中更长的映射。
<silent>
在默认情况下,当按下某个映射的{lhs}
序列键中,vim 下面的命令行 会显示{rhs}
序列键。加上这个<silent>
参数时,就不会回显了。我的建议是 一般没必要加这个参数禁用这个特性。当映射键正常工作时,你不必去理会它的回显, 但是当映射键没按预想的工作时,你就可在回显中看到它实际映射成什么{rhs}
了 ,这可帮助你判断是由于映射被覆盖了还是映射本身哪里写错了。<special>
这是相对过时的参数了,它指示当前这个映射命令中接受<>
标记特殊 键。在默认不兼容 vi 的设置下,不必加这个参数也能直接用<>
表示特殊键。<script>
当坚持用:noremap
代替:map
这个参数也没什么用了。它的本意是 限定右参数{rhs}
不会再与脚本外部的映射相互作用了。<unique>
唯一性要求是确保不会覆盖原来已定义的映射。在使用命令:map <unique> {lhs} {rhs}
时,如果发现{lhs}
在此前已定义,这条重定义映射的命 令就会失败。这个参数一般用在共享插件中,为了避免覆盖用户自己已定义的映射。不过在脚本中,还 有两个函数能作更好的控制。内建函数
mapcheck()
用于判断一个{lhs}
是否已 被映射,hasmapto()
用于判断一个{rhs}
是否有映射过。具体用法请用:help
查问相应的函数说明。<expr>
这是通过一个表达式间接计算出{rhs}
的用法。这是个相对高级的用法, 将在下一节详细讨论。
*表达式映射
常规的映射定义 :map {lhs} {rhs}
只是简单的将一个键序列转换解析为另一个序列, 所以这是一种静态的映射。如果在映射定义中结合表达式的思想,通过某种表达式计算出 所要转换的 {rhs}
,那就能极大地扩展映射的功能,达到静态映射所无法实现的灵活性 。
有两方式在映射定义中使用表达式。一种是 <expr>
参数,另一种是表达式寄存器 @=
。我们先讨论后一种方式。=
是一种特殊的寄存器,那么普通的寄存器又是什么概 念呢?那就从宏开始说起吧。虽然乍看之下宏与映射的关系远着呢,但究其本质也是通过 少量按键来实现需要大量按键的功能。
假设有这么个需求,将每两行连接为一行,怎么处理比较方便快捷。不妨打开在第一章示 例生成的 ~/.vim/vimllearn/helloworld.txt
作为示例编辑文件吧,如果这个文件你 未保存或丢失了,重新生成也是极快的。
vim 普通模式下有个命令 J
用于将光标当前行与下一行连接为一行,就是删去其中的 回车符。如果光标初始在第一行,那么 J
就能将第一行与第二行合一行,光标停留在 第一行;再按 j
下移到第二行,也就是最初的第三行,再按 J
合并……于是你可用这 个按键序列 JjJjJjJj...
来将当前 buffer 内的每两行合并为一行。
这都是些重复按键呀,可以用宏来节省操作呢。假设撤销刚才讨论的操作,从最初打开的 helloworld.txt
重新开始,(普通模式下)请依次按这些键 qaJjq
:
q
是录制宏的命令,qa
表示将宏保存到寄存器a
中;Jj
就是刚才我们讨论的手动操作,将当前行与下一行合并,再将光标下移一行;q
再一个q
表示结束录制宏。
现在我们已经有了 a
宏,就可以用 @a
命令播放这个宏了。可见其效果与在录制时 的操作 Jj
是一样的。然后我们可以进一步在播放宏的命令之前加个重复数字。因为原 来的 helloworld.txt
有 100 行,录制宏时合了两行,尝试播放宏时又合了两行,所 以还需要再合并 48 次。用这个命令 48@a
就可以瞬间将剩余的文本行两两合并了。也 可以使用 48@@
命令,因为 @@
是表示播放上一次播放过的宏。
(注:上述操作要产生相同结果,需要未打开折行选项,即 :set nowrap
,或没有将 j
映射为 gj
或其他,同时 J
命令也未被映射)
那么宏到底又是什么,宏里面到底保存了什么神秘的东西。其实它一点都不神秘,宏就是 一个寄存器而已。你可以用 :reg
命令(全名:registers
)查看所有寄存器的内容, 或者特定地 :reg a
查看寄存器 a
(宏 a
)的内容。可见它就是保存着 Jj
这 两个字符而已。可以将它粘贴出来再确认下 o<Esc>"ap
:
o<Esc>
表示用o
命令打开新一行,然后用<Esc>
回到普通模式。如果你按刚 才的批量宏操作后,光标应该位于 buffer 的最后一行;此时在最后新加了一空行,光 标也在这空行上。"ap
粘贴命令p
应属常见,在这之前先按"a
表示从寄存器a
中粘贴内容。
执行完这个命令后,就会发现已经将寄存器 a
的内容 Jj
粘贴到当前 buffer 末尾了。 常规寄存器有 26 个,即以 a-z
字母命名。我们可以试试其他寄存器,比如先用 v
选定 Jj
这两个字符,再用命令 "by
将这两个字符复制进寄存器 b
中。你可以用 :reg
命令再次查看下寄存器内容,确认 a
与 b
两个寄器都保存着 Jj
了。
题外话:我们平时使用复制命令 y
与粘贴命令 p
都不会加寄存器前缀的,这时它们 使用的是默认寄存器,其名就是双引号 "
,它其实是关联着最近使用的寄存器,与最近 使用那个寄存器内容相同。可以在当前行继续尝试 p
命令与 ""p
命令(或在使用每 个命令之前先输入一个空格,分隔内容方便查看),可见它们都粘贴出了 Jj
。此外还 有大写字母的寄存器,但它们不是额外的寄存器,只是表示往相应的寄存器中附加内容。 比如若 v
选定 Jj
内容后,再按 "Ap
,就表示将这两字符附加到原来的 a
寄 存之后了。可以用 :reg
查看 a
寄存器的内容已变成 JjJj
了。
为了说明宏即是寄存器,先用 q!
强制关闭当前的 helloworld.txt
而不保存,再重 新打开原始的有 100 行的 helloeworld.txt
。如果光标不在首行(vim 有可能会记住 光标位置的)则用 gg
回到首行。然后直接用命令 50@b
,看看会发生啥。没错,这 命令也将 buffer 内的文本行两两合并了,相当于执行了 50 次 Jj
命令。
所以 @a
或 @b
操作,正式地讲不叫“播放”宏,而是“读取寄存器,将其内容当作普 通命令来执行”。其实,当作普通命令来执行的内容,不仅可以放在内部寄存器,也可以 放在外部文件中。比如,只将 Jj
这两个字符保存到一个 Jj.txt
文件中,然后执行 ex 命令 :source! Jj.txt
。当 :source
命令之后加个 !
符号,就是表示所读的 文件不是当作 ex 命令的脚本了,而是当作普通命令的“宏”了。在这个命令之前,请将光 标移到首行,至少不要末行,否则就看不到 j
的效果了。同时由于这个文件只保存了 一组 Jj
,所以它只合并了两行。不过普通命令的序列组合可读性比较差,且很大程度 地依赖操作上下文,所以一般不会保存到外部文件,临时录制保存到寄存器较为常见。当 然你也可以先简单思考一下如何组织操作序列,明确地写出来,再复制或剪切到某个寄存 器中。
当明白了 @a
的执行意义,也就能更好地理解 @=
的意义了。这里,=
与 a
一 样是个寄存器,这个特殊寄存叫做表达式寄存器。
请在普通模式下,按下这两个键 @=
,此时光标将跳到命令行的位置,不过前面不是 :
, 而是 =
了。vim 在等待你输入一个有效的表达式,再按回车执行。比如输入 "Jj"<CR>
,这里 <CR>
表示回车结束输入并执行,注意 "Jj"
需要引号括起,这样 它才是个字符串常量表达式,否则若裸用 Jj
,回车后 vim 会报错说 Jj
是个未定义 变量。
然后这整个按键序列 @="Jj"<CR>
的效果是什么?就是与普通命令 Jj
一样,合并两 行并下移。可以用 :reg
查看寄存器 =
中的内容也正是 Jj
。所以,@=
的意图 是让用户临时输入一个表达式,vim 将计算该表达式的值,然后将结果值(应是字符串) 当作普通命令来执行。如果 @=
之后直接回车,不输入表达式,则延用原来保存在 =
寄存器中的值。
当你终于明白了 @=
的意义之后,就可以用 @=
来构建表达式映射了(终于回到正题 了)。例如:
: nnoremap \j @="Jj"<CR>
这样就可以用快捷键 \j
来“合并两行并下移”了。当然了,在这个简单的特定实例中, 所谓快捷键 \j
其实并不比直接输入 Jj
快多少。那个映射命令似乎也可以直接写成 :nnoremap \j Jj
。然而问题的关键是,在 @=
与 <CR>
之间,可以使用几乎任意 合法的 VimL 表达式(即使不是所有),而不会是像 "Jj"
这样无趣的常量表达式。
举个实用的例子:
:nnoremap <Space> @=(foldlevel(line('.'))>0) ? "za" : "}"<CR>
这个映射是说用空格键来切换折叠,即相当于命令 za
,但如果当前行根本就没有折叠 ,那就无所谓切换折叠了,那就换用命令 }
跳到下一个空行。这里用到了条件表达式 ?:
,我在脚本中很少用这个,不必省 if else
的输入,但在定义一些映射时条件表 达式却是极简捷实用的。
在插入模式下(包括命令行模式),不是用 @
键调取寄存器,而是用另一个快捷键 <C-R>
。比如 <C-R>a
就表示将寄存器 a
的内容插入到当前光标位置上。如果用 <C-R>=
就表示将要读取表达式寄存器的内容了,此时光标也会跳到命令行处,允许你 输入一个表达式后按回车,vim 就将表达式的计算值插入到光标处。例如:
: inoremap <F2> <C-R>=strftime("%Y/%m/%d")<CR>
它定义了一个映射,使用快捷键 <F2>
在当前光标处插入当前日期(请参阅 strftime()
函数的用法)。
然后再来看 <expr>
参数的意义与用法,比如以下两个映射定义是等效的:
: nnoremap \j @="Jj"<CR>
: nnoremap <expr> \j "Jj"
可见,在使用了 <expr>
参数后,@=<CR>
就没必要了,直接将后面的 {rhs}
参数 部分当作一个表达式,vim 首先计算这个表达,然后将其结果值当成真正的 {rhs}
参 数来解析为按键序列。
再尝试将上面那个空格切换折叠的快捷键改写成 <expr>
:nnoremap <expr> <Space> (foldlevel(line('.'))>0) ? "za" : "}"
(注:我在 vim8.0 中测试该映射有效,但在 vim7.4 中同样的映射无效,可能在低版本 中 <expr>
对条件表达式的 ?:
的支持不完全,但对于其他简单表达式无问题)。
除了应用条件表达式,当计算 {rhs}
需要涉及更复杂的逻辑时,还可以包装在一个函 数中,那就几乎有着无限的可能了。仍以切换折叠的示例,改写成函数就如:
: function! ToggleFold()
: if foldlevel(line('.')) > 0
: return "za"
: else
: return "}"
: endif
: endfunction
:nnoremap <expr> <Space> ToggleFold()
不过要注意,VimL 函数的默认返回值是数字 0
,如果在函数中忘了返回值,或在某个 分支中忘了返回值,那就可能导致奇怪的结果。例如,将上面的 ToggleFold()
函数改 写成:
: function! ToggleFold()
: if foldlevel(line('.')) > 0
: let l:rhs = "za"
: else
: let l:rhs = "}"
: endif
: " return l:rhs
: endfunction
:nnoremap <expr> <Space> ToggleFold()
假装忘了返回 l:rhs
,那么快捷键 <Space>
将取得 ToggleFold()
的默认返回值 0
,就是移到行首的意思了。取消 :return l:rhs
行的注释,可使之恢复正常使用。
当然了,用于表达式映射 <expr>
的函数还是有些限制的:
- 不能改变 buffer 内容
- 不能跳到其他窗口或编辑另一个 buffer
- 不能再使用
:normal
命令 - 虽然可在函数内移动光标,以便实现某些逻辑,但在返回
{rhs}
后会自动恢复光标 ,所以移动光标是无效的。
总之,映射的表达式函数尽量保持逻辑简明,以返回一个字符串作为 {rhs}
为主,避 免在其内执行有其他副作用的操作。更多内容请参考帮助 :help :map-<expr>
。
*命令后缀映射
定义命令后缀映射的命令是 :omap
,当然最好用 :onoremap
。要能定义有趣的命令后 缀映射,首先就要理解命令后缀模式(Operator-pending,直译操作符悬挂模式)。
Vim 普通模式下的许多命令都是“操作符+文本对象”范式。比如最常见的 y
d
c
就 是操作符,当你按下这几个键之一后,就进入了所谓的“命令后缀”模式,vim 会等待你输 入后续的操作目标即文本对象。文本对象包括以下两大类:
- 使用移动命令后光标扫描过的文本区域,即光标停靠点与原来光标位置之间的区域。
- 预定义的文本对象,常用的有:
ap
ip
一个段落,段落由空行分隔,ap
包括下一个空行,ip
不包括。a(
i(
或a)
i)
一个小括号,a-
表示包括括号本身,i-
只是括号内 部部分。a[
a]
a{
a}
,i[
i]
i{
i}
与小括号类似。a"
a'
,i"
i'
与小括号类似,但是由引号括起的部分。
Vim 允许用户分别独立定义操作符与文本对象,然后任意组合。命令后缀映射就是可用 :omap
自定义文本对象。
还是举个例子。假如你需要经常操作双引号的字符串,觉得每次用 i"
略麻烦,因为它 实际上是三个键,还要按个 Shift
键呢。你想选个单键来代替这三个键,比如说 q
键吧。首先,你可能尝试作如下映射定义:
: nnoremap dq di"
: nnoremap cq ci"
然而,这只是个普通模式下的映射,并非命令后缀模式下映射,它不具备普适性。这里只 定义了 dq
与 cq
就表明只能用这两个快捷键,但 yq
就无效了(复制字符串?) ,其他自定义的操作符当然也就无效。
然后试试改成一个命令后缀映射:
:onoremap q i"
这样,cq
dq
与 yq
都有效了,如果你知道如何自定义操作符,它对自定义操作符 也有效。
一个功能更丰富的例子请参考我写的一个小插件: https://github.com/lymslive/autoplug/tree/master/autoload/qcmotion 在命令后缀模式下,单键 q
不仅可以模拟 i"
与 a"
,还可以模拟 i(
与 a(
等括号对象(基于一定的上下文与优先级判断)。它的映射命令如下:
: onoremap q :call qcmotion#func#OpendMove()<CR>
不过它所调用的函数实现略复杂,不便全部引用,有兴趣的请参阅源代码。
总结下命令后缀映射的机制,对于 :onoremap {lhs} {rhs}
映射。首先将 {rhs}
当 作普通模式下命令(按键序列)执行。如果执行后 vim 仍在普通模式下,且移动了光标 ,则将前后两个时刻的光标位置之间的区域当作文本对象。如果执行后在可视模式,则将 选择部分的文本当作文本对象。内置命令 dw
dp
类似前一种情况,而 da(
di(
类似后一种情况。
命令后缀映射的另一方面是操作符映射。也可以称之为命令前缀映射吧。这样,很多普通 模式下的操作就可理解为“命令前缀”与“命令后缀”的组合了。定义满足这样特性的操作符 的映射要分两步:
- 设定选项
operatorfunc
,其值一般是个函数名,用该函数来执行相应的工作。 - 用命令
g@
激活这个函数调用。
当然了,不要将这两步分开,如果单独将 operatorfunc
选项设置放在 vimrc
,那 就只能定义一个操作符了。最好是类似如下定义:
: nnoremap {lhs} :set operatorfunc=OperaFunc<CR>g@
就是临时设定 operatorfunc
的选项值,然后激活它。这样就能为不同的 {lhs}
定 义为不同操作符了。
操作符函数 OperaFunc()
有一定的规范。它收受的第一个参数表示文本对象的选择模 式(即三种可视模式之一),这个参数是该操作符后面所接的文本对象自动传递给它的, 其值为以下三种,在函数内可根据不同值作不同处理:
- "line" 行选择模式
- "char" 字符选择模式
- "block" 列块选择模式
同时,在该函数内可利用 '[
与 ']
这两个光标标记(mark)取得所操作文本对象的 范围。即相当于文本对象的选择范围,加上参数一所指示的选择模式,就获得了足够的信 息来操作文本对象了。
缩写映射
缩写也是一种映射,不过只用于可输入模式下。包括插入模式与命令行模式,以及不太常 用的替换模式。其命令与映射也类似,不过将 map
换成 abbreviate
,如:
: abbreviate {lhs} {rhs}
: noreabbrev {lhs} {rhs}
: iabbreviate {lhs} {rhs}
: cabbreviate {lhs} {rhs}
: unabrrev {lhs}
: abclear {lhs}
也包括定义(退化参数则列表查询)、删除一个、清除所有缩写的命令。同样可以用 nore
限定,与模式前缀限制(但只有 i
与 c
分别表示插入模式与命令行模式)。
缩写的含义是当你输入 {lhs}
时,自动替换为 {rhs}
。不过由于在插入模式,字符 是连续输入的,所以还有一些限定规则才能让 vim 识别刚才输入的几个字符是某个缩写 的 {lhs}
。
Vim 支持三类缩写,根据 {lhs}
中关键字位置区分。所谓关键字就是 iskeyword
选 项,一般认为数字、字符是关键字,其他标点符号与空白不是关键字。
- 全关键字(full-id),即
{lhs}
全部由关键字组成。必须完全匹配,即{lhs}
之前不能有其他关键字。 - 关键字后缀(end-id),最后一个字符是关键字,前面的都不是关键字。
- 非关键字后缀(non-id),最后一个字符不是关键字,前面的可以是任意字符(空格与 制表符除外)。
其中,全关键字是最常用的缩写,最直接的想法是用它来纠正拼写错误,如:
: abbreviate teh the
: abbreviate higth hight
下面两例是另外两类缩写:
: abbreviate #i #include
: abbreviate inc# #include
在使用缩写时,还要输入一个额外的键来触发识别缩写,这也叫缩写的展开。一般地,输 入一个非关键字后,就会试图向前回溯寻找是否有缩写。最常用的是格式与制表符,还有 离开插入模式的 <Esc>
与离开命令行模式的 <CR>
。当缩写展开后,这个触发字符也 同时会插入在被展开的 {rhs}
后,如果这不是想用的效果,可用一个快捷键 <C-]>
作为纯粹的缩写展开,而不会插入额外字符。
缩写同样支持 <buffer>
与 <expr>
参数。例如:
: abbreviate today= <C-R>=strftime("%Y/%m/%d")<CR>
: abbreviate <expr> today= strftime("%Y/%m/%d")
这两个缩写定义是等效的,在你输入 "today=" 之后(再空格或<C-]>
等触发)就会替 换为今天的日期。
那么它与插入模式下的映射又有什么不同呢:
: inoremap <expr> today= strftime("%Y/%m/%d")
如果把 "today=" 定义为映射的话,那么在输入前面几个字符 "today" 之前都不会上屏 ,接着输入 "=" 后立即上屏。这个体验并不好,因为你即使输入 "to" 时,vim 也会等 待,根据后续字符才能决定是否当作映射处理。
而定义为缩写的话,展开之前的字符是直接上屏的,是否展开的决定延迟,且可由用户 决定是否展开。如果用户想抑止 "today=" 的展开,比如确实想在这个字符串之后输入个 空格,则可用 <C-v><Space>
输入下一个空格。<C-v>
是插入模式下的转义快捷键, 它后面接入的按键都屏蔽了其特殊意义,就按其字面字符输入。
结语
使用映射,除了一些基本的命令语法技巧外,更重要的是自己的统一习惯。可以多多凝视 一下你的键盘布局,想想定义哪些快捷键自己会觉得比较方便与舒服。合适的快捷键对于 每个人可能会有不同,不过有些键强烈建议不要重映射,请保留其默认意义:
- 数字不要被映射,数字用于表示命令的重复次数。
- 冒号
:
进入命令行不要改,当然如果觉得冒号不好按,可以将其他键也映射为冒号 。两样建议保留的键是<Esc>
@
键。 - 插入模式下的
<C-v>
与<C-r>
。Vim 的插入模式的默认快捷键确实不如普通模式 方便,于是有些用户想把 Emacs 那套快捷键映射过来。或者 Window 用户想将<C-v>
当作粘贴使用。然后这两个键在 Vim 映射中确实有特殊意义,经常能用来救急,还是 保留的好。此外<C-o>
是临时回到普通模式使用一个普通命令,也是很有用的,尽 可能保留。
另外,关于 <Leader>
的使用。如果基本只用一种映射前缀,使用 <Leader>
是方便 的。但如果使用了多个 <Leader>
以对应不同类别的快捷键,则不太建议使用 <Leader>
,直接写出映射前缀字符就是。毕竟 mapleader
是个全局变量,若要经常 改变其值,就不容易维护了。
除了映射与缩写,Vim 的自定义命令与自定义菜单的用法与思想也是类似的。自定义菜单 是只用于 gVim
的,本教程不打算介绍,而自定义命令将在一下节介绍。