跳转到内容

zsh 启动文件与加载顺序 ​

为什么需要它 ​

三个常见症状,其实是同一个问题:

  • 用 Dock 或 Spotlight 打开一个 GUI 应用,它提示找不到用 Homebrew 装的命令——终端里明明能用。
  • ssh 主机 命令 执行时报错或版本不对,登录进去手动敲同一条命令却正常。
  • 明明用 Homebrew 装了新版本的工具,终端里跑出来的却还是系统自带的旧版本。

三者的根因相同:环境变量、别名、PATH 修改被写进了错误的配置文件,而 zsh 在不同场景下只读取其中一部分文件。不理解加载顺序,改配置就是在猜。

开始之前 ​

前置:终端与 Shell 基础。你需要能在终端里查看当前目录、用 cat 读文件内容。

先备份,改坏了才有退路:

zsh
cp ~/.zshrc ~/.zshrc.backup

如果 ~/.zshrc 尚不存在,跳过这一步——没有旧文件也就没有可备份的内容。

恢复方式:保留一个已经打开的终端窗口不要关。它的 shell 早已启动完毕,不会因为你改坏了配置文件而受影响,用它把文件改回去或者把备份拷贝回来即可:cp ~/.zshrc.backup ~/.zshrc。真正的风险在于新开窗口——如果 .zshrc 里有语法错误,新窗口的 shell 可能直接报错甚至退出。

操作步骤 ​

1. 五个文件与它们的加载顺序 ​

zsh 涉及的启动文件分两级:系统级在 /etc/ 下,用户级在 ~ 下。每一对里系统级先加载,且用户没有权限直接改系统级文件(也不应该改)。

顺序系统级用户级什么场景加载
1/etc/zshenv~/.zshenv任何 zsh 启动都会读,包括脚本
2/etc/zprofile~/.zprofile仅登录 shell
3/etc/zshrc~/.zshrc仅交互 shell
4/etc/zlogin~/.zlogin仅登录 shell(在 zshrc 之后)
5—~/.zlogout登录 shell 退出时

「登录」和「交互」是两个独立的维度,不是同一件事:登录 shell 是你登录一个会话时启动的那个 shell(比如打开终端窗口、或者 ssh 主机 进去之后的那个 shell);交互 shell 是等你敲命令、有提示符的 shell。两者可以任意组合。

2. 三种场景各加载哪些文件 ​

用打点法实测最直接:给每个启动文件加一行 echo,再分别用三种方式启动 zsh,看打印出哪几行。

别在自己的 ~/.zshenv 这些文件上做这个实验——忘了删掉打点,以后每开一个终端都会多出几行输出。zsh 会按 ZDOTDIR 指定的目录去找这四个用户级文件,所以把实验整个放进一个临时目录就行,跑完删掉目录,你的真实配置全程没被碰过:

zsh
P=$(mktemp -d)
for f in zshenv zprofile zshrc zlogin; do echo "echo '-> $f'" > "$P/.$f"; done
ZDOTDIR=$P zsh -c true
ZDOTDIR=$P zsh -i -c true
ZDOTDIR=$P zsh -l -i -c true
rm -rf "$P"

ZDOTDIR 只改变用户级那四个文件的位置,/etc/ 下的系统级文件不受影响,照常先加载。三条命令的实测输出:

text
ZDOTDIR=$P zsh -c true          # 非登录、非交互(脚本典型场景)
-> zshenv

ZDOTDIR=$P zsh -i -c true        # 非登录、交互
-> zshenv
-> zshrc

ZDOTDIR=$P zsh -l -i -c true     # 登录、交互(终端应用打开新窗口的典型场景)
-> zshenv
-> zprofile
-> zshrc
-> zlogin

三行结果印证了上面的表格:

  • 脚本 / cron / 一次性命令(非登录非交互)只读 .zshenv。这是唯一保证任何场景都会被读到的文件。
  • zsh -i -c '命令'(显式加 -i)这类非登录、交互场景读 .zshenv 和 .zshrc——tmux、screen 新建分屏,或某些终端模拟器新建标签页时不走登录流程但仍以交互方式启动 shell,就是这种组合。
  • **终端新开窗口、ssh 主机(不带命令,进入交互 shell)**属于登录 + 交互,四个文件全读,顺序是 zshenv → zprofile → zshrc → zlogin。

ssh 主机 命令(直接带命令执行)默认启动的是非交互、非登录 shell,只读 .zshenv——这就是本文开头第二个症状的根因:你在 .zshrc 或 .zprofile 里设的环境变量,ssh 直接执行命令时根本不会被加载。

3. macOS 特有的一步:path_helper ​

macOS 的系统级 /etc/zprofile 会调用 /usr/libexec/path_helper:

zsh
cat /etc/zprofile
zsh
# System-wide profile for interactive zsh(1) login shells.

# Setup user specific overrides for this in ~/.zprofile. See zshbuiltins(1)
# and zshoptions(1) for more details.

if [ -z "$LANG" ]; then
	export LANG=C.UTF-8
fi

if [ -x /usr/libexec/path_helper ]; then
	eval `/usr/libexec/path_helper -s`
fi

path_helper 读取 /etc/paths 和 /etc/paths.d/ 下的文件,把里面列出的系统目录(/usr/local/bin、/usr/bin、/bin、/usr/sbin、/sbin 等)拼到 PATH 最前面,再把调用时 PATH 里已有、但不在这份系统清单里的目录原样追加在后面——它不理解 Homebrew 或其他工具管理器,只按这两份清单和已有 PATH 的先后顺序机械拼接。可以直接看它输出的命令(-s 只是把这些命令打印到屏幕上,不会真的改掉当前 shell 的 PATH;man path_helper 也说明它只应由 profile 调用,这里仅用来观察):

zsh
/usr/libexec/path_helper -s

输出形如:

text
PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:(原有 PATH 中其余目录,保持原有顺序)"; export PATH;
MANPATH="..."; export MANPATH;

关键在于这一步发生在 /etc/zprofile(继而是 .zprofile)阶段,早于 .zshrc。如果你把 Homebrew 的 brew shellenv(或手动 export PATH="/opt/homebrew/bin:$PATH")写进 .zshenv,path_helper 会在它之后运行、并把系统目录重新怼到最前面,看起来像是「系统版本盖过了 Homebrew 版本」——其实是加载顺序和执行顺序都没对上:.zshenv 先跑没错,但 path_helper 在它之后的 .zprofile 里又把 PATH 结构重排了一次。把 PATH 相关的设置放进 .zprofile 或 .zshrc(在 path_helper 之后执行),或者确认自己的设置是「追加/前置到当前 PATH」而不是硬编码整个 PATH,才能让 Homebrew 的目录稳定排在系统目录前面。

4. 什么放哪个文件 ​

按「谁会用到」来分:

  • .zshenv:所有场景都需要的环境变量,比如 EDITOR、语言相关变量,以及 GOPATH、JAVA_HOME 这类跨 shell 共用、但不参与 PATH 拼接的路径变量(PATH 本身不要在这里做最终调整,原因见上一节的 path_helper)。因为脚本和 cron 任务也会读它,不要在这里做耗时操作(网络请求、启动子进程、大量 eval),否则每一次脚本调用都会变慢。也不适合在这里放只对交互操作有意义的别名。
  • .zprofile:只需要在登录时执行一次的初始化,比如 Homebrew 的 eval "$(/opt/homebrew/bin/brew shellenv)"、一次性的 PATH 调整。它在 path_helper 之后执行,是修 PATH 顺序问题最合适的位置。
  • .zshrc:只有交互 shell 才需要的东西——别名、命令补全、提示符(prompt)、命令行插件、history 相关设置。这是三个文件里体积通常最大的一个,也是唯一会被「新开一个终端标签页」反复加载的文件。
  • .zlogin:很少用到,适合放登录时要做、但依赖 .zshrc 已经加载完(比如某个交互补全或提示符插件初始化完毕)的收尾操作。
  • .zlogout:登录 shell 退出时执行,适合清理临时文件、打印退出信息,用得也不多。

5. 自行验证 ​

改完配置后,想确认某个设置有没有在目标场景生效,直接测这个设置本身,比重跑打点更有用:

zsh
zsh -c 'echo $变量名'         # 非登录、非交互:只有写在 .zshenv 里的能打印出来
zsh -i -c 'echo $变量名'      # 交互:.zshenv 和 .zshrc 里的都能
zsh -l -i -c 'echo $变量名'   # 登录 + 交互:四个文件都已加载

想重新确认加载顺序本身,把上面那段 ZDOTDIR 实验再跑一遍即可,它同样不会动到你的配置。

检查结果 ​

  • 用上面三条命令确认三种场景分别加载了预期的文件。
  • 打点实验的临时目录已经随 rm -rf "$P" 一起删掉,~/.zshenv、~/.zprofile、~/.zshrc、~/.zlogin 自始至终没被改动,不需要额外清理。

常见问题 ​

PATH 里出现重复条目。 通常是同一段 export PATH=... 被写进了会叠加加载的多个文件(比如 .zshenv 和 .zshrc 里都写了一遍),或者 .zshrc 里的 PATH 设置在每次新开标签页时都重复前置一次。恢复:用 echo $PATH 看当前内容,找到重复来源,只保留一处;改完新开窗口验证 echo $PATH 不再重复。

把配置写进 .zshenv 导致每个脚本都变慢。 判断方法:time zsh -c true,如果明显慢于预期,八成是 .zshenv 里有网络请求或重量级 eval。恢复:把不是所有场景都需要的部分挪到 .zprofile 或 .zshrc,只保留纯变量赋值在 .zshenv 里。

GUI 应用不读 .zshrc,环境变量传不进去。 这是预期行为——双击打开的应用不经过登录、交互 shell,不会加载这几个文件。需要传给 GUI 应用的环境变量应该用 launchctl setenv,或者写进应用自己支持的配置方式,而不是指望塞进 .zshrc。这不是配置错了,是找错了机制。

装了插件框架(如 Oh My Zsh、zinit)后开新窗口明显变慢。 排查用 zsh 自带的性能分析:在 .zshrc 最前面加 zmodload zsh/zprof,最后一行加 zprof,新开窗口看输出里哪个插件耗时最多。恢复:确认没有影响后,把这两行从 .zshrc 删掉,避免每次开窗口都打印分析结果。

动手练习 ​

  1. 起一个只属于练习的变量名,比如 SHELL_STARTUP_DEMO,写进 .zshrc:export SHELL_STARTUP_DEMO=hello。
  2. 跑 zsh -c 'echo $SHELL_STARTUP_DEMO'。它应该是空的——zsh -c 启动的是非交互、非登录 shell,不读 .zshrc。
  3. 把这一行从 .zshrc 剪切到 .zshenv,再跑一次同样的命令,这次应该能打印出 hello——因为 .zshenv 是唯一保证任何场景都加载的文件。
  4. 练完把 .zshenv 和 .zshrc 恢复到本文开头备份的状态:cp ~/.zshrc.backup ~/.zshrc,并删掉你在 .zshenv 里加的这一行。

参考资料 ​