受不了 oh-my-zsh 的速度,我换到了 zinit
oh-my-zsh 我已经用了好几年了,几乎从我学习开发的第一天起,就一直是它。但是这几年配置越加越多,插件、补全、各种工具的初始化,全都堆在一个 .zshrc 里,结果就是每次打开终端,都要等上一下才能开始输入。新开一个标签页、在编辑器里打开终端、分屏,每一次都要等,而且有时候输入还会有点不跟手。
一开始我也想过精简一下,减少几个插件,但是减来减去效果都不明显,而且很多插件我是真的在用。所以最后决定,干脆换一个插件管理器。
慢在哪
在换之前,我先看了一下到底是哪里慢。zsh 自带了一个 zprof 模块,可以统计启动过程中每个函数花了多少时间。只需要在 .zshrc 的第一行加上 zmodload zsh/zprof,最后一行加上 zprof,然后新开一个终端,就会打印出一张耗时表。
# ~/.zshrc 第一行
zmodload zsh/zprof
# ... 原来的配置 ...
# ~/.zshrc 最后一行
zprof
以及用 time 粗略地测一下整体的启动时间。
for i in {1..10}; do time zsh -i -c exit; done
根据测试结果,时间主要花在:
- oh-my-zsh 本身要加载它的一整套 lib,然后再挨个 source 插件,最后跑一遍
compinit初始化补全,这些都是同步的,全部做完才会显示提示符。 - 各种工具的初始化,比如
starship init、zoxide init、fzf --zsh、mise activate、gh copilot alias,每一个都是eval "$(xxx)",也就是每次启动都要先跑一个子进程,再执行它的输出。 - 零零碎碎的
PATH、函数定义、别名,单个不慢,但是加起来也不少。
而 oh-my-zsh 的问题在于,它没有办法把这些东西往后放。不管一个插件是不是马上要用,都得在提示符出来之前加载完。
zinit
zinit 是一个 zsh 的插件管理器,最早由 zdharma 开发。2021 年原作者把仓库删掉了,现在由社区在 zdharma-continuum/zinit 继续维护。
它最吸引我的是 Turbo 模式。简单来说,就是先把提示符显示出来,让你可以马上开始输入,然后再在后台把插件一个个加载进来。因为人从看到提示符到敲下第一个命令,中间总会有那么一点时间,而这点时间已经够把大部分插件加载完了,所以体感上就是终端一打开就能用。
另外 zinit 还能直接加载 oh-my-zsh 里的单个文件,它把这种用法叫做 snippet。所以我不需要整个 oh-my-zsh 框架,只把常用的那几个 lib 和插件拿过来就行,原来习惯的那些缩写和行为也都还在。
安装
zinit 官方的安装脚本会在 .zshrc 顶部加上一段代码,如果 zinit 不存在就自动克隆,然后加载它。
### Added by Zinit's installer
if [[ ! -f $HOME/.local/share/zinit/zinit.git/zinit.zsh ]]; then
print -P "%F{33} %F{220}Installing %F{33}ZDHARMA-CONTINUUM%F{220} Initiative Plugin Manager (%F{33}zdharma-continuum/zinit%F{220})…%f"
command mkdir -p "$HOME/.local/share/zinit" && command chmod g-rwX "$HOME/.local/share/zinit"
command git clone https://github.com/zdharma-continuum/zinit "$HOME/.local/share/zinit/zinit.git" && \
print -P "%F{33} %F{34}Installation successful.%f%b" || \
print -P "%F{160} The clone has failed.%f%b"
fi
source "$HOME/.local/share/zinit/zinit.git/zinit.zsh"
autoload -Uz _zinit
(( ${+_comps} )) && _comps[zinit]=_zinit
这样一来,换到一台新电脑上,只要把 .zshrc 放过去,第一次打开终端的时候它就会自己把 zinit 装好,插件也会在第一次加载的时候自动下载。
下面的配置里我用的都是 zi,它是 zinit 自带的简写,和 zinit 是同一个命令。
几个关键的 ice
zinit 的配置看起来有点怪,因为它大量使用一种叫 ice 的修饰符。ice 写在 zi ice 后面,或者直接写在 for 语法的前面,只对紧接着的那一次加载生效,就像冰块一样用一次就化了,名字大概也是这么来的。
我用到的主要是这几个。
| ice | 作用 |
|---|---|
wait | 开启 Turbo 模式,等提示符出来之后再加载 |
lucid | 后台加载完成时不要打印提示信息 |
light-mode | 不追踪插件做了什么,加载更快,代价是不能用 zinit unload 卸载 |
blockf | 不让插件自己改 fpath,补全统一交给 zinit 管理 |
depth"1" | 克隆插件时只拉最新的一次提交 |
as"completion" | 把这个文件当成补全文件来处理 |
atinit / atclone / atpull | 在加载前、克隆后、更新后执行一段命令 |
把 oh-my-zsh 搬过来
oh-my-zsh 的 lib 和插件,可以用 OMZL:: 和 OMZP:: 这两个前缀直接加载。我把原来用到的那些都列了出来,全部延迟加载。
zi wait lucid light-mode for \
OMZL::theme-and-appearance.zsh \
OMZL::key-bindings.zsh \
OMZL::clipboard.zsh \
OMZL::history.zsh \
OMZL::git.zsh \
OMZP::git \
OMZP::history \
OMZP::eza \
OMZP::extract \
OMZP::git-commit \
OMZP::rust \
OMZP::copyfile
OMZL 是 oh-my-zsh 的 lib,比如按键绑定、历史记录的设置、git 相关的辅助函数,OMZP 则是插件。那么 OMZP::git 就是原来那个提供了一大堆 git 缩写的插件,gst、gco 这些都还能照常使用。
不过有一点要注意,oh-my-zsh 原本会帮你设置一些 zsh 的选项,现在没有了框架,这些就要自己写。
setopt promptsubst
setopt interactive_comments
setopt auto_cd
setopt auto_pushd
setopt pushd_ignore_dups
setopt pushdminus
提示符
我用的提示符是 starship。如果 starship 也延迟加载,那么在它加载完之前,终端会先显示 zsh 默认的那个光秃秃的提示符,然后一闪变成 starship 的样子,看着有点难受。
所以我先在 .zshrc 里手写了一个和 oh-my-zsh 的 robbyrussell 主题长得一样的提示符,命令成功是绿色箭头,失败是红色箭头,后面跟着当前目录名。这个提示符不依赖任何插件,所以终端一打开就有。
PROMPT="%(?:%{$fg_bold[green]%}%1{➜%} :%{$fg_bold[red]%}%1{➜%} ) %{$fg[cyan]%}%c%{$reset_color%} "
然后 starship 再在后台加载,加载完之后接管提示符。因为两者长得差不多,所以切换的时候基本看不出来。
starship 本身我也换成了用 zinit 来装。from"gh-r" 会直接从 GitHub Release 下载编译好的二进制,atclone 则在下载之后执行一次 starship init zsh,把输出保存成 init.zsh,之后每次启动直接 source 这个文件就行,不用再每次都跑一遍 starship init。atpull"%atclone" 的意思是更新之后再重新生成一次。
if $IS_VSCODE; then
# vscode has _precmd and _preexec hooks conflict with starship if we use async
zi ice lucid as"command" from"gh-r" \
atclone"./starship init zsh > init.zsh; ./starship completions zsh > _starship" \
atpull"%atclone" src"init.zsh"
else
zi ice wait lucid as"command" from"gh-r" \
atclone"./starship init zsh > init.zsh; ./starship completions zsh > _starship" \
atpull"%atclone" src"init.zsh"
fi
zi light starship/starship
这里单独判断了一下 VS Code。因为 VS Code 的终端有自己的 shell integration,会挂上 precmd 和 preexec 两个 hook,如果 starship 是异步加载进来的,就会和它冲突,所以在 VS Code 里我让 starship 同步加载。判断的方法是看 TERM_PROGRAM 这个变量。
IS_VSCODE=false
if [[ "$TERM_PROGRAM" == "vscode" ]]; then
IS_VSCODE=true
fi
其他插件
GitHub 上的插件也一样,写上仓库名就行。
zi wait lucid light-mode depth"1" blockf for \
Aloxaf/fzf-tab \
orbstack/orbstack \
ajeetdsouza/zoxide
其中 fzf-tab 会把 zsh 原生的补全菜单换成 fzf,按 Tab 之后可以模糊搜索,还能预览。我给 cd 的补全加了一个预览,用 eza 列出目录里的内容。
zstyle ':completion:*' menu no
zstyle ':fzf-tab:complete:cd:*' fzf-preview 'eza -1 --color=always $realpath'
zstyle ':fzf-tab:*' switch-group '<' '>'
补全
补全这块稍微讲究一点。zsh 的补全需要先运行 compinit 来初始化,而且最好只运行一次,因为它本身就不快。但是插件是在后台陆续加载的,每个插件都可能带着自己的补全,所以 compinit 要等到所有补全都就位之后再跑。
zinit 的做法是,在加载插件的时候,先把各个插件里调用的 compdef 记下来,最后再用 zicompinit 初始化补全,接着用 zicdreplay 把之前记下来的 compdef 重放一遍。所以我把这一步挂在最后加载的 zsh-syntax-highlighting 上,用 atinit 在它加载之前执行。
zi wait lucid light-mode depth"1" for \
atinit"ZINIT[COMPINIT_OPTS]=-C; zicompinit; zicdreplay" \
zsh-users/zsh-syntax-highlighting \
blockf \
zsh-users/zsh-completions
ZINIT[COMPINIT_OPTS]=-C 会让 compinit 跳过检查,直接使用已经生成好的补全缓存,这样又能快一点。
一些单独的补全文件,比如 docker、fd、ripgrep 的,也可以直接用地址加载,加上 as"completion" 就会被当成补全文件放到合适的位置。
zi wait lucid light-mode as"completion" for \
OMZP::docker/completions/_docker \
OMZP::docker-compose/_docker-compose \
https://raw.githubusercontent.com/sharkdp/fd/master/contrib/completion/_fd \
id-as"_rg" \
https://raw.githubusercontent.com/BurntSushi/ripgrep/master/crates/core/flags/complete/rg.zsh
ripgrep 的补全文件名叫 rg.zsh,但是 zsh 要求补全文件以下划线开头,所以用 id-as"_rg" 给它换个名字。
拆成几个模块
以前所有东西都在一个 .zshrc 里,两百多行翻起来很累。这次我把自己写的那部分拆了出来,放进 ~/.config/zinit/ 里的四个文件。
~/.config/zinit/
├── env.zsh # 环境变量和 PATH
├── func.zsh # 自己写的函数
├── active.zsh # 需要 eval 激活的工具,比如 fzf 和 mise
└── alias.zsh # 别名
然后用 zinit 一次性把它们都加载进来,同样是延迟加载。
zi ice wait'0' lucid light-mode blockf multisrc'~/.config/zinit/{env,func,active,alias}.zsh'
zi light z-shell/null
multisrc 可以一次 source 多个文件,但是 ice 总得附着在一次加载上,所以这里借用了一个空插件 z-shell/null,它本身什么都不做,只是给这几个文件当个载体。
active.zsh 里放的就是那些 eval 的初始化,现在它们也都被挪到了提示符出来之后。
# Activate some plugins and completions
source <(fzf --zsh)
eval "$(mise activate zsh)"
func.zsh 里则是我平时攒下来的一些小函数,比如修改上一次提交时间的 git-amend-date,用 yazi 浏览完目录之后顺便 cd 过去的 yy,还有一个检查 cargo 是不是用错了的 check_cargo。
# git last commit date edit
function git-amend-date() {
local date=$1
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" git commit --amend --no-edit --date="$date" --no-verify
}
# check if using correct cargo
function check_cargo() {
cargo_path=$(which cargo 2>/dev/null)
if [[ -z "$cargo_path" ]]; then
echo "Cargo is not installed or not in PATH."
return 1
fi
if [[ "$cargo_path" == "/opt/homebrew/bin/cargo" ]]; then
echo "$cargo_path cargo path error"
fi
}
这样一来,.zshrc 里只剩下 zinit 的加载顺序和几个 zsh 的选项,现在一共不到一百行,想改什么也能很快找到对应的文件。
整体的加载顺序
最后整理一下,打开终端的时候,大致是这样一个过程。
实线的部分是同步的,只有加载 zinit 和设置几个选项,非常快。虚线的部分都在后台,等提示符出来之后再陆续完成。
用起来
换完之后,打开终端基本就是秒开,提示符一出来就能打字,分屏和新开标签页也不用再等了。而 oh-my-zsh 里用习惯的那些东西,git 的缩写、按键绑定、历史记录的行为,全都还在,所以几乎没有什么需要重新适应的地方。
平时更新插件也很简单,zi update --parallel 就会并行更新所有插件,zi self-update 则是更新 zinit 本身。