3.2 快捷键重映射

第三章 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! 则同时作用于插入模式与命令 行模式,即相当于 imapcmap 的综合体。其实 vmap 也是 xmap(可视模式 )与 smap (选择模式)的综合体,只是 smap 用得很少,vmap 更便于记忆(v 命令进入可视模式),因此我在定义可视选择模式下的快捷键时倾向于用 vmap

在其他情况下,建议用对应模式的映射命令,也就是将模式简名作为 map 的限定前缀。 而不建议用太过宽泛的 mapmap! 命令。

特殊键表示 #

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

然后再可以将其他多个键如 x1x2 都映射为 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),则 gjgk 命令表示按屏幕行移动,这可能比按文件行的 j k 移动更方便。所以这两个键的重 映射是有意义的,可惜残酷的事实是这并没有达到想要的效果。作了这两个映射命令之后 ,若试图按 jk 时,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 会等待一小段时间,以判断 用户是否想用 x1x2 快捷键,然后触发相应的映射定义。如果超过一定时间后用 户没有按任何键,就按默认的 x 键意义处理了。当然如果后面接着的按键不匹配任何 映射,也是按实际按键解释其意义。

因此,若还定义单键 x 的映射:

: nnoremap x simple-thing

当用户想通过按 x 键来触发该映射时,由于 x1x2 的存在,仍然需要等待一 小段时间才能确定用户确实是想用 x 键来触发 simple-thing 这件事。这样的迟滞 效应可不是个好体验。

于是就提出 <nowait> 参数,与 <buffer> 参数联用,可避免等待:

: nnoremap <buffer> <nowait> x local-thing

这样,在当前 buffer 中按下 x 键时就能直接做 local-thing 这件事了。

尽管有这个效用,但 <nowait> 在实践中还是用得很少。用户在自行设定快捷键时,最 好还是遵循“相同前缀等长快捷键”的原则。也就说当定义 x1x2 快捷键后,就最好 不要再定义 xx123 这样的变长快捷键了。规划整齐点,体验会好很多。当然, 如实在想为某个功能定义更方便的快捷键快,可定义为重复按键 xx,因为重复按键 的效率会比按不同键快一点。(想想 vim 内置的 ddyy 命令)

: 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 命令再次查看下寄存器内容,确认 ab 两个寄器都保存着 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 会等待你输 入后续的操作目标即文本对象。文本对象包括以下两大类:

  1. 使用移动命令后光标扫描过的文本区域,即光标停靠点与原来光标位置之间的区域。
  2. 预定义的文本对象,常用的有:
  • 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"

然而,这只是个普通模式下的映射,并非命令后缀模式下映射,它不具备普适性。这里只 定义了 dqcq 就表明只能用这两个快捷键,但 yq 就无效了(复制字符串?) ,其他自定义的操作符当然也就无效。

然后试试改成一个命令后缀映射:

:onoremap q i"

这样,cq dqyq 都有效了,如果你知道如何自定义操作符,它对自定义操作符 也有效。

一个功能更丰富的例子请参考我写的一个小插件: 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( 类似后一种情况。

命令后缀映射的另一方面是操作符映射。也可以称之为命令前缀映射吧。这样,很多普通 模式下的操作就可理解为“命令前缀”与“命令后缀”的组合了。定义满足这样特性的操作符 的映射要分两步:

  1. 设定选项 operatorfunc,其值一般是个函数名,用该函数来执行相应的工作。
  2. 用命令 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 限定,与模式前缀限制(但只有 ic分别表示插入模式与命令行模式)。

缩写的含义是当你输入 {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 的,本教程不打算介绍,而自定义命令将在一下节介绍。