terminal

“zsh 为什么要搞 3 个配置文件?从设计意图讲起”

“很多人把 .zshenv、.zprofile、.zshrc 当成三个平级的配置文件来用,结果越写越乱。其实 zsh 设计这三个文件,背后有一个很清晰的模型。搞懂它,配置就不会再靠试错了。”

发布时间

阅读信息

约 7 分钟

主题标签

zsh / shell / macOS

先说结论

zsh 的启动文件不是”三个差不多的配置文件随便选一个写”,而是设计者针对不同类型的 shell 进程做的分层:

文件什么时候加载设计意图
.zshenv所有 zsh 进程全局基线,任何 shell 都需要的最小环境
.zprofile登录 shell会话级初始化,一次登录只跑一次
.zshrc交互式 shell让终端好用的东西:alias、补全、提示符

搞懂这张表,后面所有的”该放哪”问题都能自己推导出来。

设计者在解决什么问题

要理解这三个文件,得先理解 zsh 看到的世界。对 zsh 来说,每次被启动都不是同一回事。它会用两个维度来判断自己是什么角色:

维度一:是不是登录 shell(login shell)

  • 登录 shell:用户刚坐到这台机器前,系统分配给 ta 的第一个 shell。macOS 打开 Terminal 新标签也算。
  • 非登录 shell:已经有 shell 了,再套一层,比如在终端里敲 zsh,或者脚本里调 zsh -c “...”

维度二:是不是交互式 shell(interactive shell)

  • 交互式:有人坐在终端前面敲命令,需要提示符、补全、别名。
  • 非交互式:没人看着,只是跑个脚本或者 IDE 后台执行一条命令。

两个维度组合出四种场景:

                    登录          非登录
交互式         打开 Terminal     终端里敲 zsh
非交互式       ssh 执行远程命令   脚本 / IDE Task

不同场景需要的配置完全不同。 这就是为什么 zsh 不用一个文件搞定所有事,而是拆成了多个——每个文件精确覆盖一类场景。

三个文件的加载顺序

zsh 启动时,按这个固定顺序查找并执行:

.zshenv → .zprofile(仅登录)→ .zshrc(仅交互)→ .zlogin(仅登录)

其中 .zlogin 也只在登录 shell 执行,但跑在 .zshrc 之后——它是 zsh 为了兼容 csh 的 .login 而保留的”收尾钩子”,用于需要交互环境完全就绪后才做的事(比如打印欢迎信息)。大多数人用不到,本文不展开。

用一张表画出来:

场景.zshenv.zprofile.zshrc.zlogin
打开 Terminal 新标签
终端里敲 zsh
脚本 #!/bin/zsh
IDE 后台跑命令

设计者的核心意图:越通用的配置,放越靠前的文件;越”只跟人有关”的配置,放越靠后的文件。

.zshenv:全局基线

.zshenv 是唯一一个所有 zsh 进程都会加载的文件。不管是交互式还是脚本,不管是登录还是非登录,只要启动了 zsh,就会先读它。

zsh 文档对它的要求也很明确:尽量小。 因为每个 shell 进程都要付这个启动成本——包括咱们跑的每一个 shell 脚本。

设计者希望咱们放什么:

  • 真正全局需要的环境变量(比如 EDITORLANG
  • $fpath(zsh 函数搜索路径)
  • 极少数语法选项(比如 EXTENDED_GLOB

不该放什么:

  • PATH 拼接 —— 因为每个子 shell 都会重复执行,PATH 会越来越长
  • nvm / Homebrew 这类重型初始化 —— 每个脚本都跑一遍 nvm 加载,白白浪费时间
  • 任何产生输出的东西 —— 会破坏非交互脚本的 stdout

实际建议:大多数人可以不写这个文件。 如果咱们没有明确需要”每个 zsh 进程都必须有”的环境变量,留空就行。

.zprofile:会话级初始化

.zprofile 只在登录 shell 启动时执行。设计上,它对应的是”一个用户会话开始了”这个事件。

在 macOS 上,Terminal.app 的每个新标签都会打开一个登录 shell(这是 macOS 的特殊行为,Linux 通常只有第一次登录才是)。所以 .zprofile 在 macOS 上大致等于”每次开新 Terminal 标签都会跑”。

设计者希望咱们放什么:

  • PATH 的组装(Homebrew、JAVA_HOME、自定义 bin 目录)
  • 开发工具链的初始化(nvm、sdkman、jenv)
  • export 的环境变量(JAVA_HOME、GOPATH 等)
# Homebrew
eval$(/opt/homebrew/bin/brew shellenv)

# Java
export JAVA_HOME=”/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home”
export PATH=”$JAVA_HOME/bin:$PATH”

# Node
export NVM_DIR=”$HOME/.nvm”
[ -s “$NVM_DIR/nvm.sh” ] && .$NVM_DIR/nvm.sh”

为什么放这里而不是 .zshenv?

因为这些初始化通常比较重(nvm 加载要几百毫秒),而且只需要在会话开始时跑一次。子 shell 会自动继承父进程的环境变量,不需要重复执行。如果放在 .zshenv 里,每个 shell 脚本都会重跑一遍,既浪费又容易出问题(比如 PATH 重复追加)。

为什么不放 .zshrc?

因为 IDE 后台执行命令时,启动的是非交互 shell,不会加载 .zshrc。如果 JAVA_HOMEPATH 只写在 .zshrc 里,Terminal 里 mvn -v 正常,但 IntelliJ 跑 Spring Boot 时就找不到正确的 Java。

这就是很多人遇到的经典问题:Terminal 正常,IDE 不正常。 原因就是环境变量放错了文件。

.zshrc:交互体验

.zshrc 只在交互式 shell 启动时执行——有人坐在终端前面敲命令时才需要。

设计者的意图很直白:这个文件让终端好用,仅此而已。

该放什么:

  • alias(alias dev=”pnpm dev”
  • 补全配置(compinit
  • 提示符 / 主题(oh-my-zsh、starship、powerlevel10k)
  • 按键绑定(bindkey
  • history 配置
  • 插件加载
alias dev=”pnpm dev”
alias testm=”./mvnw test”

plugins=(git docker)
source $ZSH/oh-my-zsh.sh

这些配置跑在脚本里毫无意义——脚本不需要 alias,不需要补全,更不需要漂亮的提示符。所以设计者把它们限定在交互式 shell 里。

macOS 为什么特别容易搞混

在 Linux 上,终端模拟器(如 GNOME Terminal)默认打开的是非登录交互式 shell,只加载 .zshenv + .zshrc,不加载 .zprofile。用户很快就会发现这几个文件的区别。

但 macOS 的 Terminal.app 和 iTerm2 默认打开的是登录交互式 shell,三个文件全都加载。这导致咱们觉得”放哪都一样”——因为在 macOS 的日常使用中,确实都会生效。

直到遇到 IDE 后台执行命令(非交互非登录,只加载 .zshenv),问题才暴露出来。

一张图总结

所有 zsh 进程
  └─ .zshenv (全局基线,尽量小或留空)

       ├─ 登录 shell?
       │    └─ .zprofile (PATH、JAVA_HOME、nvm,一次性环境准备)

       ├─ 交互式 shell?
       │    └─ .zshrc (alias、补全、主题,让终端好用)

       └─ 非交互非登录?(脚本、IDE Task)
            └─ 只有 .zshenv 生效

设计者的核心思路:按进程类型分层,而不是按”咱们觉得顺手”来分。

搞懂这个模型之后,不用死记”什么放哪”——遇到任何一条配置,只需要问自己:”这条配置,是所有 shell 都需要,还是只有人在终端前才需要?” 答案自然就出来了。


如果也经常被终端环境、PATH、IDE 不生效这类问题卡住,欢迎关注我的公众号 粒方Lab。我会继续写这类真正能减少排障时间的开发环境经验。

zshshellmacOSterminal环境变量开发效率
上一篇 这个效率技巧,能找回你复制过的内容 下一篇 source 命令是干什么的?一篇讲清楚