这是《与 Shell 对话》系列的第六篇。前五篇你学会了一系列独立的命令——操作文件、搜索内容、串管道、管理系统、配置别名。脚本就是把学过的命令写进一个文件,让电脑替你按顺序执行。你不需要是"程序员"才能写脚本——你只需要把已经会用的命令整理起来。学完这篇,你将能创建自己的工具,让重复劳动变成一次回车。
上一篇:让你的 Shell 认识你 · 下一篇:遇到问题怎么办
| 关键词 | 一句话说明 |
|---|---|
| 脚本(Script) | 存着一串命令的文本文件——运行它等于逐条执行里面的命令 |
| Shebang | 脚本的第一行 #!/bin/zsh——告诉系统用什么解释器来执行这个文件 |
命令替换 $() | 把命令的输出存到变量里——files=$(ls) 让 files 等于 ls 的结果 |
条件判断 if | 让脚本根据条件做不同的事——如果文件存在就怎样,不存在就怎样 |
循环 for | 对一组东西重复做同一件事——对每个 .md 文件都执行一次操作 |
$1 $2 $@ | 脚本接收的参数——$1 是第一个参数,$@ 是所有参数 |
$? | 上一条命令的退出码——0 表示成功,非 0 表示出错 |
exit | 脚本主动结束——exit 0 表示成功退出,exit 1 表示出错 |
| 可执行权限 | 文件需要 chmod +x 才能像程序一样直接运行 |
调试模式 set -x | 让 Shell 在执行每条命令前先打印它——找 bug 的利器 |
你在终端里逐条输入命令。脚本就是把这些命令存到一个文件里,然后让 Shell 替你一条条执行。
创建一个文件,写上你熟悉的命令:
# 用 echo 创建一个简单脚本(以后你会用编辑器写)
echo '#!/bin/zsh' > ~/Desktop/hello.sh
echo 'echo "你好,终端!"' >> ~/Desktop/hello.sh
echo 'echo "现在是 $(date)"' >> ~/Desktop/hello.sh
echo 'echo "你的家目录是 $HOME"' >> ~/Desktop/hello.sh
# 查看文件内容
cat ~/Desktop/hello.sh
现在你有两种方式运行它:
# 方式一:直接把文件交给 zsh 解释器(不需要执行权限)
zsh ~/Desktop/hello.sh
# 方式二:先加执行权限,然后直接运行(需要 chmod +x)
chmod +x ~/Desktop/hello.sh
~/Desktop/hello.sh # 或者 ./hello.sh(如果当前在 Desktop 里)
输出:
你好,终端!
现在是 2026年 6月 3日 星期四 10:30:00 CST
你的家目录是 /Users/aodesai
脚本的第一行 #!/bin/zsh 叫 shebang(念作"希帮")。它告诉系统:用 /bin/zsh 这个程序来执行这个文件。
如果你写 #!/bin/bash,就会用 bash 执行。如果你写 #!/usr/bin/env python3,就会用 Python 执行。shebang 决定了脚本用什么语言写的。
这个系列的脚本都用 #!/bin/zsh,因为你的 Mac 默认 Shell 是 zsh。
| 规则 | 说明 |
|---|---|
| .sh 后缀 | 约定俗成,不是必须——但加了便于识别 |
| 放到 ~/bin/ 或 ~/Desktop/ | 随便放,但放 ~/bin/ 里并加入 PATH 后可以在任何位置直接调用 |
| 先加执行权限 | chmod +x 脚本.sh |
| 用 ./ 运行 | 当前目录下的脚本要加 ./——./脚本.sh |
为什么当前目录的脚本要加
./?因为 Shell 只在 PATH 里列出的目录中找命令。当前目录(.)一般不在 PATH 中——这是安全设计,防止你cd到一个陌生目录后不小心运行了里面的恶意脚本。用./明确地说"我就是想运行当前目录的这个文件"。
第五篇里你配置了一些别名(如 alias rm='rm -i')。这些别名在终端中交互式输入时有效,但在脚本文件中无效——因为脚本启动的是一个新的非交互式 Shell,不会读取 .zshrc。
#!/bin/zsh
# 即使你在 .zshrc 里设了 alias rm='rm -i'
# 下面的 rm 也不会询问确认——脚本中的 rm 是原始的 rm
rm 文件.txt
这意味着脚本中写命令时,必须写完整的、原始的版本。rm 就是 rm,不会自动变成 rm -i。ls 就是 ls,不会自动变成带颜色的版本。如果你需要脚本中的某个命令带特定选项,在脚本里显式写出来。
#!/bin/zsh
# ✅ 正确:在脚本中显式写选项
rm -f 临时文件.tmp
ls -la ~/Desktop
这不是 bug——是设计。脚本需要在可预测的环境中运行,不依赖任何人的个性化配置。你在终端里用的别名是给你自己省事的,脚本的行为是写给所有人看的。
变量在第五篇(环境变量)已经接触过。在脚本里,变量的用法更灵活。
#!/bin/zsh
# 设置变量(等号两边不能有空格!)
name="水母流星"
count=42
# 使用变量
echo "你好,$name"
echo "计数:$count"
# 字符串拼接
greeting="你好,${name}!今天第${count}次见面"
echo "$greeting"
${} 和 $ 的区别:花括号明确标出变量名的边界。${name}! 中 Shell 能正确识别变量名是 name 而不是 name!。不加花括号时,如果变量名后面紧跟字母或数字,Shell 可能认错。
#!/bin/zsh
# 把命令的输出存到变量中
today=$(date "+%Y-%m-%d")
files=$(ls ~/Desktop | wc -l)
current_dir=$(pwd)
echo "今天是 $today"
echo "桌面上有 $files 个项目"
echo "当前在 $current_dir"
$(命令) 语法会先执行括号内的命令,然后把它的输出字符串赋给变量。这是脚本中最常用的技巧之一——让脚本知道"当前状态是什么"。
也可以用反引号 `命令` 实现同样的效果,但 $() 更清晰且可以嵌套,建议始终用 $()。
常见的反直觉点:
count=$(ls | wc -l)中,变量的值是字符串不是数字。虽然在 zsh 中它可以参与算术运算,但本质上它是字符 "42" 而不是数字 42。脚本中几乎所有变量都是字符串——这是 Shell 脚本和"真正的编程语言"的一个重要区别。
没有条件判断的脚本只是"按顺序执行一遍"。有了 if,脚本可以根据不同情况走不同的路。
#!/bin/zsh
if [ 条件 ]; then
# 条件为真时执行
echo "条件成立"
fi
[ 条件 ] 中的方括号是 test 命令的简写形式。注意方括号前后必须有空格。 [ 条件 ] 是语法,[条件] 是错误。
| 条件 | 为真的情况 | 示例 |
|---|---|---|
[ -f 文件 ] | 文件存在且是普通文件 | [ -f ~/.zshrc ] |
[ -d 目录 ] | 目录存在 | [ -d ~/Desktop ] |
[ -e 路径 ] | 路径存在(不限类型) | [ -e ~/Desktop ] |
[ "$a" = "$b" ] | 两个字符串相等 | [ "$name" = "知白" ] |
[ "$a" != "$b" ] | 两个字符串不等 | [ "$name" != "知白" ] |
[ "$a" -eq "$b" ] | 两个数字相等 | [ "$count" -eq 42 ] |
[ "$a" -lt "$b" ] | 数字 a 小于 b | [ "$age" -lt 18 ] |
[ -z "$a" ] | 变量 a 是空字符串 | [ -z "$name" ] |
多个条件组合:[ 条件1 -a 条件2 ](并且)或 [ 条件1 -o 条件2 ](或者)。
#!/bin/zsh
file="$1" # 脚本的第一个参数
if [ -f "$file" ]; then
echo "✅ 文件存在:$file"
echo "它的大小是:$(wc -c < "$file") 字节"
elif [ -d "$file" ]; then
echo "📁 这是一个目录:$file"
echo "里面的文件数量:$(ls "$file" | wc -l)"
else
echo "❌ 文件不存在:$file"
fi
把这个脚本保存为 check.sh,chmod +x check.sh,然后运行 ./check.sh ~/.zshrc——它会告诉你文件是否存在、是文件还是目录。
关于
$file两边的引号:在[ -f "$file" ]中加引号是必须的。如果$file包含空格(比如 "我的笔记.md"),不加引号的条件会变成[ -f 我的 笔记.md ],Shell 会把它当成四个参数而不是一个文件名。引号让空格被正确识别为文件名的一部分。
循环让你对一批文件或数据逐一执行相同的操作。
#!/bin/zsh
# 最简单的 for 循环——遍历一个列表
for name in Alice Bob Charlie; do
echo "你好,$name"
done
# 更实用的例子——遍历当前目录下的所有 .md 文件
for file in *.md; do
echo "找到 Markdown 文件:$file"
echo "行数:$(wc -l < "$file")"
echo "---"
done
for 变量 in 列表; do ... done 是基本结构。列表 可以是直接写的(如 Alice Bob Charlie),也可以是通配符展开的结果(如 *.md),也可以是命令替换的输出(如 $(ls))。
#!/bin/zsh
# 备份指定目录下的所有 .jpg 文件
backup_dir="$HOME/Desktop/图片备份"
mkdir -p "$backup_dir"
for file in *.jpg; do
echo "备份:$file"
cp "$file" "$backup_dir/"
done
echo "✅ 备份完成,共备份了 $(ls "$backup_dir" | wc -l) 个文件"
#!/bin/zsh
# while 循环:当条件为真时重复执行
count=1
while [ "$count" -le 5 ]; do
echo "第 $count 次"
count=$((count + 1)) # $(( )) 是算术运算
done
# 更实用的例子:逐行读取文件
while read line; do
echo "读取到:$line"
done < ~/.zshrc
$(( )) 是 zsh 的算术运算语法。$((count + 1)) 会做数字加法,然后把结果赋给变量。
while 配合重定向 < 文件 可以逐行读取文件——这是脚本中处理配置文件、日志文件的常用模式。
for 和 while 的选择:如果你知道要对谁做循环(一组文件)→ for。如果你在等某个条件发生(比如计数器到某个值、读文件到末尾)→ while。 日常脚本中 for 的使用频率远高于 while。
前面的例子已经在用 $1 了。这一节系统梳理脚本的输入输出机制。
#!/bin/zsh
echo "脚本名:$0" # 脚本自己的名字
echo "第一个参数:$1" # 第一个参数
echo "第二个参数:$2" # 第二个参数
echo "参数总数:$#" # 参数个数
echo "所有参数:$@" # 所有参数组成的列表
实际脚本中,常先用 $# 检查参数数量是否足够,避免用户忘记传参:
#!/bin/zsh
if [ "$#" -lt 1 ]; then
echo "❌ 用法:$0 文件名" >&2
exit 1
fi
file="$1"
echo "要处理的文件:$file"
保存为 args.sh,运行 ./args.sh 苹果 香蕉 橘子,输出:
脚本名:./args.sh
第一个参数:苹果
第二个参数:香蕉
参数总数:3
所有参数:苹果 香蕉 橘子
每条命令执行后都会返回一个数字给系统——0 表示成功,非 0 表示出错。脚本也可以用 exit 主动设定退出码:
#!/bin/zsh
if [ -f "$1" ]; then
echo "文件存在"
exit 0 # 成功退出
else
echo "文件不存在" >&2 # 错误消息输出到 stderr
exit 1 # 失败退出
fi
退出码可以被脚本的调用者检查。&& 和 || 就是基于退出码工作的——只有左边退出码为 0 时 && 才执行右边。
当脚本不按预期工作时,在脚本开头加一行 set -x:
#!/bin/zsh
set -x # 开启调试模式
file="$1"
if [ -f "$file" ]; then
echo "存在"
fi
set +x # 关闭调试模式
运行后,Shell 会在执行每条命令前把它打印到屏幕上(前面带 + 号),让你看到变量展开后的真实值、条件判断走了哪条路。
调试模式是排查脚本问题的第一工具。比瞪着眼看代码快得多。看到
+ file=''你就知道变量为什么是空的了——你忘了传参数。
把以上所有概念串起来,写一个真正有用的脚本。这个脚本会把桌面上的重要文件备份到一个带日期的文件夹中。
#!/bin/zsh
# ═══════════════════════════════════════
# 桌面备份脚本
# 用法:./backup.sh [来源目录] [目标目录]
# 来源目录:要备份的文件夹(默认 ~/Desktop)
# 目标目录:备份存放位置(默认 ~/Desktop/备份)
# ═══════════════════════════════════════
# --- 参数处理 ---
source_dir="${1:-$HOME/Desktop}" # 默认备份桌面
backup_dir="${2:-$HOME/Desktop/备份}" # 默认放在桌面的"备份"文件夹
# --- 检查来源目录是否存在 ---
if [ ! -d "$source_dir" ]; then
echo "❌ 错误:来源目录不存在:$source_dir" >&2
exit 1
fi
# --- 创建带日期的备份目录 ---
today=$(date "+%Y-%m-%d")
target="$backup_dir/备份_$today"
mkdir -p "$target"
echo "📦 开始备份:$source_dir"
echo "📂 目标位置:$target"
echo ""
# --- 备份所有 .md 和 .txt 文件 ---
count=0
for file in "$source_dir"/*.md "$source_dir"/*.txt; do
if [ -f "$file" ]; then
cp "$file" "$target/"
echo " ✅ 已备份:$(basename "$file")"
count=$((count + 1))
fi
done
# --- 统计结果 ---
echo ""
if [ "$count" -eq 0 ]; then
echo "⚠️ 没有找到 .md 或 .txt 文件"
rmdir "$target" 2>/dev/null # 空目录删掉
else
echo "✅ 备份完成!共 $count 个文件"
echo "📂 打开备份文件夹:$target"
open "$target"
fi
exit 0
把这个脚本保存为 ~/Desktop/backup.sh,然后:
chmod +x ~/Desktop/backup.sh
~/Desktop/backup.sh # 备份桌面上的 .md 和 .txt 文件
~/Desktop/backup.sh ~/Downloads # 备份下载文件夹里的文件
脚本中用到了 basename "$file"——它从完整路径中提取文件名部分。例如 basename "/Users/aodesai/Desktop/日记.md" 返回 日记.md。搭配它的还有 dirname(提取目录部分),它们是脚本中处理路径时的标配工具。
这个脚本包含了本篇学到的几乎所有概念:
#!/bin/zshsource_dir、backup_dir、today${1:-默认值}$(date)、$(basename "$file")exit 1,一切正常时 exit 0>&2这个脚本不是终点——它是你可以持续改进的起点。你可以给它添加更多文件类型、压缩备份包、只备份今天修改过的文件、删除超过 30 天的旧备份……每个改动的想法都是一个学习的机会。脚本是活的工具,不是一次性的作业。
创建一个脚本,接收一个文件名模式(如 *.md),在桌面范围内搜索匹配的文件,显示每个文件的大小和行数。
# 保存为 finder.sh
#!/bin/zsh
pattern="$1"
echo "搜索模式:$pattern"
echo "---"
for file in "$HOME/Desktop"/$pattern; do
if [ -f "$file" ]; then
size=$(du -sh "$file")
lines=$(wc -l < "$file")
echo "$(basename "$file") | 大小:$size | 行数:$lines"
fi
done
# du -sh 的输出自带大小信息和文件名(如 "4.0K 文件.md"),
# 这里直接显示就行。如果你只想看大小不看名字,可以用 cut
# 处理,但 cut 我们还没学——先这样用着。
创建一个脚本,运行后一次性显示:用户名、系统版本、运行时间、内存大小、桌面文件数。
# 保存为 sysinfo.sh
#!/bin/zsh
echo "═══════════════════════════"
echo " 系统速查"
echo "═══════════════════════════"
echo "用户:$(whoami)"
echo "系统:$(sw_vers -productVersion)"
echo "运行时长:$(uptime | grep -o 'up .*' | head -1)"
echo "CPU 核心:$(sysctl -n hw.ncpu)"
echo "桌面文件:$(ls ~/Desktop | wc -l) 个项目"
echo "═══════════════════════════"
# 提示:如果想看内存大小,可以用 sysctl -n hw.memsize,
# 但它的输出是以字节为单位的纯数字(如 17179869184),
# 需要换算成 GB(除以 1073741824),这超出了本篇范围。
# 以后学了 bc 或 python 可以完成换算。
把你写的脚本放到 ~/bin/ 目录(需要自己创建),然后把该目录加入 PATH。
# 创建 ~/bin 目录
mkdir -p ~/bin
# 把脚本移进去
cp ~/Desktop/backup.sh ~/bin/
# 在 .zshrc 中添加一行
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
# 现在可以在任何位置直接运行了
backup.sh
当你开始在
~/bin里存放自己写的脚本时,你就真正成了终端的主人。你不再只是使用别人写的命令——你在创造自己的命令。写脚本的能力会把计算机操作的自由度提升一个层级:任何重复劳动,都可以变成一个脚本。
| 语法 | 含义 | 示例 |
|---|---|---|
#!/bin/zsh | Shebang——用 zsh 解释器执行 | 脚本第一行 |
变量="值" | 给变量赋值(等号前后无空格) | name="知白" |
$变量 | 取变量的值 | echo "$name" |
$(命令) | 命令替换——把命令输出赋给变量 | today=$(date) |
$1 $2 ... $@ | 脚本参数 | file="$1" |
${1:-默认值} | 参数默认值 | dir="${1:-$HOME}" |
[ 条件 ] | 条件判断(前后有空格!) | [ -f "$file" ] |
if ... then ... elif ... else ... fi | 分支结构 | if/then/elif/else/fi |
for var in 列表; do ... done | 遍历循环 | for f in *.md; do |
while 条件; do ... done | 条件循环 | while [ $n -le 5 ] |
exit n | 以退出码 n 结束脚本 | exit 0(成功)exit 1(错误) |
set -x / set +x | 开启/关闭调试模式 | 脚本开头加 set -x |
$(( )) | 算术运算 | n=$((n + 1)) |
$? | 上一条命令的退出码 | 后面常用 if [ $? -eq 0 ] |
set -e 安全模式——脚本中任何命令失败立即退出,避免"失败后继续执行"的连锁错误trap 捕获信号——让脚本在收到 Ctrl+C 或退出时执行清理操作(如删除临时文件)case 分支——多条件匹配的简洁写法,适合处理命令行选项local 变量作用域——函数内部用 local 声明变量,避免污染全局命名空间read 与交互式脚本——让脚本向用户提问、接收输入rsync 增量备份——只复制变化的部分,比 cp 聪明得多的备份工具