TL;DR
先用
zprof量,再动手。我的 650ms 里有 350ms 花在一个从没用过的功能上;另有三处顺序问题和一个漏掉的export完全不报错——包括 oh-my-zsh 的zstyle ':omz:update' mode auto只要写在source $ZSH/oh-my-zsh.sh之后,启动时的自动更新检查就永远读不到它。
新开终端总觉得有一下卡顿,于是先量了一次:
1 | for i in 1 2 3 4 5; do /usr/bin/time -p zsh -i -c exit 2>&1 | grep real; done |
1 | real 0.91 |
650ms 对一个每天要开几十次的终端来说不算小。更麻烦的是,打开 ~/.zshrc 会发现它已经 231 行——其中约 120 行是 oh-my-zsh 安装时的注释模板,剩下的被十来个第三方安装器分散追加在文件各处。这种文件没法靠读来判断问题在哪。
先量,再动手
zsh 自带 zprof 模块,能给出每个函数的耗时:
1 | zsh -f -c 'zmodload zsh/zprof; source ~/.zshrc >/dev/null 2>&1; zprof | head -12' |
zsh -f 跳过所有启动文件,避免统计到自身。输出(节选):
1 | num calls time self name |
两个信号很直接:nvm_auto 一项 351ms,而 compinit 的 calls 是 2。
一半耗时来自一个我从没用过的功能
nvm_auto 是 nvm 在 shell 启动时自动切换到 default 版本。它存在的意义是配合按项目切换——可我全盘搜了一遍:
1 | find ~/git -maxdepth 4 -name ".nvmrc" -not -path "*/node_modules/*" | wc -l |
1 | 0 |
一个 .nvmrc 都没有。也就是说 nvm 每次花 350ms,只为把一个固定版本放进 PATH。
两个版本管理器并存,项目 pin 会静默失效
真正的问题在后面。我同时装了 mise,某个项目下有 .mise.toml 要求 node 24:
1 | [tools] |
cd 进去,node -v 输出的却是 v25.7.0——nvm 的 default。查 mise 的状态:
1 | mise ls |
1 | node 24.20.0 (missing) ~/git/MyApp/.mise.toml 24 |
(missing) 说明 mise 知道该用 24,但没装;而 PATH 里 nvm 的 25.7.0 顶上了。整个过程没有任何报错,项目 pin 就这么被无声忽略了。
两个版本管理器同时管一门语言,迟早会撞上这个。我的处理是删掉 nvm、统一到 mise:
1 | mise use -g node@25.7.0 |
nvm 下的全局 npm 包需要重装一遍(这是迁移的主要成本,先用 npm ls -g --depth=0 列出来)。确认无误后再卸载:
1 | brew uninstall nvm |
顺序错了但不报错的三个地方
1. oh-my-zsh 的 zstyle 写晚了
我的 .zshrc 里,zstyle ':omz:update' mode auto 在 source $ZSH/oh-my-zsh.sh 的下面。看起来无所谓,实际上:
1 | grep -n "check_for_upgrade" ~/.oh-my-zsh/oh-my-zsh.sh |
1 | 71:source "$ZSH/tools/check_for_upgrade.sh" |
1 | sed -n '14p' ~/.oh-my-zsh/tools/check_for_upgrade.sh |
1 | zstyle -s ':omz:update' mode update_mode || { |
oh-my-zsh 在自身加载到第 71 行时就读这个值。写在 source 之后,启动时的自动更新检查读到的是空——设置等于没写。(手动执行 omz update 时会重新读,所以不是完全无效,但自动更新那条路走不通。)
验证是否真的生效:
1 | zsh -i -c 'zstyle -L ":omz:update"' |
2. 第二次 compinit
上面 zprof 里 compinit 的 calls 是 2。翻文件找到末尾这段——某个 CLI 的安装脚本追加的:
1 | # >>> some-cli installer >>> |
oh-my-zsh 已经跑过 compinit 了:
1 | grep -n "^\s*compinit" ~/.oh-my-zsh/oh-my-zsh.sh |
1 | 129: compinit -i -d "$ZSH_COMPDUMP" |
安装器的做法本身没错——它不知道你用了 oh-my-zsh。正确的合并方式是把 fpath=(...) 那行移到 source $ZSH/oh-my-zsh.sh 之前,然后删掉安装器带来的 compinit。补全照常工作,省下约 100ms。
3. 交互增强插件必须在最后
zsh-autosuggestions 和 zsh-syntax-highlighting 需要观察前面各集成注册的 widget,位置提前会丢绑定。这条广为人知,但当安装器不断往文件末尾追加时,它们会被慢慢挤到中间——这正是下面「用软链根治」要解决的问题。
还有一个漏掉的 export
1 | HOMEBREW_AUTO_UPDATE_SECS=86400 |
少了 export,它只是个 shell 变量,brew 子进程根本读不到。这类问题的通用验证方法是查子进程可见的环境,而不是 echo:
1 | zsh -i -c 'env | grep -c HOMEBREW_AUTO_UPDATE_SECS' |
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 上。查下去发现安装目录是空的:
1 | mise where ruby@3.4.9 |
那次编译从没成功,只留了个空壳,而 mise ls 仍报告已安装。mise uninstall 后重装才正常。
以后遇到 mise 报告已装、但工具行为不对,先看 mise where 指向的目录是不是空的。
Homebrew ruby 卸不掉,但可以移出 PATH
统一 ruby 时想直接卸载 Homebrew 版,先查了一下依赖:
1 | brew uses --installed ruby |
1 | fastlane |
Homebrew 的 fastlane 公式依赖它,卸载会连带废掉 fastlane。但看 wrapper 脚本就会发现,它并不依赖你 PATH 里的 ruby:
1 | sed -n '2p' /opt/homebrew/bin/fastlane |
1 | PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/Cellar/fastlane/2.238.0/libexec/bin:..." |
ruby 路径和 GEM_HOME 都写死在脚本内部。所以正确做法是:保留安装,但把 /opt/homebrew/opt/ruby/bin 从 .zshrc 的 PATH 里去掉。 fastlane 照常工作,ruby 则交给 mise。
代价是原本装在 Homebrew ruby 下的 gem 二进制(jekyll、nokogiri、sass、xcpretty 等)不再在 PATH 上。它们仍然装着,需要时用全路径调用,或者在项目 Gemfile 里声明——后者本来就是更好的做法。
用软链根治「追加到末尾」
上面三处顺序问题里有两处都源于同一个习惯:安装器(和我们自己)往 ~/.zshrc 末尾追加。我翻自己两个月前的文章,里面就写着:
1 | echo 'export PATH="/opt/homebrew/opt/curl/bin:$PATH"' >> ~/.zshrc |
对单次操作没毛病,重复十几次之后文件就没结构了。
我的做法是把 .zshrc 放进一个 Git 仓库,~/.zshrc 软链过去:
1 | mv ~/.zshrc ~/.zshrc.bak.$(date +%s) |
用软链而不是拷贝,关键在于:安装器仍然会往末尾追加,但那些追加会直接显示为 git 改动:
1 | git -C ~/git/zsh-config diff |
看到就归位,不会积累,也不会出现仓库副本和实际文件互相镶不上的情况。
文件本身按段组织——Zsh 基础、环境变量、oh-my-zsh、PATH、工具集成、别名、交互增强——新东西归入对应段落,而不是堆在末尾。
结果
231 行降到 104 行,650ms 降到 160ms:
1 | real 0.16 |
再看 zprof,nvm 整个消失,compinit calls 从 2 变 1、耗时从 167ms 降到 12ms。
验证时的一个细节
改完 PATH 相关配置后,zsh -i -c '...' 验证是不够的——它会继承父 shell 的 PATH,你以为删掉的路径可能还在。用 env -i 起一个干净的登录 shell:
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 依据排查过程中的记录整理而成。