.zshrc 里不会报错的四个错:启动从 650ms 降到 160ms

本文目录

TL;DR

先用 zprof 量,再动手。我的 650ms 里有 350ms 花在一个从没用过的功能上;另有三处顺序问题和一个漏掉的 export 完全不报错——包括 oh-my-zsh 的 zstyle ':omz:update' mode auto 只要写在 source $ZSH/oh-my-zsh.sh 之后,启动时的自动更新检查就永远读不到它。

新开终端总觉得有一下卡顿,于是先量了一次:

bash
1
for i in 1 2 3 4 5; do /usr/bin/time -p zsh -i -c exit 2>&1 | grep real; done
text
1
2
3
4
5
real 0.91
real 0.67
real 0.63
real 0.65
real 0.67

650ms 对一个每天要开几十次的终端来说不算小。更麻烦的是,打开 ~/.zshrc 会发现它已经 231 行——其中约 120 行是 oh-my-zsh 安装时的注释模板,剩下的被十来个第三方安装器分散追加在文件各处。这种文件没法靠读来判断问题在哪。

先量,再动手

zsh 自带 zprof 模块,能给出每个函数的耗时:

bash
1
zsh -f -c 'zmodload zsh/zprof; source ~/.zshrc >/dev/null 2>&1; zprof | head -12'

zsh -f 跳过所有启动文件,避免统计到自身。输出(节选):

text
1
2
3
4
5
6
7
8
num  calls                time                       self            name
-----------------------------------------------------------------------------
1) 2 220.51 110.25 38.45% 130.58 65.29 22.77% nvm
2) 1 351.82 351.82 61.35% 115.54 115.54 20.15% nvm_auto
3) 1 81.67 81.67 14.24% 70.72 70.72 12.33% nvm_ensure_version_installed
4) 809 55.98 0.07 9.76% 55.98 0.07 9.76% compdef
5) 2 167.87 83.93 29.27% 52.63 26.31 9.18% compinit
6) 1 51.59 51.59 9.00% 51.59 51.59 9.00% compdump

两个信号很直接:nvm_auto 一项 351ms,而 compinit 的 calls 是 2。

一半耗时来自一个我从没用过的功能

nvm_auto 是 nvm 在 shell 启动时自动切换到 default 版本。它存在的意义是配合按项目切换——可我全盘搜了一遍:

bash
1
find ~/git -maxdepth 4 -name ".nvmrc" -not -path "*/node_modules/*" | wc -l
text
1
0

一个 .nvmrc 都没有。也就是说 nvm 每次花 350ms,只为把一个固定版本放进 PATH。

两个版本管理器并存,项目 pin 会静默失效

真正的问题在后面。我同时装了 mise,某个项目下有 .mise.toml 要求 node 24:

toml
1
2
[tools]
node = "24"

cd 进去,node -v 输出的却是 v25.7.0——nvm 的 default。查 mise 的状态:

bash
1
mise ls
text
1
node  24.20.0 (missing)  ~/git/MyApp/.mise.toml  24

(missing) 说明 mise 知道该用 24,但没装;而 PATH 里 nvm 的 25.7.0 顶上了。整个过程没有任何报错,项目 pin 就这么被无声忽略了。

两个版本管理器同时管一门语言,迟早会撞上这个。我的处理是删掉 nvm、统一到 mise:

bash
1
2
mise use -g node@25.7.0
mise install

nvm 下的全局 npm 包需要重装一遍(这是迁移的主要成本,先用 npm ls -g --depth=0 列出来)。确认无误后再卸载:

bash
1
brew uninstall nvm

顺序错了但不报错的三个地方

1. oh-my-zsh 的 zstyle 写晚了

我的 .zshrc 里,zstyle ':omz:update' mode auto 在 source $ZSH/oh-my-zsh.sh 的下面。看起来无所谓,实际上:

bash
1
grep -n "check_for_upgrade" ~/.oh-my-zsh/oh-my-zsh.sh
text
1
71:source "$ZSH/tools/check_for_upgrade.sh"
bash
1
sed -n '14p' ~/.oh-my-zsh/tools/check_for_upgrade.sh
text
1
zstyle -s ':omz:update' mode update_mode || {

oh-my-zsh 在自身加载到第 71 行时就读这个值。写在 source 之后,启动时的自动更新检查读到的是空——设置等于没写。(手动执行 omz update 时会重新读,所以不是完全无效,但自动更新那条路走不通。)

验证是否真的生效:

bash
1
zsh -i -c 'zstyle -L ":omz:update"'

2. 第二次 compinit

上面 zprof 里 compinit 的 calls 是 2。翻文件找到末尾这段——某个 CLI 的安装脚本追加的:

plaintext
1
2
3
4
5
# >>> some-cli installer >>>
export PATH="$HOME/.some-cli/bin:$PATH"
fpath=(~/.some-cli/completions/zsh $fpath)
autoload -Uz compinit && compinit -C
# <<< some-cli installer <<<

oh-my-zsh 已经跑过 compinit 了:

bash
1
grep -n "^\s*compinit" ~/.oh-my-zsh/oh-my-zsh.sh
text
1
2
129:  compinit -i -d "$ZSH_COMPDUMP"
134: compinit -u -d "$ZSH_COMPDUMP"

安装器的做法本身没错——它不知道你用了 oh-my-zsh。正确的合并方式是把 fpath=(...) 那行移到 source $ZSH/oh-my-zsh.sh 之前,然后删掉安装器带来的 compinit。补全照常工作,省下约 100ms。

3. 交互增强插件必须在最后

zsh-autosuggestions 和 zsh-syntax-highlighting 需要观察前面各集成注册的 widget,位置提前会丢绑定。这条广为人知,但当安装器不断往文件末尾追加时,它们会被慢慢挤到中间——这正是下面「用软链根治」要解决的问题。

还有一个漏掉的 export

plaintext
1
HOMEBREW_AUTO_UPDATE_SECS=86400

少了 export,它只是个 shell 变量,brew 子进程根本读不到。这类问题的通用验证方法是查子进程可见的环境,而不是 echo:

bash
1
zsh -i -c 'env | grep -c HOMEBREW_AUTO_UPDATE_SECS'
text
1
0

echo $HOMEBREW_AUTO_UPDATE_SECS 会正常打印 86400,看不出问题——这是它能潜伏这么久的原因。

顺带一提,oh-my-zsh 默认 HISTSIZE=50000 而 SAVEHIST=10000:内存里留 5 万条,落盘只存 1 万条。想保留长历史的话两个都要设。

mise 说装好了,但目录是空的

迁移 ruby 时踩到一个值得单独说的坑。mise ls 显示 ruby 3.4.9 已安装,但用它执行 gem install 却落到了系统另一个 ruby 上。查下去发现安装目录是空的:

bash
1
2
mise where ruby@3.4.9
ls "$(mise where ruby@3.4.9)"

那次编译从没成功,只留了个空壳,而 mise ls 仍报告已安装。mise uninstall 后重装才正常。

以后遇到 mise 报告已装、但工具行为不对,先看 mise where 指向的目录是不是空的。

Homebrew ruby 卸不掉,但可以移出 PATH

统一 ruby 时想直接卸载 Homebrew 版,先查了一下依赖:

bash
1
brew uses --installed ruby
text
1
fastlane

Homebrew 的 fastlane 公式依赖它,卸载会连带废掉 fastlane。但看 wrapper 脚本就会发现,它并不依赖你 PATH 里的 ruby:

bash
1
sed -n '2p' /opt/homebrew/bin/fastlane
text
1
2
PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/Cellar/fastlane/2.238.0/libexec/bin:..."
GEM_HOME="${FASTLANE_GEM_HOME:-${HOME}/.local/share/fastlane/4.0.0}"

ruby 路径和 GEM_HOME 都写死在脚本内部。所以正确做法是:保留安装,但把 /opt/homebrew/opt/ruby/bin 从 .zshrc 的 PATH 里去掉。 fastlane 照常工作,ruby 则交给 mise。

代价是原本装在 Homebrew ruby 下的 gem 二进制(jekyll、nokogiri、sass、xcpretty 等)不再在 PATH 上。它们仍然装着,需要时用全路径调用,或者在项目 Gemfile 里声明——后者本来就是更好的做法。

用软链根治「追加到末尾」

上面三处顺序问题里有两处都源于同一个习惯:安装器(和我们自己)往 ~/.zshrc 末尾追加。我翻自己两个月前的文章,里面就写着:

bash
1
echo 'export PATH="/opt/homebrew/opt/curl/bin:$PATH"' >> ~/.zshrc

对单次操作没毛病,重复十几次之后文件就没结构了。

我的做法是把 .zshrc 放进一个 Git 仓库,~/.zshrc 软链过去:

bash
1
2
mv ~/.zshrc ~/.zshrc.bak.$(date +%s)
ln -s "$HOME/git/zsh-config/zshrc" ~/.zshrc

用软链而不是拷贝,关键在于:安装器仍然会往末尾追加,但那些追加会直接显示为 git 改动:

bash
1
git -C ~/git/zsh-config diff

看到就归位,不会积累,也不会出现仓库副本和实际文件互相镶不上的情况。

文件本身按段组织——Zsh 基础、环境变量、oh-my-zsh、PATH、工具集成、别名、交互增强——新东西归入对应段落,而不是堆在末尾。

结果

231 行降到 104 行,650ms 降到 160ms:

text
1
2
3
4
5
real 0.16
real 0.16
real 0.16
real 0.16
real 0.16

再看 zprof,nvm 整个消失,compinit calls 从 2 变 1、耗时从 167ms 降到 12ms。

验证时的一个细节

改完 PATH 相关配置后,zsh -i -c '...' 验证是不够的——它会继承父 shell 的 PATH,你以为删掉的路径可能还在。用 env -i 起一个干净的登录 shell:

bash
1
env -i HOME="$HOME" USER="$USER" TERM=xterm-256color /bin/zsh -l -i -c 'command -v node ruby'

-l 让 ~/.zprofile 也走一遍(Homebrew 的 shellenv 通常在那里),这才是新开终端窗口的真实情况。我就是靠这一步才发现有条 PATH 一直是从父进程继承来的,而不是 .zshrc 设置的。

适用范围

macOS 26.6.2(arm64)、zsh 5.9、oh-my-zsh(2026-08 版本)、mise 2026.8.14。oh-my-zsh 的具体行号会随版本变化,用上面的 grep 命令自己定位;结论本身不依赖行号。

参考资料

本文由 Claude Code 依据排查过程中的记录整理而成。