受不了 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...