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 のものも、URL を直接指定して読み込めます。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 自体を更新します。