受不了 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 本身。