现代 Shell:fish、nushell 与迁移

系统对比 fish 与 nushell 相对 bash 的语法与数据模型差异,讲解结构化管道、外部命令互操作、配置体系与脚本模式,并给出交互层迁移、脚本层保守化、CI 能力探测降级与团队共存的完整落地路径,附常见踩坑与排错速查表,帮助在保留部署链路稳定的前提下升级终端体验。

1. 为什么还要有新的 Shell

一句话总结: bash 的语法是为了兼容 1970 年代的 Bourne shell 而层层加码的产物,现代 Shell 想解决的不是「多几个语法糖」,而是「数据模型」这一层的问题。

bash 在交互场景下有三个长期痛点:字符串即一切(没有数组/表/字典的原生表达)、错误处理靠退出码和 set -e 的隐式约定、补全与高亮要么靠外部工具要么靠项目自带的黑魔法。fish 与 nushell 分别从「交互体验」和「结构化数据」两个方向给出了不同答案。

1.1 三种取向

Shell核心取向脚本定位学习成本
bash/zshPOSIX 兼容 + 扩展一等公民,部署脚本主力低
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; dofor 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/zshfishnushell
数据模型字符串 + 数组列表表 + 类型
管道内容字节流字节流结构化值
错误处理set -e / 退出码and / or / $statuserror make
配置方式dotfile 手写conf.d/ + functions/config.nu
脚本生态最广小中
推荐场景落盘脚本、CI日常交互数据处理 REPL

选型结论可以压缩成一句话:脚本层保守(bash/POSIX),交互层激进(fish/nushell),两层之间用「脚本文件」而不是「Shell 函数」做边界。 这样你既拿到了现代 Shell 的交互红利,又不会在部署链路上引入不可控的依赖。

变量的作用域与引用规则是两层共用的基础,无论用哪种 Shell 都值得先弄清楚,参见 变量、作用域与函数 。如果迁移后要处理结构化配置,JSON 与 YAML 处理 里的工具链可以跨 Shell 复用。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 日志轮转与归档
  2. 监控采集与告警脚本
  3. Shell 处理二进制数据