我一直用的 Zsh + Oh My Zsh,但是听说了 Fish 在这方面也做的很出现,所以打算尝试使用下 Fish。

为什么是 Fish

之前用 Zsh + Oh My Zsh,得装 zsh-autosuggestions、zsh-syntax-highlighting 这些插件,再配主题,折腾不少。Fish 把这些都内置了——自动补全、语法高亮,装完就有。

裸的 Zsh 和裸的 Fish 对比,Fish 体验差距挺明显。自动补全会扫系统的 Man Pages,按 Tab 不光补全参数,旁边还显示参数说明;历史记录预测用灰色字给出建议,按 采纳,命中率不错。语法高亮也是实时的,命令打错立刻变红,不用等回车才发现。

Zsh 插件装多了启动会慢,有些插件是 Shell 脚本写的,效率不高。Fish 的补全和高亮内置在底层,功能全开也不影响响应速度。

语法方面 Fish 抛弃了 POSIX 兼容,换来更干净的表达。比如设置环境变量:

1
2
# Zsh / Bash
export PATH="/usr/local/bin:$PATH"
1
2
# Fish
set -x PATH /usr/local/bin $PATH

If 判断也没有 fiesac 这种反写关键字,统一用 end 收尾:

1
2
3
4
# Zsh / Bash
if [ "$foo" = "bar" ]; then
echo "yes"
fi
1
2
3
if test "$foo" = "bar"
echo "yes"
end

还有一个 fish_config 命令,终端里敲一下就在浏览器里打开配置页面,颜色、主题都能点选,不用手改配置文件。

不兼容 POSIX 是最大的代价,后面迁移过程中踩的坑基本都跟这个有关。

装上 Fish

macOS 直接 Homebrew 装:

1
brew install fish

装完确认一下:

1
2
3
4
5
fish --version
# fish, version 3.7.1

which fish
# /opt/homebrew/bin/fish

which fish 输出的路径后面配 Ghostty 要用到,记一下。也可以直接在当前终端输入 fish 回车,看到欢迎语和灰色自动补全就说明没问题了。

设置默认 Shell 的两种方式

装完之后怎么让终端默认用 Fish,有两条路。

第一种是直接改终端模拟器的配置,让新建窗口时跑 Fish 而不是系统默认 Shell。我用的 Ghostty,配置很简单:

1
2
# 用 ghostty +edit-config 打开配置文件,加一行:
command = /opt/homebrew/bin/fish

路径用 which fish 查一下,不同机器可能不一样。

这种方式的好处是系统底层的 Zsh/Bash 不受影响,SSH 登录也还是原来的 Shell,不会踩到 PATH 或 profile 加载的坑。

第二种是把 Fish 设为登录 Shell:

1
2
3
4
5
# 先加到 /etc/shells
command -v fish | sudo tee -a /etc/shells

# 再切换
chsh -s "$(command -v fish)"

这样所有终端登录(包括 SSH)都会直接进 Fish。不过有些 Linux 发行版要求登录 Shell 必须兼容 Bourne 并读取 /etc/profile,Fish 在这些系统上可能会出问题。我选的是第一种,稳妥一些。

把 .zshrc 翻译成 Fish 语法

Fish 的语法比 Zsh 简洁不少,不需要等号赋值那一套。配置文件在 ~/.config/fish/config.fish

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 环境变量
fish_add_path $HOME/.local/bin

# 代理
set -gx https_proxy http://127.0.0.1:7897
set -gx http_proxy http://127.0.0.1:7897
set -gx all_proxy socks5://127.0.0.1:7897

# 交互式环境专属
if status is-interactive
alias ls 'eza --color=always --icons=always'
alias top 'btop'

if type -q starship
starship init fish | source
end
if type -q zoxide
zoxide init fish | source
end
end

改完 config.fish 保存后,两种方式让配置生效:偷懒的话直接 source ~/.config/fish/config.fish,当前窗口立刻生效。不过配置里用了 if status is-interactive,Starship、Zoxide 这些工具的初始化流程用 source 不一定完全干净,关掉终端重开一个窗口更稳妥。

迁移的时候遇到一个 sed 的坑。之前 Zsh 里有个 precmd 脚本用到了 sed -n "{$var}p" 这种写法,在 Fish 里直接报 expects up to 0 address(es), found 1。原因是 Fish 会原样解析大括号,改成拼接写法 sed -n "$var"p 就好了。

Zsh 历史记录导入 Fish

Fish 的历史记录格式跟 Zsh 完全不一样,不能直接搬。写了个 Python 脚本做转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import os, re

zsh_history = os.path.expanduser("~/.zsh_history")
fish_history = os.path.expanduser("~/.local/share/fish/fish_history")

with open(zsh_history, 'r', encoding='utf-8', errors='replace') as f:
lines = f.readlines()

fish_lines = []
for line in lines:
line = line.strip()
match = re.match(r'^:\s*(\d+):\d+;(.*)', line)
if match:
fish_lines.append(f"- cmd: {match.group(2)}\n when: {match.group(1)}\n")
elif line:
fish_lines.append(f"- cmd: {line}\n")

os.makedirs(os.path.dirname(fish_history), exist_ok=True)
with open(fish_history, 'a', encoding='utf-8') as f:
f.writelines(fish_lines)
print("历史记录同步完成!")

跑完重启终端,Fish 就能基于之前的 Zsh 记录做灰色自动补全了。

SDKMAN 的 Bash 兼容性问题

SDKMAN 的初始化脚本是给 Bash/Zsh 写的,Fish 没法直接 source。需要先装 Fish 的包管理器 Fisher,再通过它装 sdkman-for-fish 插件:

1
2
3
4
5
# 安装 Fisher
curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish | source && fisher install jorgebucaran/fisher

# 安装 SDKMAN 插件
fisher install reitzig/sdkman-for-fish

装完之后 sdk 命令基本能用了,但在 macOS 上还可能碰到一个报错:

1
/Users/caratacus/.sdkman/src/sdkman-path-helpers.sh: line 61: ${candidate_name^^}: bad substitution

一开始以为是 Fish 的锅,看了下发现不是——^^ 是 Bash 4.0 才有的变量转大写语法,而 macOS 自带的 Bash 还停在 3.2。插件底层还是会调系统自带的 Bash 执行脚本,照样报错。

直接改 SDKMAN 的源码,把 ^^ 替换成兼容的写法:

1
2
3
4
5
# 修改前:
# ucase_candidate_name="${candidate_name^^}"

# 修改后:
ucase_candidate_name="$(printf %s "$candidate_name" | tr '[:lower:]' '[:upper:]')"

改完 sdk 命令就正常了。缺点是每次更新 SDKMAN 都得重新改一遍,不过也就一行的事。


整体下来,从 Zsh 切到 Fish 最大的成本就是配置迁移和那些基于 Bash 的工具链适配。POSIX 不兼容这个事见仁见智,对我来说日常交互用 Fish,脚本跑 Bash,各司其职,没什么冲突。Fish 的自动补全和语法高亮确实比 Zsh 舒服不少,配好之后基本不用再折腾配置了。