源码解读——ohmyzsh

最近在做一个 shell 项目——NOX,在开发过程中遇到了一些设计方面的问题。为了能够得到一些灵感,我将注意力放到了 github 上 star 数量最高的 shell 项目——ohmyzsh。本文将记录我阅读 ohmyzsh 源码后,对于其设计的一些理解。

概述

Ohmyzsh(下文简称 OMZ) 是一个由开发者社区驱动的开源框架,用于管理我们的 zsh 配置。OMZ 提供了大量的插件,能够有效提升我们的命令行工作效率。此外,OMZ 提供了大量的主题,通过主题配置我们可以将终端打造得极具极客范!

目录结构

我们首先来看一下 OMZ 的目录结构,如下图所示。根据 .gitignore 中的定义,我们可以大概能够猜测到:

  • cache 目录用于存放 OMZ 运行时产生的缓存文件。cache 目录不加入 git 管理。
  • custom 目录用于存放用户自定义的一些插件或主题,该目录下默认有两个子目录 pluginsthemes 分别用户存放插件和主题。比如:我们可以将 https://github.com/zsh-users/zsh-completions 克隆到 custom/plugins 目录下,作为自定义的插件集合。custom 目录不加入 git 管理。
  • lib 目录下的一系列文件定义了诸多 shell 工具方法。下文我们将具体进行介绍。
  • log 目录应该用户存放 OMZ 运行时产生的日志缓存文件。log 目录不加入 git 管理。
  • plugins 目录存放了 OMZ 项目所管理的插件。该目录下的子目录数量庞大,其中每一个子目录对应一个工具,如:xcode 目录对应 Xcode,该目录下存放的文件可分为两类:一是以 _ 为前缀的自动补全文件;二是以 .plugin.zsh 为后缀的插件文件。我们可以通过设置 .zshrc 中的 plugins 数组变量来选择加载的插件。
  • templates 目录存放了一个 zsh 配置文件的模板。
  • themes 目录存放了大量主题,文件命名方式遵循 主题名称 + .zsh-theme 的规则。我们可以通过设置 .zshrc 中的 ZSH_THEME 变量来选择主题。
  • tools 目录包含了一系列 OMZ 系统相关的重要脚本,如:安装、卸载、检查更新、执行更新等。

安装方式

tools/install.sh 脚本中,我们能够看出 OMZ 的安装过程可以分为以下五个步骤:

  • 设置变量
  • 环境检查
  • 设置 OMZ
  • 设置 zshrc
  • 设置 shell

设置变量

第一步 设置变量 中设置的变量包含两类:基础设置相关的变量、执行选项相关的变量。

基础设置相关的变量包括以下这些:

  • ZSH:表示 OMZ 仓库的路径,默认是 $HOME/.oh-my-zsh
  • REPO:表示仓库名称,默认是 ohmyzsh/ohmyzsh
  • REMOTE:表示 OMZ 仓库的远程地址,默认是 https://github.com/${REPO}.git
  • BRANCH:表示安装 OMZ 时所对应的仓库分支,默认是 master

执行选项相关的变量包括以下这些:

  • CHSH:表示安装 OMZ 时是否会切换默认的 shell,默认是 yes
  • RUNZSH:表示是否会在安装后运行 zsh,默认是 yes
  • KEEP_ZSHRC:表示是否会替换已有的 .zshrc,默认是 no

在执行 install.sh 脚本时,我们可以通过传入特定选项来设置执行选项相关的变量。如:

  • --skip-chsh 选项:将 CHSH 设置为 no
  • --unattended 选项:将 CHSHRUNZSH 均设置为 no
  • --keep-zshrc 选项:将 KEEP_ZSHRC 设置为 yes

环境检查

第二步 环境检查 中主要判断了两种情况:

  • zsh 未安装的情况下,退出 OMZ 的安装
  • OMZ 已安装的情况下,退出 OMZ 的安装

设置 OMZ

第三步 设置 OMZ 的任务主要是根据第一步中所设置的变量 REPOREMOTEZSH,通过 git 将远程仓库克隆到本地。

设置 zshrc

第四步 设置 zshrc,顾名思义,就是设置 ~/.zshrc 文件。这里会用到第一步中所设置的变量 ZSHKEEP_ZSHRC

KEEP_ZSHRCyes 时,则跳过本步骤。

KEEP_ZSHRCno 时,会先将原有的 ~/.zshrc 备份为 ~/.zshrc.pre-oh-my-zsh,用于卸载时进行恢复。然后将 templates 目录下的 zshrc.zsh-template 拷贝为 ~/.zshrc,同时将 ZSH 变量的设置导入至 ~/.zshrc 中。

~/.zshrc 设置完之后,其内部默认开启的配置并不多,如下所示。

1
2
3
4
5
6
7
8
# Path to your oh-my-zsh installation.
export ZSH=$HOME/.oh-my-zsh

ZSH_THEME="robbyrussell"

plugins=(git)

source $ZSH/oh-my-zsh.sh
每次启动 zsh 时都会执行 ~/.zshrc 中定义的命令。OMZ 默认在 ~/.zshrc 中设置了 ZSHZSH_THEMEplugins 三个变量,此外还定义了每次启动 zsh 时执行 oh-my-zsh.sh 脚本,对 OMZ 进行初始化。

设置 shell

第五步 设置 shell 的任务主要是根据第一步中所设置的变量 CHSH 来决定是否将默认的 shell 切换成 zsh。

OMZ 初始化

在上一节 安装方式设置 zshrc 小结中,我们提到每次启动 zsh 时都会执行 oh-my-zsh.sh 脚本对 OMZ 进行初始化。那么这个初始化过程具体包含哪些内容呢?如下所示为 oh-my-zsh.sh 的源代码。

OMZ 初始化的主要步骤包括:

  • 默认路径设置,如:ZSHZSH_CACHE_DIRfpathZSH_CUSTOMZSH_COMPDUMP
  • 检查更新
  • 初始化补全系统,包括:预设 fpath 变量、创建 .zcompdump 文件等
  • 加载 lib
  • 加载插件
  • 加载自定义配置
  • 加载主题

详细初始化过程可以阅读如下所示的源代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# 1. 如果 `ZSH` 变量没有设置,则将 `ZSH` 设置为当前 `oh-my-zsh.sh` 脚本所在的目录路径
[[ -z "$ZSH" ]] && export ZSH="${${(%):-%x}:a:h}"

# 2. 如果 `ZSH_CACHE_DIR` 变量没有设置,则将 `ZSH_CACHE_DIR` 设置为 `cache` 目录路径,用于存放缓存文件。
if [[ -z "$ZSH_CACHE_DIR" ]]; then
ZSH_CACHE_DIR="$ZSH/cache"
fi

# 3. 检查更新
if [ "$DISABLE_AUTO_UPDATE" != "true" ]; then
source $ZSH/tools/check_for_upgrade.sh
fi

# 初始化 OMZ

# 4. 为 `fpath` 变量增加两个索引路径,即使这两个路径在当前仓库中并不存在。`fpath` 类似于 `PATH`,zsh 启动时会加载 `fpath` 路径下的所有脚本文件。
fpath=($ZSH/functions $ZSH/completions $fpath)

# 5. 根据 `fpath` 变量加载所有后面会用到的库函数
autoload -U compaudit compinit

# 6. 如果 `ZSH_CUSTOM` 变量没有设置,则将 `ZSH_CUSTOM` 设置为 `custom` 目录路径,用于存放自定义配置文件和插件。
if [[ -z "$ZSH_CUSTOM" ]]; then
ZSH_CUSTOM="$ZSH/custom"
fi

is_plugin() {
local base_dir=$1
local name=$2
builtin test -f $base_dir/plugins/$name/$name.plugin.zsh \
|| builtin test -f $base_dir/plugins/$name/_$name
}

# 7. 将 `~/.zshrc` 中 `plugins` 变量所指定的插件路径加入到 `fpath` 变量中。`ZSH_CUSTOM` 中的自定义插件的优先级比 OMZ 提供的插件的优先级更高。所有的插件会在执行 `compinit` 之前加载完成。
for plugin ($plugins); do
if is_plugin $ZSH_CUSTOM $plugin; then
fpath=($ZSH_CUSTOM/plugins/$plugin $fpath)
elif is_plugin $ZSH $plugin; then
fpath=($ZSH/plugins/$plugin $fpath)
else
echo "[oh-my-zsh] plugin '$plugin' not found"
fi
done

# 8. 设置 `SHORT_HOST` 变量
if [[ "$OSTYPE" = darwin* ]]; then
# macOS's $HOST changes with dhcp, etc. Use ComputerName if possible.
SHORT_HOST=$(scutil --get ComputerName 2>/dev/null) || SHORT_HOST=${HOST/.*/}
else
SHORT_HOST=${HOST/.*/}
fi

# 9. 设置 `ZSH_COMPDUMP` 变量,`.zcompdump` 文件用于加速 `compinit` 的执行(在 zsh 中,`compinit` 用于初始化 shell 补全)
if [ -z "$ZSH_COMPDUMP" ]; then
ZSH_COMPDUMP="${ZDOTDIR:-${HOME}}/.zcompdump-${SHORT_HOST}-${ZSH_VERSION}"
fi

# 10. 构建 zcompdump OMZ metadata,本质上就是用 OMZ git 仓库当前的 commit 号进行区分
zcompdump_revision="#omz revision: $(builtin cd -q "$ZSH"; git rev-parse HEAD 2>/dev/null)"
zcompdump_fpath="#omz fpath: $fpath"

# 11. 如果 OMZ zcompdump metadata 发生变化,则删除 `.zcompdump` 文件
if ! command grep -q -Fx "$zcompdump_revision" "$ZSH_COMPDUMP" 2>/dev/null \
|| ! command grep -q -Fx "$zcompdump_fpath" "$ZSH_COMPDUMP" 2>/dev/null; then
command rm -f "$ZSH_COMPDUMP"
zcompdump_refresh=1
fi

# 12. 执行 compinit,生成 `.zcompdump` 文件
if [[ $ZSH_DISABLE_COMPFIX != true ]]; then
source $ZSH/lib/compfix.zsh
# If completion insecurities exist, warn the user
handle_completion_insecurities
# Load only from secure directories
compinit -i -C -d "${ZSH_COMPDUMP}"
else
# If the user wants it, load from all found directories
compinit -u -C -d "${ZSH_COMPDUMP}"
fi

# 13. 如果 `.zcompdump` 文件不存在,则创建,并写入 zcompdump metadata
if (( $zcompdump_refresh )); then
# Use `tee` in case the $ZSH_COMPDUMP filename is invalid, to silence the error
# See https://github.com/ohmyzsh/ohmyzsh/commit/dd1a7269#commitcomment-39003489
tee -a "$ZSH_COMPDUMP" &>/dev/null <<EOF

$zcompdump_revision
$zcompdump_fpath
EOF
fi

unset zcompdump_revision zcompdump_fpath zcompdump_refresh

# 14. 加载 `lib` 目录下的所有以 `.zsh` 为后缀的配置文件
for config_file ($ZSH/lib/*.zsh); do
custom_config_file="${ZSH_CUSTOM}/lib/${config_file:t}"
[ -f "${custom_config_file}" ] && config_file=${custom_config_file}
source $config_file
done

# 15. 加载 `~/.zshrc` 中 `plugins` 变量所定义的全部插件
for plugin ($plugins); do
if [ -f $ZSH_CUSTOM/plugins/$plugin/$plugin.plugin.zsh ]; then
source $ZSH_CUSTOM/plugins/$plugin/$plugin.plugin.zsh
elif [ -f $ZSH/plugins/$plugin/$plugin.plugin.zsh ]; then
source $ZSH/plugins/$plugin/$plugin.plugin.zsh
fi
done

# 16. 加载 `custom` 目录下的所有自定义配置文件
for config_file ($ZSH_CUSTOM/*.zsh(N)); do
source $config_file
done
unset config_file

# 17. 加载 `~/.zshrc` 中 `ZSH_THEME` 变量所定义的主题
if [ ! "$ZSH_THEME" = "" ]; then
if [ -f "$ZSH_CUSTOM/$ZSH_THEME.zsh-theme" ]; then
source "$ZSH_CUSTOM/$ZSH_THEME.zsh-theme"
elif [ -f "$ZSH_CUSTOM/themes/$ZSH_THEME.zsh-theme" ]; then
source "$ZSH_CUSTOM/themes/$ZSH_THEME.zsh-theme"
else
source "$ZSH/themes/$ZSH_THEME.zsh-theme"
fi
fi

更新策略

由于安装 OMZ 时,OMZ 会在 ~/.zshrc 中加入一行代码 source $ZSH/oh-my-zsh.sh,从而使得每次启动 zsh 会都会执行 oh-my-zsh.sh 中的代码。oh-my-zsh.sh 中通过 DISABLE_AUTO_UPDATE 变量来判断是否执行更新检查,然后由 check_for_upgrade.sh 来决定是否进行更新,如下所示:

1
2
3
if [ "$DISABLE_AUTO_UPDATE" != "true" ]; then
source $ZSH/tools/check_for_upgrade.sh
fi

DISABLE_AUTO_UPDATE 默认未进行初始化,所以每次启动 zsh 都会进行更新检查,用户可以通过在 ~/.zshrc 去掉对 DISABLE_AUTO_TITLE="true" 的注释来关闭更新检查。

下面,我们依次来介绍 OMZ 中的更新检查和更新执行。

更新检查

OMZ 更新检查的主要逻辑包括以下这些步骤:

  • 读取 cache/.zsh-update 中的 LAST_EPOCH 变量,如果文件不存在或 LAST_EPOCH 的值为空,则写入新值并退出。LAST_EPOCH 表示上一次更新的时间(单位:从 1970年1月1日至当前的天数)。
  • 读取更新间隔值 UPDATE_ZSH_DAYS,默认值为 13。如果上次 LAST_EPOCH 的更新时间与当前相隔 13 天及以上,那么继续后面的步骤;否则,退出。
  • 根据 DISABLE_UPDATE_PROMPT 的值决定是否请求用户同意更新。采用交互式的方式请求用户同意。如果同意更新,则执行更新,更新完成后更新 cache/.zsh-update 中上次更新的时间 LAST_EPOCH

更新执行

具体的更新操作非常简单,就是通过 git pull --rebase 的方式获取最新的代码。

插件

插件是 OMZ 所提供的最重要的功能之一。插件主要位于 OMZ 的 plugins 目录下,每一个插件都有一个维护者,因此 OMZ 是一个社区驱动的开源项目。

那么插件究竟干了些什么呢?我们以 plugins/git/git.plugin.zsh 为例,进行分析。

总体而言,插件提供了三方面的功能。

  • 函数
  • alias
  • 自动补全

对于函数,插件中提供了如下所示的一些函数,这些函数我们可以在 shell 中直接调用。

1
2
3
4
5
6
7
8
9
10
function current_branch() {
git_current_branch
}

function work_in_progress() {
if $(git log -n 1 2>/dev/null | grep -q -c "\-\-wip\-\-"); then
echo "WIP!!"
fi
}
...

对于 alias,插件中则定义大量缩写以供用户快速调用命令,如下所示:

1
2
3
4
5
6
7
8
9
10
11
alias gc='git commit -v'
alias gc!='git commit -v --amend'
alias gcn!='git commit -v --no-edit --amend'
alias gca='git commit -v -a'
alias gca!='git commit -v -a --amend'
alias gcan!='git commit -v -a --no-edit --amend'
alias gcans!='git commit -v -a -s --no-edit --amend'
alias gcam='git commit -a -m'
alias gcsm='git commit -s -m'
alias gcb='git checkout -b'
...

与此同时,插件还为函数定义了自动补全函数,如下所示定义 ggpnp 函数的自动补全与 git checkout 相同。

1
2
3
4
5
6
7
8
function ggpnp() {
if [[ "$#" == 0 ]]; then
ggl && ggp
else
ggl "${*}" && ggp "${*}"
fi
}
compdef _git ggpnp=git-checkout

此外,OMZ 还会提供特定的自动补全文件,一般以 _ 为前缀,后跟具体的命令名称,如:_podpod 提供了自动补全定义。

我们可以对 ~/.zshrcplugins 变量进行修改,选择我们想要加载的插件。

关于 zsh 自动补全的细节,本文不作具体介绍,可以参考 《Zsh 自动补全脚本入门》 一文。

主题

主题主要用于修改 zsh 提示符和某些程序的外观,还有一些其他的行为,如:终端的 tab 和窗口标题。

主题通过设置各种变量来控制 zsh 提示符的外观,包括:PROMPTRPRPROMTLSCOLORSLS_COLORS 等变量;此外,主题还能够安装 hook 方法,从而提供更深层次的修改。

我们可以对 ~/.zshrcZSH_THEME 变量进行修改,选择我们想要的主题。

关于主题的具体实现,后面有时间写一篇文章来进行介绍。

总结

总体而言,从设计角度而言,OMZ 非常简单,它使用清晰的目录结构管理插件、主题。OMZ 的安装和更新也非常简单,没有复杂的机制。由于 OMZ 提供了巨量的插件和主题,能够极大地提升用户的工作效率,从而受到了用户的热烈追捧。

参考

  1. oh-my-zsh
  2. Zsh 开发指南(第十八篇 更多内置模块的用法)