1. 为什么还要有新的 Shell
一句话总结: bash 的语法是为了兼容 1970 年代的 Bourne shell 而层层加码的产物,现代 Shell 想解决的不是「多几个语法糖」,而是「数据模型」这一层的问题。
bash 在交互场景下有三个长期痛点:字符串即一切(没有数组/表/字典的原生表达)、错误处理靠退出码和 set -e 的隐式约定、补全与高亮要么靠外部工具要么靠项目自带的黑魔法。fish 与 nushell 分别从「交互体验」和「结构化数据」两个方向给出了不同答案。
1.1 三种取向
| Shell | 核心取向 | 脚本定位 | 学习成本 |
|---|---|---|---|
| bash/zsh | POSIX 兼容 + 扩展 | 一等公民,部署脚本主力 | 低 |
| fish | 交互优先,语法干净 | 能写但生态小 | 中 |
| nushell | 结构化数据管道 | 一等公民,主打数据处理 | 高 |
判断标准很直接:如果这段代码要交给 CI、要跑在别人的机器上,就用 bash 或 POSIX sh;如果这段代码是你每天在终端里敲的,才轮到 fish / nushell 发挥。
1.2 一个具体的对比
同一个任务——找出当前目录下大于 1MB 的文件并按大小排序——三种写法:
# bash:靠 find 的输出格式 + sort -k 的位置约定
find . -maxdepth 1 -type f -size +1M -printf '%s %p\n' | sort -nr
# fish:没有 printf 谓词,靠 ls 与 math
ls -l | awk '$5 > 1048576 {print $5, $9}' | sort -nr
# nushell:结构化,直接按列名排
ls | where type == file and size > 1mb | sort-by size --reverse
一句话总结: bash 的管道传「字节流」,位置靠约定;nushell 的管道传「表」,位置靠列名——这是两者最本质的分野。
2. fish:把交互体验做到极致
一句话总结: fish 的语法糖全部服务于「少打字、少记忆、少踩坑」,代价是与 POSIX 的语法不兼容,任何
sh脚本片段都不能直接粘进 fish。
2.1 变量与命令替换
fish 取消了 $() 和反引号的区别,命令替换直接用括号:
# 赋值:set 是唯一入口,等号写法被禁止
set -l name "web-01"
set -x API_TOKEN "abc123" # 等价于 bash 的 export
# 命令替换用括号,且默认按行拆分(不会词分裂)
set -l files (ls *.log)
# 变量引用必须带 $,且没有 ${} 花括号展开
echo "主机: $name"
set -l(local)与 set -g(global)的显式作用域,是 fish 少踩「函数里改了全局变量」这类坑的原因。
2.2 列表是一等公民
fish 的每个变量都是列表,不存在「字符串伪装成数组」:
set -l hosts web1 web2 db1
for h in $hosts
echo "检查 $h"
end
# 追加与切片
set -a hosts web3
echo $hosts[2..3] # web2 db1
echo (count $hosts) # 4
注意 $hosts 在 fish 里会自动展开成多个参数,不需要 "${hosts[@]}" 这种 bash 里的防词分裂写法——因为 fish 根本不做词分裂。
2.3 条件与错误处理
# 条件用 test 或命令退出码
if test -f /etc/os-release
echo "存在"
end
# 没有 set -e,改用 and / or 与 $status
command -q docker; and echo "docker 可用"
curl -fsS https://example.com >/dev/null; or echo "请求失败"
echo "上一条命令退出码: $status"
fish 没有 set -e 语义,脚本的失败传播靠 and / or 链和显式检查 $status。这对习惯 set -euo pipefail 的人是个思维切换,但好处是「失败即退出」不再是一个全局隐式开关。
2.4 配置与自动加载
fish 的配置目录结构决定了它的可维护性:
~/.config/fish/
├── config.fish # 每次启动都执行
├── conf.d/ # 按文件名排序自动 source
│ ├── 00-path.fish
│ ├── 10-aliases.fish
│ └── 20-tools.fish
└── functions/
└── mkcd.fish # 文件名即函数名,自动加载
# ~/.config/fish/functions/mkcd.fish
function mkcd --description "建目录并进入"
mkdir -p $argv[1]; and cd $argv[1]
end
conf.d/ 的自动 source 与 functions/ 的按需加载,替代了 bash 里手写 .bashrc 判断顺序的混乱。fish_config 还能在浏览器里配主题与提示符,省掉 PS1 转义序列的手工拼接。
3. nushell:结构化数据管道
一句话总结: nushell 把管道里的数据从「行文本」升级成「带类型的表」,所有命令都在操作同一套结构化值,从而把
awk/sed/cut的活儿收回语言内部。
3.1 一切皆表
# 输出是表,列有名字和类型
ls | select name size type | first 3
# 从 JSON 直接进入结构化世界
open package.json | get dependencies | transpose name version
open 会根据扩展名自动解析 JSON / YAML / TOML / CSV,get / select / where / sort-by / group-by 都在列上操作,不再需要 jq 的 .foo.bar 路径表达式。
3.2 命令分类
nushell 把命令分成四类,理解这个分类是写好脚本的前提:
| 类别 | 说明 | 例子 |
|---|---|---|
| 内部命令 | 操作结构化值 | where select get |
| 外部命令 | 系统二进制 | ^git ^curl |
| 别名 | 语法替换 | alias ll = ls -l |
| 自定义命令 | 用户定义的函数 | def deploy [] { ... } |
外部命令默认以文本流返回,必须显式转换:
# 外部命令的输出是字符串,需要 parse 或 lines 才能结构化
^git log --oneline | lines | first 5
# 解析成表
^df -h | detect columns | where 'Use%' | into int | $in > 80
^ 前缀强制走外部命令,避免内部命令同名覆盖——这是排查「为什么我的 ls 行为和文档不一样」的第一手段。
3.3 脚本模式
nushell 脚本用 def 定义,参数带类型:
#!/usr/bin/env nu
def "main deploy" [
env: string # 环境名
--dry-run # 开关
--replicas: int = 3 # 带默认值的选项
] {
let manifest = $"deploy/($env).yaml"
if not ($manifest | path exists) {
error make {msg: $"清单不存在: ($manifest)"}
}
if $dry_run {
print $"将部署 ($env) 副本数 ($replicas)"
return
}
^kubectl apply -f $manifest
}
注意 let 绑定不可变、$env 访问环境变量、error make 抛出结构化错误——这三条让 nushell 脚本比同等的 bash 更容易做静态检查。
3.4 配置
# ~/.config/nushell/config.nu
$env.config.show_banner = false
$env.config.table.mode = "rounded"
$env.PATH = ($env.PATH | split row (char esep) | prepend "/opt/homebrew/bin")
# 自定义提示符
$env.PROMPT_COMMAND = {|| $"(pwd) > " }
nushell 用 .nu 文件而非 dotfile 片段,配置本身也是结构化脚本,可以用 where 过滤、用 if 分支。
4. 迁移:交互层与脚本层分离
一句话总结: 迁移现代 Shell 的正确姿势不是「全换」,而是把终端交互换成 fish/nushell,把落盘脚本继续留在 bash/POSIX——两层用不同的语言,互不干扰。
4.1 分层原则
┌─────────────────────────────┐
│ 交互层:fish / nushell │ ← 你每天敲的命令、提示符、补全
├─────────────────────────────┤
│ 胶水层:bash 函数 / 别名 │ ← 少量本地工具,允许 bash 语法
├─────────────────────────────┤
│ 脚本层:#!/bin/sh 或 bash │ ← 落盘、进 CI、给别人跑
└─────────────────────────────┘
脚本层坚持 bash 的理由很实际:容器基础镜像里没有 fish,cron 的 SHELL 默认是 /bin/sh,Kubernetes 的 command: 只保证有 sh。把这三处的脚本写成 fish 等于自断部署路径。
4.2 交互层迁移清单
从 bash 迁到 fish 时,.bashrc 里这些东西需要翻译:
| bash 写法 | fish 写法 |
|---|---|
export PATH="...:$PATH" | fish_add_path /opt/bin |
alias gs='git status' | abbr -a gs 'git status' |
function f() { ... } | function f; ...; end |
$(cmd) | (cmd) |
for i in 1 2 3; do | for i in 1 2 3; |
abbr 比 alias 更适合交互:它在按下空格时才展开,因此命令历史里存的是完整命令而不是缩写,日后搜历史不会搜不到。
4.3 渐进迁移路径
不要一次性切换登录 Shell,按下面的顺序推进:
# 第一步:先装不改,用 fish 交互式启动试试
fish
# 第二步:把 bash 配置转成 fish 的 conf.d 片段,逐条验证
# 第三步:确认无阻断问题后,再改默认 shell
chsh -s "$(command -v fish)"
# 第四步:保留 bash 作为脚本执行器(不要改 /bin/sh)
第四步尤其重要:chsh 只影响你的登录 Shell,不影响 #!/bin/bash 的解释器选择。任何脚本文件的 shebang 都不会因为你换了交互 Shell 而改变。
4.4 团队共存
团队里只有你一个人用 fish 时,要守住两条线:
# 1. 任何要提交到仓库的脚本,仍然写成 bash
# 2. 需要跨 Shell 的本地工具,用独立的 .sh 文件而非 shell 函数
# 例如把常用逻辑落成脚本,fish 和 bash 都能调
# ~/bin/collect-logs.sh ← #!/usr/bin/env bash
# fish 侧只做薄封装
function collect-logs
~/bin/collect-logs.sh $argv
end
这样即使同事用 bash,也能直接调 ~/bin/collect-logs.sh,不依赖你的 Shell 环境。跨 Shell 兼容的细节差异(数组下标、glob 语义)在 zsh 与 bash 兼容移植
里有系统梳理。
5. 踩坑与排错
一句话总结: 现代 Shell 的坑集中在三处:把 bash 片段直接粘进去、误判外部命令的输出类型、以及 CI 里找不到解释器。
5.1 粘贴 bash 片段
最常见的失败是复制一段网上教程的 bash 代码:
# 报错:fish 不认识 $() 与 export
export FOO=bar
for f in $(ls); do echo $f; done
排错手段是让 bash 去执行那段代码,而不是翻译它:
# 用 bash -c 包一层,fish 只负责传参
bash -c 'export FOO=bar; for f in $(ls); do echo $f; done'
5.2 nushell 里外部命令返回字符串
# 想过滤却报错:字符串没有 where 的列
^docker ps | where status == "Up"
正确写法是先结构化:
^docker ps --format '{{json .}}' | lines | each { from json } | where Status =~ "Up"
一句话总结: nushell 里凡是
^开头的命令,输出都是字符串;要参与结构化管道,必须先lines/from json/detect columns转成表。
5.3 CI 与远程环境
# CI 里不要假设 fish/nushell 存在
if command -v nu >/dev/null 2>&1; then
nu -c 'ls | length'
else
echo "nushell 不可用,降级到 bash"
ls | wc -l
fi
CI 的镜像通常只保证 POSIX sh 与 bash。把现代 Shell 的调用包在能力探测后面,是让本地体验与流水线互不冲突的关键。
5.4 排错速查
| 症状 | 原因 | 处理 |
|---|---|---|
Unknown command | 粘了 bash 语法 | 用 bash -c 包一层 |
where 报类型错 | 上游是外部命令的文本 | 先 lines / from json |
| 补全不生效 | fish 版本旧或插件未装 | fish_update_completions |
| 脚本在 CI 挂 | 解释器不存在 | 加 command -v 探测 |
| 历史搜不到缩写 | 用了 alias 而非 abbr | 改用 abbr |
6. 总结
| 维度 | bash/zsh | fish | nushell |
|---|---|---|---|
| 数据模型 | 字符串 + 数组 | 列表 | 表 + 类型 |
| 管道内容 | 字节流 | 字节流 | 结构化值 |
| 错误处理 | set -e / 退出码 | and / or / $status | error make |
| 配置方式 | dotfile 手写 | conf.d/ + functions/ | config.nu |
| 脚本生态 | 最广 | 小 | 中 |
| 推荐场景 | 落盘脚本、CI | 日常交互 | 数据处理 REPL |
选型结论可以压缩成一句话:脚本层保守(bash/POSIX),交互层激进(fish/nushell),两层之间用「脚本文件」而不是「Shell 函数」做边界。 这样你既拿到了现代 Shell 的交互红利,又不会在部署链路上引入不可控的依赖。
变量的作用域与引用规则是两层共用的基础,无论用哪种 Shell 都值得先弄清楚,参见 变量、作用域与函数 。如果迁移后要处理结构化配置,JSON 与 YAML 处理 里的工具链可以跨 Shell 复用。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。