TODO: 这篇文章已经过时。我后来又做了许多的改动,有兴趣的可以直接去看我的 dotfiles 。等我有空了会更新这篇文章。

引言

我用 Zsh 到现在大约三年了,从抛弃 Oh My Zsh 自行配置开始也有大约两年了,零零散散积攒了不少我觉得值得分享的东西,因此有了这篇 blog。另外,考虑到我的朋友大多对 Zsh 的使用比较轻度,写 Bash 居多,这篇 blog 也会顺便讲解一些零碎的 Zsh 的小知识。

我的配置文件全都放在 QuarticCat/dotfiles,有兴趣的可以去翻阅一下。值得一提的是,管理这个 repo 所用的 dotfile manager 也是我用 Zsh 写的。

尽管在 repo 里我把所有 Zsh 的配置放到了一个文件夹里,但它们在我系统中是分开的,结构大致上是这样:

- ~
  - .zshenv
  - $XDG_CONFIG_HOME/zsh
    - all-other-files

因为文件太多,全放在 home 目录会很乱,因此我遵循 XDG Base Directory 把大部分文件转移到了 $XDG_CONFIG_HOME,只有 .zshenv 必须得放在 home 目录。下面我就一个一个文件介绍一下我都配置了些什么。

.zshenv

关于 Zsh 几个配置文件的区别可以看这篇 blog 。在这里我主要放一些环境变量,这样它们对 DE 启动的 GUI 程序也生效(似乎是因为 SDDM 会自动 source 这个文件)。其中很多变量也可以用 ~/.pam_environment~/.xsession 等配置文件管理,但它们都都没有 Zsh 写得舒服,而且合在一起修改也比较方便(当然它们在效果上有轻微的差异)。

回到文件内容。首先是一个自己写的函数,主要是把 sourceeval 简单包了一层,在文件不存在或指令出错的时候直接跳过。这是因为很多软件需要在 shell 里面挂 hook,但有时我需要把我的 shell 配置快速移植到远程的开发机上,这些机子没有相应的软件就会报错。

include() {
    case $1 in
    -f)
        [[ -f $2 ]] && source $2
        ;;
    -c)
        local output=$($=2) &>/dev/null && eval $output
        ;;
    *)
        echo 'Unknown argument!' >&2
        return 1
        ;;
    esac
}

然后是 XDG Base Directory 、输入法(很多教程把它们放在 ~/.pam_environment 里)和默认编辑器的配置:

export XDG_CONFIG_HOME=~/.config
export XDG_CACHE_HOME=~/.cache
export XDG_DATA_HOME=~/.local/share

export INPUT_METHOD='fcitx5'
export GTK_IM_MODULE='fcitx5'
export QT_IM_MODULE='fcitx5'
export XMODIFIERS='@im=fcitx5'

export EDITOR='vim'
export VISUAL='vim'

然后把 .zshrc 的查找路径指向 $XDG_CONFIG_HOME/zsh ,接下来的其他文件就都可以放在那里了。因为 Zsh 默认不 split word,不需要到处加引号,看起来清晰很多。

ZDOTDIR=$XDG_CONFIG_HOME/zsh

最后是 $PATH 的设置(以及挂 hook ,此处省略)。Zsh 可以把一个 array 变量绑定到一个 scalar 变量上,两者的值会同步变化。Zsh 默认给你绑定了 $path$fpath。你可以用echo ${(t)var} 来查看 $var 的类型。在我的电脑上 $path 的类型默认是 array-tied-special,我这里用 typeset -U path 给它设置 unique 属性,使得 $PATH 自动去重。

typeset -U path
path=(  # no need to export
    ~/.local/bin
    ~/.cargo/bin
    ~/.ghcup/bin
    ~/go/bin
    $path
)

.zshrc -> zshrc.zsh

我通过在 .zshrcsource $ZDOTDIR/zshrc.zsh 来把配置转移到了 zshrc.zsh,这是因为一些地方不会识别出 .zshrc 这个“后缀”属于哪个语言因而不能正确高亮,另外 . 开头的文件有的地方会被默认忽略。但总的来说其实没有多少必要,也许哪天我又把配置转移回 .zshrc 了也说不定。

文件夹路径缩写

hash -d config=$XDG_CONFIG_HOME
hash -d cache=$XDG_CACHE_HOME
hash -d data=$XDG_DATA_HOME
hash -d zdot=$ZDOTDIR

hash -d OneDrive=~/OneDrive
hash -d Downloads=~/Downloads
hash -d Workspace=~/Workspace
for p in ~Workspace/*; hash -d $(basename $p)=$p
for p in ~Code/*; hash -d $(basename $p)=$p

Zsh 可以通过 hash -d short=long 来设置一个 shortcut ,之后就可以用 ~short 的语法访问 long 路径,算是 home 目录 tilde 语法的延伸,非常方便。不仅如此,在 powerlevel10k 等一些 theme 里,如果你的当前路径有对应 shortcut,那么它就会使用 shortcut 显示。同时你自己也可以实现这个功能,通过 ${(D)path_var} 获取 $path_var 变量里所存路径的 shortcut。

注意 Zsh 在这里的 for 可以省略 dodone 。后面还会用到更多可选写法,详情可见 Zsh 文档

P10k Instant Prompt

启动 powerlevel10k instant prompt。

include -f ~cache/p10k-instant-prompt-${(%):-%n}.zsh

我特别喜欢 p10k 的这个 feature,它通过预先启动一个外观一致但功能不完整的 prompt 来让你感觉 shell 已经启动完毕了,此时后台继续执行其余的部分,最后再加载一个完整功能的 prompt。即使我的 shell 启动时间已是飞快,用了这个 feature 都能明显感觉到提升。有了这个 feature 后 shell 的启动时间就已经不太重要了,感知大大减弱。

插件

include -f ~zdot/.zgenom/zgenom.zsh

zgenom autoupdate  # every 7 days

if ! zgenom saved; then
    # load plugins and compile ~zdot
fi

我用的插件管理器是 zgenom 。这是我目前知道的最快的插件管理器,比知名的 zinit 还快不少,但功能上少非常多,倒也合理。它用了一个比较取巧的方法,在加载完一次插件后,它会把一些解析后的结果写入到一个 init script 里面,之后就只加载这个 init script,从而省去了一点时间。但这也带来了一些不便,当想要修改插件设置的时候就需要多一些步骤,而且无法在插件的加载中间插入指令。不过它也支持动态加载。我喜欢它的主要原因除了快还有简单,有任何问题我自己看代码都能解决,甚至能发个 PR。另一个我也很喜欢的轻量级插件管理器是 zcomet

现在很多插件管理器(包括我上面提到的三个)还支持预先把 Zsh 文件编译成 Zwc 文件来加速加载。这是一个 Zsh 本身提供的功能,所以并不难实现。

Zinit 用户可能会想要 zinit 里的延迟加载功能。但实际上这个功能已经完全不稀奇了,上面提到的两个轻量级插件管理器应该都可以通过组合 zsh-defer 来实现延迟加载。而且延迟加载需要处理的问题比较多,十分麻烦,例如需要重新 compinit 或者加载一些别的东西。再加上我有 instant prompt,感觉这个功能已经没太大用处了。

我用到的插件大概有下面这些:

  • Oh My Zsh 的一小部分

    • lib/completion.zsh:提供了一些补全的基本设置。这个文件里很坑的一点是把$WORDCHAR—— Zsh 用来分词的配置——设置成了空,并且为了兼容无法改回来。如果用了它的话记得重新设置 $WORDCHAR。关于 Zsh 的分词机制会在下面介绍。

    • lib/clipboard.zsh:提供了 clipcopyclippaste 这两个很好用的函数,用来在 terminal 里复制和粘贴剪贴板,用法如 echo 123 | clipcopyclippaste | xxx

    • plugins/sudo:双击 Esc 切换 sudo 和无 sudo / sudoeditEDITOR ,如果当前 buffer (就是已经输入但还没提交的那些字符)为空则取上一条指令。

    • plugins/extract:提供了一个 extract 函数和 x 别名,用来自动识别压缩包后缀并解压,用法如 x 123.zip

    • plugins/pip:提供了 pip 的自动补全。因为需要把 pip 包索引缓存下来,因此要加载整个插件而不能只加载补全文件。

    • plugins/rust:提供了 rustc、rustup、cargo 的补全。其中后两者通过在插件入口文件里调用 rustup completions zsh 来实现,也就是说 rustup 本身就附带了这些补全文件。实际上如果软件本身附带补全文件的话,系统的包管理器一般会帮你一并打包,因此我这里只加载了 rustc 的补全。

    • plugins/docker-compose:提供了 docker-compose 的补全和一些 subcommand 别名。我只加载了补全,因为有了后面提到的 fzf-tab ,补全变得非常方便,单纯 subcommand 完全没有 alias 的必要。

    有些 OMZ 插件和 theme 会依赖于 lib/git.zsh ,如果遇到 git 相关功能异常可以考虑加上它。

  • nix-zsh-completions:提供了 nix 的一些补全和别名,还有一个自动检测 nix-shell 并显示在 prompt 上的 precmd hook。我只加载了补全,最后一个功能 p10k 已经包含了。

  • zsh-nix-shell:让 nix-shell 使用 Zsh 作为默认 shell。

  • fzf-tab:使用 fzf 来选择补全的插件,好用到起飞。非常多朋友看到我的 terminal 截图来问我这个插件是啥。强烈建议移步 repo 页面看效果演示。此外还有一个值得关注的竞品 zsh-autocomplete ,我还没尝试过。我大概率不会切换到这个插件,但我可能从那里抄一些好用的补全配置并且彻底抛弃 OMZ 的 completion lib。

  • zdharma-continuum/fast-syntax-highlighting:必备插件之一 zsh-syntax-highlighting 的全面上位替代品。原作者删库后由社区继续维护(具体事件见 reddit),但非常不活跃,没有新功能开发,我发的修复 global alias 高亮问题的 PR 也一直没有下文。

  • zsh-autosuggestions:必备插件之一,建议移步 repo 页面看效果演示。

  • zsh-history-substring-search:同上。注意,这个插件没有帮你绑定按键,而是只提供给你 widget 让你自行绑定。好评如潮!

  • zsh-edit:提供了一个更合理的分词机制以及相应的键位绑定。原先 Zsh 只根据哪些字符属于一个 word 来分词(用 $WORDCHAR 控制),也就是移动到非 word 字符为止,准确率非常低。这个插件把相邻字符的差异也考虑进去作为分词边界,舒服了很多,建议移步 repo 页面看示例。这个插件还提供了一些其他功能,不过我并不关心(甚至想 fork 出一份精简版)。

  • QuarticCat/zsh-autopair:我自己维护的一个 hlissner/zsh-autopair 的 fork,去除了 Ctrl-Backspace 的键位绑定。原插件把这个键位绑定成和 Backspace 一样,非常诡异,一般人 Ctrl-Backspace 都是删除一个词。

我没有使用任何的 autojump / z / z.lua / zoxide 一类插件,对我来说 hash -d 和 fzf-tab 的组合已经让我路径输入体验十分舒适了。实际上我在使用 Zsh 的第一年经常用 z.lua ,后来换了 zoxide 。直到有一天我发现我已经很久很久没有打开过 zoxide 了,我就知道我已经不需要它了。

另外,有一个使用 SQLite 来管理 shell history 的软件 atuin,也许能替换掉几个 history 相关的插件。我正打算尝试,但我非常怀疑它和 Zsh 的整合效果。

配置

只挑一些比较重要的写。

zsh

# zsh misc
setopt auto_cd               # simply type dir name to cd
setopt auto_pushd            # make cd behaves like pushd
setopt pushd_ignore_dups     # don't pushd duplicates
setopt pushd_minus           # exchange the meanings of `+` and `-` in pushd
setopt interactive_comments  # comments in interactive shells
setopt multios               # multiple redirections
setopt ksh_option_print      # make setopt output all options
setopt extended_glob         # extended globbing
setopt no_bare_glob_qual     # disable `PATTERN(QUALIFIERS)`, extended_glob has `PATTERN(#qQUALIFIERS)`
WORDCHARS='*?_-.[]~=&;!#$%^(){}<>'  # remove '/'

# zsh history
setopt hist_ignore_all_dups  # no duplicates
setopt hist_save_no_dups     # don't save duplicates
setopt hist_ignore_space     # no commands starting with space
setopt hist_reduce_blanks    # remove all unneccesary spaces
setopt share_history         # share history between sessions
HISTFILE=~zdot/.zsh_history
HISTSIZE=50000
SAVEHIST=10000

这些 setopt 主要功能都写在注释里了。$WORDCHARS 也是之前介绍过的。$HISTFILE$HISTSIZE$SAVEHIST 这三个分别指定存指令历史记录的文件、文件大小限制、条目数限制。

# zsh completion
compdef _galiases -first-
_galiases() {
    if [[ $PREFIX == :* ]]; then
        local des
        for k v ("${(@kv)galiases}") des+=("${k//:/\\:}:alias -g '$v'")
        _describe 'alias' des
    fi
}
zstyle ':completion:*:git-checkout:*' sort false
zstyle ':completion:*:git-rebase:*' sort false
zstyle ':completion:*:git-revert:*' sort false
zstyle ':completion:*:git-reset:*' sort false
zstyle ':completion:*:git-diff:*' sort false

这里我给自己的 global alias 写了一个补全。因为补全类型比较特殊不知道怎么拆成文件,就留在这了。Global alias 是 Zsh 特有的功能,通过 alias -g xxxx=yyy 设置,在指令的任何地方遇到单独的 xxx 都会被替换为 yyy,而不只是指令的开头。通常设置成全大写来避免意外替换,我则是采用冒号开头。其实在 fast-syntax-highlighting 里 global alias 会被突出显示,基本不会意外替换的。这个补全的效果如下:

galias-comp

然后剩下的就是一些补全的配置。我关掉了很多 git 指令的补全排序,因为它们补全的是 commit ,默认就是按照时间顺序给出的,按字符串排序后反而乱了。我正在考虑要不要默认关闭掉所有补全的排序,并不只有 git 会给出有内在顺序的补全选项。

由于我自己对 Zsh 的补全系统也很不了解,这块就不展开讲语言知识了……

fzf

export FZF_DEFAULT_OPTS='--ansi --height=60% --reverse --cycle --bind=tab:accept'

主要想提一下 --bind=tab:accept,用 Tab 键来选择,使得补全体验与默认情况以及代码编辑器里的更接近。

Fzf 真的是一个特别好用的命令行基础设施。模糊搜索这个功能可以与许多其他的功能组合在一起,创造出更强大的功能,这里就有一些例子。

fast-syntax-highlighting

unset 'FAST_HIGHLIGHT[chroma-man]'  # chroma-man will stuck history browsing

一个 bug 的暂时解决方案。因为原 repo 没了,我现在也不知道修了没有,如果有遇到可以像我这样设置。原作者推荐 FAST_HIGHLIGHT[chroma-man]=,这样写若是 fast-syntax-highlighting 没加载就会报错,因为对一个不存在的变量取了索引,而用 unset 就不会,不过其实也没啥用。

zsh-autosuggestions

ZSH_AUTOSUGGEST_MANUAL_REBIND='1'

默认情况下 zsh-autosuggestions 为了防止它绑定的按键被其他插件覆盖,会挂一个 precmd hook,每条指令都给你重新绑定一次键位,就比较蠢,而且会拖慢速度。因为我对自己的键位绑定非常清楚所以就改为手动了。

HISTORY_SUBSTRING_SEARCH_FUZZY='1'

默认把 ab c 当成一个整体来搜索,开了这个以后当成 *ab*c* 来搜索。

man-pages

export MANPAGER='sh -c "col -bx | bat -pl man --theme=Monokai\ Extended"'
export MANROFFOPT='-c'

配置 man-pages 使用 bat 作为 pager ,bat 在这里可以提供语法高亮。我的 bat 默认使用的 theme 是 OneHalfDark,这里特意指定了用 Monokai Extended ,这是我试过一遍后感觉对 man-pages 高亮效果最好的。效果如下:

bat-man-page

Bat 的仓库里还有许多别的用法示例。

别名

alias l='exa -lah --group-directories-first --git --time-style=long-iso'
alias lt='l -TI .git'
alias clc='clipcopy'
alias clp='clippaste'
alias clco='tee >(clipcopy)'  # clicpcopy + stdout
alias sc='sudo systemctl'
alias scu='systemctl --user'
alias sudo='sudo '
alias cgp='cgproxy '
alias pc='proxychains -q '
alias open='xdg-open'
alias with-proxy=' \
    http_proxy=$MY_PROXY \
    HTTP_PROXY=$MY_PROXY \
    https_proxy=$MY_PROXY \
    HTTPS_PROXY=$MY_PROXY '

alias -g :n='/dev/null'
alias -g :bg='&>/dev/null &'
alias -g :bg!='&>/dev/null &!'  # &!: background + disown

正如我前面所说,我并不喜欢 subcommand 别名。我现在补全的体验很舒适,我会重度依赖补全。在不用补全的时候,写 ab 确实比 aaa bbb 快,但在用补全的时候,原先我可能只需要按 a<TAB>b<TAB> 就能打出来,有了 alias 后我按 a<TAB> 将会出现一大堆候选项,反而慢了。或者可能原先打 a<TAB> 是 X 个候选项,有了 alias 后就是 XY 个候选项了。而且 alias 的补全是没解释文本的,而 subcommand 的补全往往是有的。这也是为啥我不喜欢 apt-*nix-*,虽然它们不是 alias 。

那么多长得差不多的 alias 对记忆力也是挑战。我曾经用过很长一段时间的 zsh-you-should-use 来帮助我记忆 alias,最后发现不用记 alias 才是最舒服的。所以我在 alias 的数量方面一直都很克制。

注意到很多 alias 后面有一个空格,这是 Zsh 的一个优秀设计。Zsh 会首先看第一个参数是不是 alias,如果是的话就替换。如果这个 alias 最后有一个空格,那么会尝试展开下一个参数,然后看它有没有空格。这使得我们可以在 alias 前面使用 precommand ,如 sudocgproxyproxychains 等。所有的 precommand 都应该 alias 一遍并加上末尾空格。Precommand + alias 这么常见的需求不懂为什么 Fish 迟迟不解决。

这些 alias 里面除了 global alias 外我觉得最有意思的就是 with-proxy。一些软件(主要是用 go 写的那些)无法被 proxychains 代理,可以试一下环境变量。有个语法是 VAR=XXX command,把 $VAR 传进去作为这个指令的环境变量,比较方便,还限制了作用范围。这个 alias 就是用这个语法简化了传递一堆 proxy 变量的操作,用起来和其他 precommand 一模一样。我曾经把这个设计提交给了 Sukka 的 zsh-proxy ,得到一句 LGTM 之后就被晾着了……

key-bindings.zsh

Zsh 默认的按键绑定是非常难用的,啥都没有。如果你用 OMZ 的 lib/key-bindings.zsh 的话它会帮你绑定相当多的常用按键,非常贴心。不过对我来说它的设置基本都被后面的一些插件覆盖了(比如 zsh-edit 和 zsh-autopair),所以就没用。由于插件提供了很多我需要的按键绑定,我自己绑定的键位其实不多(相比那些从头开始自己绑按键的 Zsh 用户)。

如果你不知道你的按键被绑了啥,可以执行 bindkey 查看全部按键绑定,或者 bindkey <key> 来查看某个按键的绑定,有时你可以意外地发现某个插件给你绑了奇怪的东西。

bindkey -r '^['  # [Esc] (Default: vi-cmd-mode)

bindkey '^Z' undo         # [Ctrl-Z]
bindkey '^Y' redo         # [Ctrl-Y]
bindkey '^Q' push-line    # [Ctrl-Q]
bindkey ' '  magic-space  # [Space] Do history expansion

# Widgets are from zsh-history-substring-search
bindkey '^[[A' history-substring-search-up    # [UpArrow]
bindkey '^[[B' history-substring-search-down  # [DownArrow]
  • 解绑 Esc ,默认的 vi-cmd-mode 我甚至都不知道怎么退出(臭名昭著的勒索软件.jpg)。

  • Undo 和 redo ,很多人可能都不知道 Zsh 还提供了这种功能。

  • Push-line 的作用是保存你当前 buffer 的内容然后清空,等你执行完一条指令后再把保存的内容恢复到你的 buffer ,这对于输入到一半要执行其他指令时非常有用。

  • Magic-space 的作用见前文提到的 Aloxaf 的文章。

# Trim trailing newline from pasted text
bracketed-paste() {
    zle .$WIDGET && LBUFFER=${LBUFFER%$'\n'}
}
zle -N bracketed-paste

https://unix.stackexchange.com/questions/693118 改来的代码。如果你三连击 code block 任意一行,你就会全选这一行,包括后面的换行。如果此时复制粘贴这一行到 Zsh 并执行,那么这一个多余的换行也会在 history 里被保留下来。这里通过替换默认的 bracketed-paste 组件来在粘贴后自动修改要粘贴的内容以解决这个问题。链接里有更详细的描述。

这里用到了 Zsh 的 $'xxx' 语法,这个语法会对里面的转义字符进行转义,不像 Bash 还得依靠 echo -en 之类的东西。

Zsh 每个内置组件都有一个 . 开头的别名,用来在组件被替换的时候访问原组件,因此这里写 zle .$WIDGET

# [Ctrl+L] clear screen while maintaining scrollback
fixed-clear-screen() {
    # FIXME: works incorrectly in tmux
    local prompt_height=$(echo -n ${(%%)PS1} | wc -l)
    local lines=$((LINES - prompt_height))
    printf "$terminfo[cud1]%.0s" {1..$lines}  # cursor down
    printf "$terminfo[cuu1]%.0s" {1..$lines}  # cursor up
    zle reset-prompt
}
zle -N fixed-clear-screen
bindkey '^L' fixed-clear-screen

https://superuser.com/questions/1389834 改来的代码。Terminal 通常会保留你之前的输入输出,往上滚动的时候可以看到这些历史信息,称为 scrollback。系统自带(?)的 clear 程序不仅会清屏,也会清除 scrollback,而很多时候我们只是希望当前输入行被顶到上面去而已。这里用了一种移植性不太好但效果还不错的办法:发出特殊的控制指令让 terminal 把指针往下移一屏把当前输入行顶上去,再把指针往上移回去,最后调用 reset-prompt 恢复指针的正常横坐标。

链接最高赞末尾有一个很好的设计,就是绑定到 Enter ,然后检测当前 buffer,如果为空就清屏,不为空就提交指令。但它前面的部分有点冗长,我这里利用 Zsh 的特性进行了大幅简化。Zsh 的 printf 如果参数是一个数组就会对每个数组元素应用前面的格式串。我们可以利用这个特性在格式串里塞一个零宽度的格式 %.0s 然后把它应用到一个 N 个元素的数组里,从而输出同样的字符串 N 遍。这个特性还有一个很常见的用法是 printf '%s\n' $path,一行一个元素地查看 $PATH

# [Ctrl-R] Search history by fzf-tab
fzf-history-search() {
    local selected=$(
        fc -rl 1 |
        ftb-tmux-popup -n '2..' --tiebreak=index --prompt='cmd> ' ${BUFFER:+-q$BUFFER}
    )
    if [[ $selected != '' ]] {
        zle vi-fetch-history -n $selected
    }
    zle reset-prompt
}
zle -N fzf-history-search
bindkey '^R' fzf-history-search

Aloxaf 的 dotfiles 改来的。其实 fzf 的 repo 里就提供了好几个按键绑定,让你用 fzf 搜索历史、跳转路径等等。这里自己重写一个主要是为了用上 fzf-tab 提供的 ftb-tmux-popup ,它在 tmux 里视觉效果非常好。(快点前一个链接!)

# [Ctrl-N] Navigate by xplr
bindkey -s '^N' '^Q cd -- ${$(xplr):-.} \n'

这是一个很精巧的按键绑定。Xplr 是一个命令行文件浏览器,如果你在里面选择了一个文件/文件夹,它会退出并输出对应的路径。基于这点我们可以用 cd -- $(xplr) 来使用 xplr 快速导航。但如果我们中途决定直接退出的话,xplr 会返回空字符串,这时 cd 就会报错。因此我们包装一下,这种情况下返回 .,现在就变成了 cd -- ${$(xplr):-.}。为了方便,我们用 bindkey -s 把它作为一条指令(而不是 widget)绑定到按键上。先 ^Q 进行 push-line ,然后加一个空格使得这条指令不进入历史记录(别忘记我前面设置了 hist_ignore_space),最后回车提交指令。我也把这行精巧的代码分享到了 xplr 的 discussion。它的仓库里也还有许多别的用法示例。

速度

有了 instant prompt ,启动速度对我已经不那么重要了。不过还是让我们看看,在我配置了这么多东西,用了这么多插件的情况下,究竟能达到什么速度。我的 CPU 是 3700X ,以下是我的 Zsh 以 interative 模式启动(会加载 .zshrc)到执行完第一条指令退出的时间:

$ hyperfine 'zsh -ic exit'
Benchmark 1: zsh -ic exit
  Time (mean ± σ):      61.6 ms ±   2.4 ms    [User: 41.8 ms, System: 21.8 ms]
  Range (min … max):    59.7 ms …  75.4 ms    38 runs