受不了 oh-my-zsh 的速度,我换到了 zinit

2025年1月12日星期日 / , , ,
摘要:
oh-my-zsh 用了好几年,终端打开越来越慢,于是我换成了 zinit,用 Turbo 模式延迟加载插件,再把配置拆成几个模块,同时把 oh-my-zsh 里常用的东西原样搬了过来。

oh-my-zsh 我已经用了好几年了,几乎从我学习开发的第一天起,就一直是它。但是这几年配置越加越多,插件、补全、各种工具的初始化,全都堆在一个 .zshrc 里,结果就是每次打开终端,都要等上一下才能开始输入。新开一个标签页、在编辑器里打开终端、分屏,每一次都要等,而且有时候输入还会有点不跟手。

一开始我也想过精简一下,减少几个插件,但是减来减去效果都不明显,而且很多插件我是真的在用。所以最后决定,干脆换一个插件管理器。

慢在哪

在换之前,我先看了一下到底是哪里慢。zsh 自带了一个 zprof 模块,可以统计启动过程中每个函数花了多少时间。只需要在 .zshrc 的第一行加上 zmodload zsh/zprof,最后一行加上 zprof,然后新开一个终端,就会打印出一张耗时表。

Shell
# ~/.zshrc 第一行
zmodload zsh/zprof

# ... 原来的配置 ...

# ~/.zshrc 最后一行
zprof

以及用 time 粗略地测一下整体的启动时间。

Shell
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 不存在就自动克隆,然后加载它。

Shell
### 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:: 这两个前缀直接加载。我把原来用到的那些都列了出来,全部延迟加载。

Shell
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 的选项,现在没有了框架,这些就要自己写。

Shell
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 主题长得一样的提示符,命令成功是绿色箭头,失败是红色箭头,后面跟着当前目录名。这个提示符不依赖任何插件,所以终端一打开就有。

Shell
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" 的意思是更新之后再重新生成一次。

Shell
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 这个变量。

Shell
IS_VSCODE=false
if [[ "$TERM_PROGRAM" == "vscode" ]]; then
    IS_VSCODE=true
fi

其他插件

GitHub 上的插件也一样,写上仓库名就行。

Shell
zi wait lucid light-mode depth"1" blockf for \
    Aloxaf/fzf-tab \
    orbstack/orbstack \
    ajeetdsouza/zoxide

其中 fzf-tab 会把 zsh 原生的补全菜单换成 fzf,按 Tab 之后可以模糊搜索,还能预览。我给 cd 的补全加了一个预览,用 eza 列出目录里的内容。

Shell
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 在它加载之前执行。

Shell
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" 就会被当成补全文件放到合适的位置。

Shell
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/ 里的四个文件。

TEXT
~/.config/zinit/
├── env.zsh      # 环境变量和 PATH
├── func.zsh     # 自己写的函数
├── active.zsh   # 需要 eval 激活的工具,比如 fzf 和 mise
└── alias.zsh    # 别名

然后用 zinit 一次性把它们都加载进来,同样是延迟加载。

Shell
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 的初始化,现在它们也都被挪到了提示符出来之后。

Shell
# Activate some plugins and completions

source <(fzf --zsh)
eval "$(mise activate zsh)"

func.zsh 里则是我平时攒下来的一些小函数,比如修改上一次提交时间的 git-amend-date,用 yazi 浏览完目录之后顺便 cd 过去的 yy,还有一个检查 cargo 是不是用错了的 check_cargo。

Shell
# 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 的选项,现在一共不到一百行,想改什么也能很快找到对应的文件。

整体的加载顺序

最后整理一下,打开终端的时候,大致是这样一个过程。

Mermaid diagram

实线的部分是同步的,只有加载 zinit 和设置几个选项,非常快。虚线的部分都在后台,等提示符出来之后再陆续完成。

用起来

换完之后,打开终端基本就是秒开,提示符一出来就能打字,分屏和新开标签页也不用再等了。而 oh-my-zsh 里用习惯的那些东西,git 的缩写、按键绑定、历史记录的行为,全都还在,所以几乎没有什么需要重新适应的地方。

平时更新插件也很简单,zi update --parallel 就会并行更新所有插件,zi self-update 则是更新 zinit 本身。

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...