1. 终端字体样式到底是怎么"变"出来的
在 Ubuntu 上敲了几年命令,我越来越受不了那种纯白一片、信息全糊在一起的终端输出。编译报错和成功提示一个颜色,日志里的警告和普通信息长得一模一样,几十行刷过去,眼睛得来回扫三遍才能找出真正要看的那一行。后来我开始给自己的脚本加上颜色、粗体和下划线,输出一下子清爽了:错误标红、警告标黄、关键结论加粗、路径加下划线,扫一眼就定位到了。这套东西听起来像是要装什么插件,其实底层就一个东西——ANSI 转义序列(ANSI escape sequence)。它不需要额外依赖,Ubuntu 自带的 bash、printf、甚至一行 echo 就能直接用起来,成本几乎为零,收益却立竿见影。这篇文章适合三类人看:刚接触 Linux 命令行、想让自己的脚本输出别那么"素"的新手;写过一些自动化脚本、但只会无脑复制\033[31m却说不清原理的中间层;以及想把这套规则正确落到 Python、C 或者 Node 项目里、又担心踩坑的开发者。下面我把从原理到实操、从速查到避坑的完整链路捋一遍。
1.1 从一次排查需求说起
最早动这个念头,是因为某次排查一个批量处理任务。脚本要处理几千个文件,每处理一个就打一行日志,失败的打一行错误。纯文本输出跑完一屏,我只能靠grep -i error再翻一遍。当时就想,能不能让失败的直接标红、跳过的标灰、成功的标绿,这样终端里滚过去的时候异常立刻扎眼。
试过最原始的办法是加!!!或者一堆****来强调,难看不说,还占列宽。也试过把输出丢进文件再手动过滤,但那属于事后补救,实时性没了。真正解决问题的方式就是给输出加上样式控制:在文本前后各插一小段"控制字符",终端读到这些字符不会把它们显示出来,而是改变后面文字的显示方式——颜色、粗细、下划线、反显等等。这个思路特别像早年排版里的"控制码",开一个效果,再关一个效果,中间夹着你要显示的内容。
理解了这一层,后面所有花活儿都是同一个模子刻出来的。你在网上看到的各种\033[1;32m之类的"魔法字符串",本质上只是同一套参数的不同组合,搞清楚参数表,就不用再靠搜索引擎抄了。
1.2 ANSI 转义序列:终端世界里的控制码
严格说,这套东西的历史比 Ubuntu 本身老得多,是从老式终端设备沿袭下来的一套约定,后来被标准化,现在几乎所有类 Unix 终端、包括很多跨平台终端模拟器都兼容。它的基本形态是:一个"转义"字节开头,跟着一个左方括号,再跟一串参数,最后一个字母表示要做什么。
在 Ubuntu 的 shell 里,这个转义字节最常见的写法有三种:\033(八进制)、\x1b(十六进制)、以及 bash 特有的\e。三者完全等价,都是那个十进制值为 27 的控制字符。我个人习惯在 shell 脚本里写\033,因为它不依赖 bash 的扩展,printf和echo -e都认;在 Python 和 C 里我更倾向写\x1b或者\u001b,可读性稍好一点。
结尾那个字母决定了这段序列是干嘛的。我们这篇文章关心的样式控制,结尾是m,这类序列有个专门的名字叫SGR(Select Graphic Rendition,选择图形再现)。除了m,还有控制光标移动的、清屏的、设置窗口标题的,那些是另一个话题,这里不展开。记住一个直觉:看到以m结尾的转义序列,就是在管"接下来这段字怎么显示"。
注意:转义序列里的方括号和参数是连在一起的,中间不能有空格。写成
\033[ 31m会让终端把它当成普通字符输出,屏幕上就会出现一串看不懂的乱码。
一旦你意识到这只是一段"看不见的字符",很多困惑就解开了。所谓"给文字上色",并不是文字本身有颜色属性,而是在文字前后各塞了一段控制字符,让终端在渲染时切换画笔颜色。这也解释了后面要讲的一堆坑:比如为什么把输出重定向到文件后颜色就变成了乱码,因为文件里存的是原始字节,没人去"执行"这段控制码;比如为什么用wc -c统计字符串长度会比你预期的多,因为那些控制字符也被算进去了。
2. 把 SGR 参数表刻进脑子里
抄代码谁都会,但真正用顺手的关键是把参数表记住,至少记住常用的那十几个。这样你在写脚本时不用停下来搜,遇到显示不对也能立刻反推是哪一位参数写错了。下面这张表是我自己整理并长期使用的版本,覆盖了日常九成以上的需求。
2.1 基本骨架与参数含义
骨架固定成ESC[<参数>m,变化全在参数上。参数是数字,多个参数之间用分号分隔,顺序无所谓。常用的取值如下:
| 参数 | 效果 | 关闭方式 | 备注 |
|---|---|---|---|
| 0 | 重置所有样式 | — | 最常用,收尾必备 |
| 1 | 加粗 / 高亮 | 21 或 22 | 多数终端里等于把前景色调亮一档 |
| 2 | 变暗 | 22 | 做"弱化提示"很好用 |
| 3 | 斜体 | 23 | 部分终端不支持,会静默忽略 |
| 4 | 下划线 | 24 | 兼容性很好 |
| 5 | 闪烁 | 25 | 慎用,很干扰阅读 |
| 7 | 反显 | 27 | 常用于"选中态"或标签块 |
| 8 | 隐藏 | 28 | 输入密码那类场景 |
| 9 | 删除线 | 29 | 新一些的终端才支持 |
| 30-37 | 前景色(8 色) | 39 | 黑红绿黄蓝紫青白 |
| 40-47 | 背景色(8 色) | 49 | 与前景色一一对应 |
| 90-97 | 亮色前景 | 39 | 也叫高亮色,常用于强调 |
| 100-107 | 亮色背景 | 49 | 做标签底色效果好 |
几个容易搞混的点值得单独说。第一,1(加粗)在不同终端上的表现不完全一致:有的终端真的把字形加粗,有的只是把颜色提亮,还有的情况是两者同时发生。所以如果你的设计里"加粗"和"亮色"是两件事,别指望靠一个1同时实现。第二,关闭粗体有人写21,有人写22,而22同时会关掉变暗(2),这点在组合使用时要注意。第三,最保险的收尾永远是0全量重置,别去精确关闭某一个属性——省事,也少出错。
实操心得:我早期喜欢用
5(闪烁)标严重错误,觉得够醒目。实际用下来发现,长时间盯着闪烁文字非常消耗注意力,而且有些终端把闪烁做成了慢速呼吸效果,根本不像"警报"。后来统一改成红底白字41;97,识别度高得多,也不刺眼。
2.2 16 色、256 色、真彩色三档怎么选
最基础的一档是 16 色(8 常规色 + 8 亮色),上面表里的 30-37、90-97 和对应的背景色就够用了。这一档的兼容性最好,凡是支持 SGR 的终端都认,写入日志文件后再用less -R打开也不会出问题。
第二档是 256 色,参数写法比较特殊,用的是38;5;n(前景)和48;5;n(背景),其中n取 0 到 255。这个编号空间里,0-15 大致对应基础 16 色,16-231 是一个 6×6×6 的色立方,232-255 是 24 级灰阶。灰阶那一段做"渐隐效果"特别好用,比如从232到255逐级加深,做进度条或者日志等级由低到高的视觉过渡很自然。用 256 色之前建议先确认终端支持,tput colors会告诉你当前终端报告的颜色数,输出 256 或 16777216 才算真的支持。
第三档是真彩色(24 位色),写法是38;2;r;g;b,背景是48;2;r;g;b。这一档的坑比前两档多:终端得支持,COLORTERM环境变量一般会被设成truecolor或24bit;而且在纯文本控制台(TTY)里基本不支持,你按 Ctrl+Alt+F3 切到字符界面,真彩序列会被降级或者干脆忽略。我现在的策略是:面向终端交互的输出用 16 色或 256 色,只有明确知道目标环境是图形界面终端模拟器时,才考虑上真彩。
有一个非常自然的类比可以帮你记住三档的差别:16 色像一盒 12 色彩笔,256 色像一盒 120 色的专业彩铅,真彩像调色盘加颜料随便调。但现实生活中,签名、批注这类场合,一支黑笔加一支红笔往往就够用了——大多数脚本输出根本不需要 24 位色。
2.3 组合与复位:顺序、覆盖和"关灯"
多个属性可以塞在同一段序列里,用分号连起来,比如\033[1;4;31m就是"粗体 + 下划线 + 红色前景"。顺序不影响最终效果,\033[31;4;1m和前面那个等价。真正影响效果的是后出现的会不会覆盖先出现的——同类型属性以后来的为准,比如先31再32,最终是绿色;不同类属性则是叠加关系。
关于复位,我总结了个"关灯"的思维模型:每开一个效果,就欠一次关闭。最省事的做法是每段带样式的文本后面无脑跟一个\033[0m,这叫"就近复位"。比如:
printf '\033[1;31m[ERROR]\033[0m 处理失败: 文件不存在\n'这里[ERROR]是红粗体,后面的普通文本恢复正常。如果你偷懒只在整段输出的最后复位一次,中间任何一个分支提前return或者被异常打断,后面的所有输出都会带着残留样式,看起来像终端"中毒"了。这个现象我后面还会专门讲,属于最常见的坑之一。
还有一种情况值得提:嵌套。比如你想在整行加粗的前提下,只让路径部分变成下划线,那就先开1,再开4,写完路径后关24(只关下划线),整行结束再关0。这种"精确关闭"在复杂输出里偶尔需要,但能不用就不用,因为不同终端对21、22这类关闭码的实现有细微差异,写得越复杂,兼容性风险越高。
3. Shell 里最稳的几种写法
Shell 是 Ubuntu 上最直接的舞台,但也是最容易踩坑的地方,因为不同 shell、不同内建命令对转义字符的处理策略不一样。我见过太多脚本在作者自己的机器上跑得好好的,换台机器就变成满屏\033[31m。下面把几种写法的优劣讲清楚。
3.1 echo -e 的坑与 printf 的选择
新手入门基本都是echo -e "\033[31m红色\033[0m"。这条命令在 bash 交互式环境下确实能跑,但它有两个隐患。
第一个隐患是-e这个选项本身不是所有 shell 都认。Ubuntu 的/bin/sh默认指向 dash,而 dash 的内建 echo 不支持-e,你写echo -e "\033[31mhi",它会把-e原样打出来,再把\033当成四个普通字符,结果就是屏幕上出现-e \033[31mhi。所以只要你的脚本里有#!/bin/sh或者用sh script.sh执行,这条命令就废了。
第二个隐患是双引号里的\033。在 bash 的双引号中,反斜杠后面跟数字并不会被自动解释成转义字符——它之所以能工作,是因为echo -e自己又做了一遍转义解析。也就是说这里做了两层解析,任何一层变了结果就变。如果换成echo "..."不带-e,或者在某个函数里参数被转了一道,都会失效。
我更推荐的写法是printf:
printf '\033[1;32m%s\033[0m\n' "构建成功"printf是 POSIX 标准命令,bash、dash、zsh 通吃,第一参数是格式串,里面的\033会被正确解析成转义字符,%s处填入后面的实参。这个写法既稳又安全,而且能精确控制换行(\n)和制表符,不会被莫名其妙地解释。
注意:用
printf时永远不要把外部变量塞进格式串里。printf "$user_input"这种写法,如果输入里带%s或者%n,轻则输出错乱,重则被利用。格式串必须是固定的字符串字面量,变量一律走%s。
如果一定要用 bash 特有的写法,还有个$'...'语法挺舒服:
RED=$'\033[31m' RESET=$'\033[0m' printf '%s错误信息%s\n' "$RED" "$RESET"$'...'会在赋值时就完成转义解析,变量里存的已经是真正的控制字符了,后面拼接时不用再操心转义。这个写法只在 bash、zsh、ksh 里有效,dash 不支持,所以脚本头部记得写#!/usr/bin/env bash。
3.2 一套能直接抄的变量定义
我在自己的脚本模板里固定放这么一段,复制粘贴就能用,不容易出错:
#!/usr/bin/env bash # 只在 stdout 是终端时才启用颜色 if [ -t 1 ]; then RST=$'\033[0m' BLD=$'\033[1m' DIM=$'\033[2m' UND=$'\033[4m' REV=$'\033[7m' RED=$'\033[31m' GRN=$'\033[32m' YEL=$'\033[33m' BLU=$'\033[34m' MAG=$'\033[35m' CYN=$'\033[36m' WHT=$'\033[37m' BRED=$'\033[1;31m' BGRN=$'\033[1;32m' # 常用组合 ERR="${BRED}" WARN="${YEL}" OK="${BGRN}" INFO="${CYN}" HINT="${DIM}" else RST=; BLD=; DIM=; UND=; REV= RED=; GRN=; YEL=; BLU=; MAG=; CYN=; WHT= BRED=; BGRN= ERR=; WARN=; OK=; INFO=; HINT= fi这段的关键在开头那个[ -t 1 ]判断。文件描述符 1 就是标准输出,-t检测它是不是连着一个终端。如果是终端,说明有人正在看,上色有意义;如果是被重定向到文件或者管道给别的命令,那颜色码只会变成垃圾字节,全部置空最干净。这一招能让同一个脚本既适合人读,也适合机器消费,不用额外加参数。
变量名我刻意用了全大写短词,一是和常见的环境变量风格区分开(不会误覆盖),二是在脚本里用${RED}这种形式读起来像标签,可读性好。缺点是短名容易重名,如果你的脚本里已经有同名变量,记得改前缀,比如统一加C_。
3.3 分等级日志函数:从能用到好用
变量定义好之后,下一步是封装函数。裸写printf虽然能跑,但每个调用点都要重复写颜色和复位,一旦格式调整就得全文替换。封装成函数之后,调用点清爽,格式统一,改起来只动一处。
log_info() { printf '%s[INFO]%s %s\n' "${INFO}" "${RST}" "$*"; } log_warn() { printf '%s[WARN]%s %s\n' "${WARN}" "${RST}" "$*"; } log_error() { printf '%s[ERR ]%s %s\n' "${ERR}" "${RST}" "$*" >&2; } log_ok() { printf '%s[ OK ]%s %s\n' "${OK}" "${RST}" "$*"; } log_step() { printf '%s==>%s %s\n' "${BLU}${BLD}" "${RST}" "$*"; }几个设计细节值得说:
标签定宽。[INFO]、[WARN]、[ERR ]、[ OK ]都是 6 个字符宽(含方括号),这样多行日志的正文起始列完全对齐,竖着扫下来很舒服。[ERR ]中间那个空格不是手滑,是为了凑宽度。如果你不喜欢这种风格,用printf '%-6s'做右对齐也行,我选固定字符串是因为少一次格式化,更好读。
错误走 stderr。log_error里加了>&2重定向到标准错误。这样调用方可以./script.sh 2>error.log单独收集错误,而正常输出不受影响。这一步很多人不做,导致所有东西混在 stdout 里,后期想做分流处理很痛苦。
正文用$*不用$@。日志函数的参数本来就是要拼成一整句话,$*会用空格把参数连起来,正合适。如果正文里要保留参数边界,那就得换$@加循环,不过日志场景基本用不上。
不复位正文。注意我每个函数结尾都没有再补\033[0m。因为正文部分本来就没开样式,复位在标签后面就已经做过了。如果正文也想带颜色(比如log_error想让整行都红),那就在%s前再补一个颜色码,结尾补复位:
log_fatal() { printf '%s[FATAL]%s %s%s%s\n' "${REV}${RED}" "${RST}" "${RED}" "$*" "${RST}" >&2; }这个log_fatal用了反显加红做标签,正文也是红的,视觉上就是一行"严重报警"。实际项目里我一般只对真正会导致退出流程的错误用它,用多了就失去了分级的意义。
再进一步,如果你想让日志带上时间戳和调用位置,可以这么扩:
log_info() { printf '%s[INFO]%s %s %s\n' "${INFO}" "${RST}" "$(date '+%H:%M:%S')" "$*" }时间戳用灰色(${DIM})包一下会更好,不会喧宾夺主。这些都属于渐进式打磨,先能跑,再逐步舒服。
4. 换语言也得懂这套规则:Python、C、Node 的落地
转义序列是终端层面的协议,跟用什么语言写没关系。所以懂了一套,换语言只是换个"发射方式"。不过每种语言都有自己的小脾气,下面挑三个最常见的场景说说。
4.1 Python:常量、colorama 与 rich 的取舍
Python 里最朴素的写法就是字符串常量:
RESET = "\033[0m" BOLD = "\033[1m" RED = "\033[31m" print(f"{BOLD}{RED}[ERROR]{RESET} 任务失败")注意 Python 字符串里\033和\x1b都有效,我一般用\033,和 shell 保持一致,看起来统一。这里有个小陷阱:如果你用 Windows 上的老式控制台跑这段,它会把转义序列原样打出来(新版的终端应用已经支持了,但不是所有环境都默认开启)。为了跨平台,很多项目会引入colorama,初始化一下就能把 ANSI 序列翻译成对应平台的调用:
from colorama import init, Fore, Style init() # 关键,Windows 上不做这步没效果 print(f"{Style.BRIGHT}{Fore.RED}[ERROR]{Style.RESET_ALL} 任务失败")colorama的优点是轻、纯、没有额外依赖,缺点是能力有限,只有 16 色和几个基础样式,想做真彩或者复杂布局就力不从心了。
这时候就可以考虑rich。它不是简单的"上色库",而是一整套终端渲染方案:表格、进度条、语法高亮、Markdown 渲染,而且会自动处理列宽、自动降级(终端不支持颜色时自动退化成纯文本)、自动识别NO_COLOR。用起来是这样的:
from rich.console import Console console = Console() console.print("[bold red][ERROR][/bold red] 任务失败", style="")代价是它比较重,启动有额外开销。我自己的判断标准很简单:脚本里只是给几行日志上色,手写常量或者colorama就够;要输出表格、树形结构、进度条这类有排版需求的,直接上rich,自己手搓只会更痛苦。
还有一点容易忽略:print()默认输出到 stdout,如果你希望错误信息走 stderr,得写print(..., file=sys.stderr),rich则可以用Console(stderr=True)。这个细节在写 CLI 工具时很关键,不然使用者没法用2>把错误分离出来。
4.2 C:宏定义与缓冲区
C 里最自然的做法是用宏:
#include <stdio.h> #define RST "\033[0m" #define BOLD "\033[1m" #define RED "\033[31m" #define GRN "\033[32m" #define UND "\033[4m" int main(void) { printf("%s%s[ERROR]%s 打开文件失败\n", BOLD, RED, RST); printf("%s%s提示:%s 检查权限设置\n", BOLD, UND, RST); return 0; }这里必须提醒一个 C 语言特有的坑:字符串拼接和缓冲区。如果你用snprintf拼一段带颜色的字符串,那几个转义序列是算进长度里的。假设缓冲区按 32 字节分配,而内容本身 20 字节,加上三四个转义序列就可能溢出被截断,截断点如果正好落在序列中间,屏幕上就会出现半截乱码,比如ESC[3后面跟普通文字。解决思路有两个:要么把缓冲区开得足够宽裕(我一般按最坏情况乘 2),要么把颜色和内容分开打印,别拼在一起。
另一个坑是输出顺序。printf默认是行缓冲(输出到终端时)或全缓冲(重定向到文件时),如果你混用printf和write,或者 stdout 和 stderr 交替输出,可能看到顺序错乱。做进度条那类需要覆盖当前行的输出时,还会用到\r回车符配合fflush(stdout),忘了刷新就会一直看不到更新。
/* 一个最简的进度条片段 */ for (int i = 0; i <= 100; i++) { printf("\r%s进度:%s %3d%%", BOLD, RST, i); fflush(stdout); /* 这里放实际工作 */ } printf("\n");\r把光标拉回行首,下一次printf覆盖掉原来那一行,这就是终端进度条的基本原理。真正成熟的进度条还要处理终端宽度、多行刷新、非 TTY 环境降级,但核心机制就这么点东西。
4.3 该不该上色:isatty 与 NO_COLOR 约定
这一节可能是全篇最重要的一个"工程习惯"问题:不是所有时候都该输出颜色。
判断依据有两个层次。第一层是程序层面,检测输出目标是不是终端。各语言的写法:
| 语言 | 检测方式 |
|---|---|
| Shell | [ -t 1 ] |
| Python | sys.stdout.isatty() |
| C | isatty(fileno(stdout)) |
| Node | process.stdout.isTTY |
不是终端就不上色,这是最省事也最正确的默认行为。
第二层是用户层面。有些用户就是不喜欢颜色,或者他的终端配色和你的配色撞了(比如你的"灰"在他那儿几乎看不见)。业界后来形成了一个很朴素的约定:如果环境里存在NO_COLOR这个变量(不管值是什么),程序就不应该输出颜色。对应的还有CLICOLOR=0、CLICOLOR_FORCE=1、FORCE_COLOR等几个变体,支持的宽严程度不一,但NO_COLOR目前认可度最高。
把两层合起来的判断逻辑大概是这样:
import os, sys def color_enabled() -> bool: if os.environ.get("NO_COLOR") is not None: return False if os.environ.get("CLICOLOR_FORCE", "0") not in ("", "0"): return True return sys.stdout.isatty()注意FORCE_COLOR的优先级要高于NO_COLOR(有些实现反过来,看你的团队约定),而isatty作为兜底。这套判断写一次,后面所有输出函数都复用同一个开关,行为就一致了。
实操心得:早年我图省事,脚本里完全不做 TTY 检测,结果 CI 日志里全是
[32m之类的碎片,同事看日志时一脸问号。后来统一加了检测,CI 日志立刻干净了。更省事的办法是把判断做成环境变量开关,默认关、需要时手动打开,排查问题时再临时开启。
5. 那些年踩过的坑与排查手册
配色方案可以慢慢调,但坑必须先填。下面这几类问题,我在不同项目里都至少遇到过一次,整理出来供你对照。
5.1 颜色"串味儿"和复位丢失
最常见的症状是:本该只有一行红色,结果后面几十行全变红了,直到某一处莫名恢复。原因基本只有一个——开了样式忘了关。
前面说的"就近复位"就是对症药。但还有一种隐蔽情况:你在一个分支里提前return或者exit,跳过了本该执行的复位语句。比如:
printf '\033[31m' # 开红 if [ ! -f "$1" ]; then echo "文件不存在" return 1 # 这里直接返回,复位没执行 fi printf '处理中...\033[0m\n'调用方拿到返回值后继续往终端打日志,前面那行红色就一直挂着。修法有两种:一是把颜色变量和内容放在同一段printf里,天然不会漏;二是用trap兜底:
trap 'printf "\033[0m"' EXIT这行放在脚本开头,不管怎么退出,最后都会补一次复位。我在所有带颜色的长脚本里都加这一句,成本几乎为零,收益是杜绝了"终端中毒"这种事。
还有一种"串味"是配色冲突导致的错觉。假设你用了\033[2m做弱化,在深色背景主题下它是"暗一点的灰",很合适;但用户如果把终端背景切成浅色,同样的参数会变成接近白色的浅灰,直接看不清。所以弱化文字不要只依赖亮度,配合一个明确的前景色(比如${DIM}${CYN})更稳。
5.2 重定向、管道与日志文件里的乱码
把带颜色的输出重定向到文件,打开一看全是^[[31m这样的字符,这是最经典的"乱码"投诉。
根因很好理解:转义序列只有在终端解释执行时才会变成可见效果,写进文件就是原始字节。文件里存这些字节本身没错,错的是没做降级。解决方案前面已经给过——入口处判断[ -t 1 ],不是终端就把所有颜色变量置空。
如果文件里已经混进了这些字符,想看原始内容可以这样处理:
# 把转义序列显示成可见形式,方便定位 cat -v app.log # 直接过滤掉所有 SGR 序列后再读 sed -e 's/\x1b\[[0-9;]*m//g' app.log | less # 用十六进制看原始字节 od -c app.log | head有时候你是在less里看日志,发现颜色没生效。那多半是因为less默认不解释控制字符,加-R参数就行:less -R app.log。设为默认可以在环境变量里加LESS=-R。反过来,如果你希望less完全不解释,那就用-r的反面或者干脆过滤。
另一个常见场景是管道给grep、awk。有些工具会自动检测输入是否来自终端,并据此决定要不要保留颜色,有些不会。管道里颜色码会干扰文本匹配,比如你grep "错误"明明该匹配上却匹配不到,很可能是因为"错误"两个字前面夹着转义序列,导致模式不连续。排查方法就是先把颜色剥掉再匹配,或者用grep --color=never强制关闭自身的着色。
5.3 表格对齐与提示符换行错乱
对齐问题是排版时的头号敌人。你在 bash 里写printf '%-20s|%s\n' "$name" "$value",本以为能对齐,结果一旦$name里带颜色,整列就歪了。原因很简单:printf的宽度计算是按字节或字符算的,转义序列那几个字节也算进去了,但它实际渲染宽度是 0,所以视觉上就等于多算了几个字符的位置。
正确做法是只对纯文本做填充,颜色加在填充之外:
# 错误:宽度被转义序列吃掉 printf '%-20s|%s\n' "${RED}${name}${RST}" "$value" # 正确:先按纯文本算宽度,再套颜色 pad=$(printf '%-20s' "$name") printf '%s|%s\n' "${RED}${pad}${RST}" "$value"这个模式看着啰嗦,但非常可靠。如果列很多,可以封装成小函数,传入文本和目标宽度返回补好空格再着色的字符串。要注意printf '%-20s'按字符数算,中文在某些 locale 下宽度算 1 但显示占 2 格,会歪;这时候要么避免中英混排做对齐,要么引入能算"显示宽度"的工具,纯 shell 做这件事性价比不高。
提示符错乱是另一个高频问题,而且症状很吓人:你自定义了彩色的PS1,一切正常,直到输入的命令长到需要折行,光标开始乱跳,退格删错字符,历史命令显示错位。
根因是 bash 需要知道哪些字符是"不占显示宽度的"。转义序列必须用\[和\]包起来,告诉 readline "这段不算宽度":
# 错误:没用 \[ \] 包裹 PS1='\033[1;32m\u@\h\033[0m:\w\$ ' # 正确 PS1='\[\033[1;32m\]\u@\h\[\033[0m\]:\w\$ '或者用 bash 提供的\[/\]常量:
PS1='${GREEN}\u@\h${RESET}:\w\$ '配合PROMPT_COMMAND里给GREEN赋值成\[\033[1;32m\]也行,但注意变量里必须带上\[ \],不然一样错。这个坑害过太多人,一旦发现折行错乱,第一反应就该去检查PS1里的转义序列是不是裸的。
5.4 常见问题速查表
把上面这些经验压成一张表,出问题时按症状对照:
| 症状 | 最可能的原因 | 排查动作 |
|---|---|---|
屏幕上直接显示\033[31m字样 | 用了不支持-e的 echo,或双层转义没解析 | 改用printf,检查 shebang |
| 颜色生效但后面整片变色 | 复位缺失或分支提前返回 | 就近复位,加trap ... EXIT |
| 重定向到文件后是乱码字符 | 没做 TTY 检测 | 加[ -t 1 ]判断,或将颜色变量置空 |
less里看不到颜色 | less默认不解释控制字符 | 加-R,或设LESS=-R |
| 真彩色显示成近似色或无效 | 终端/TTY 不支持 24 位色 | echo $COLORTERM、tput colors确认 |
| 表格列对不齐 | 转义序列被算进宽度 | 先 padding 纯文本,再套颜色 |
| 提示符长命令折行错乱 | PS1里转义没被\[ \]包裹 | 补上方括号 |
| 颜色在某些终端不显示 | TERM设置异常或为dumb | echo $TERM,必要时export TERM=xterm-256color |
| 下划线/斜体不生效 | 终端不支持该属性 | 换用加粗或反显等兼容性更好的属性 |
| 管道后颜色消失或残留 | 工具自身检测策略不同 | 用--color=never/--color=always显式指定 |
提示:排查时用
cat -v或sed -n l观察原始字节,比盯着屏幕猜快得多。看到^[开头、以m结尾的一段,就是 SGR 序列,一眼就能定位。
6. 让输出更耐看的几个细节
能跑起来是一回事,看着舒服是另一回事。最后这部分是我长期迭代下来的一些取舍经验,不一定适用于所有人,但至少能帮你少走一段弯路。
6.1 配色不是越艳越好
刚开始折腾的时候,我恨不得每行都用不同的亮色,红绿黄蓝全上,结果视觉噪音比纯白还大——什么都强调,等于什么都没强调。后来我给自己定了三条规矩。
第一,颜色只表达"层级",不表达"好看"。绿色只用于成功,黄色只用于警告,红色只用于错误,青色用于标题和分隔线,灰色用于次要信息。一旦定了就全项目遵守,不要这次用蓝色表错误、下次用红色表提示,大脑会被搞乱。
第二,一屏内最多三种颜色。正常情况下其实只有一种(普通输出),出错时多一种。三种以上就该考虑是不是该拆分输出,或者把次要信息降到2(变暗)而不是新颜色。
第三,颜色要能在深浅两种背景上都读得清。纯黑30和纯白37是重灾区——深色背景上看不见纯黑,浅色背景上看不见纯白。相对安全的做法是用 90-97 这一档亮色做前景,或者固定用带背景色的"标签块"(比如41;97红底白字),这样无论背景怎么设,对比度都够。
还有个小心思:反显7特别适合做"块状标签"。它在深色主题下表现是浅底深字,浅色主题下自动反过来,天生自适应。用它做区块标题,比指定具体颜色更稳。
6.2 用 tput 查询终端能力
手写转义序列的最大问题是"我不知道目标终端到底支持什么"。tput这个命令就是来回答这个问题的——它读取 terminfo 数据库,把终端能力翻译成具体的控制序列。
tput colors # 输出支持的色彩数量,如 8、256、16777216 tput setaf 2 # 设置前景色(2 号色),比手写更"懂"当前终端 tput setab 4 # 设置背景色 tput bold # 开启粗体 tput smul # 开启下划线 tput rmul # 关闭下划线 tput sgr0 # 重置所有属性,等价于 \033[0m tput cols # 终端宽度 tput lines # 终端高度用tput的好处是它会根据TERM自动选择合适序列,在能力不足的终端上可能就输出空字符串,天然实现了降级。缺点是每条tput都是一次外部命令调用,在循环里大量用会明显拖慢速度。所以我的策略是:初始化时用tput把要用的序列读进变量,循环里直接用变量。
if [ -t 1 ] && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then BOLD=$(tput bold) SMUL=$(tput smul) RMUL=$(tput rmul) RED=$(tput setaf 1) GRN=$(tput setaf 2) RST=$(tput sgr0) else BOLD=; SMUL=; RMUL=; RED=; GRN=; RST= fi这一段比手写\033[31m啰嗦,但它在老式终端、精简环境、TERM=dumb的情况下不会吐出一堆乱码,鲁棒性明显更高。如果你的脚本要发给别人用,我建议走这条路线;如果是自己机器上的私用脚本,手写更省打字。
6.3 我自己的几条硬习惯
最后列几条我几乎在所有项目里都会坚持的小习惯,都是踩坑踩出来的。
入口处统一判断一次,不在每个函数里判断。颜色开关做成全局状态,初始化时定好,后面所有输出函数直接读变量的值。分散判断的后果是总有一两个地方忘了判,而那一两个地方恰恰就是出问题的地方。
永远留一个"无色模式"。除了NO_COLOR,我自己还会加一个--no-color或者PLAIN=1的开关,方便在演示、投屏、复制粘贴场景下关掉样式。投屏时颜色失真、复制时带上乱码字符,这些尴尬场景有个开关就能避开。
不要用颜色传递唯一信息。色盲用户、单色终端、日志归档都可能丢掉颜色,所以错误行除了标红,还要有[ERR]或者FAIL这样的文字标记。颜色是锦上添花,不是信息载体。
控制字符不要写进配置文件或数据文件。见过有人把带颜色的字符串存进 JSON 配置里,结果解析时各种报错。样式属于"渲染层",数据保持干净,渲染时再套。
新脚本先跑一遍重定向测试。./script.sh > /tmp/out.txt 2>&1然后cat -v /tmp/out.txt,如果里面还有^[这类字符,说明降级没做好。这个测试我基本形成了肌肉记忆,写完之后顺手就来一次。
还有一条纯个人偏好:我用${DIM}做补充说明的次数远多于用${UND}。下划线在等宽字体里会和下划线字符打架,尤其输出里本来就有_时辨识度很差;而变暗既弱化了信息,又不干扰阅读,是"补充说明"这个语义最贴切的表达。至于粗体,我只用在两处:行首标签和最终结论。用多了整屏都在喊,反而没人看。
这套东西归根结底不复杂,核心就是ESC[加参数加m,剩下的是怎么把它用得稳、用得省心。真正花时间的从来不是"记住参数",而是处理好中断、降级、对齐、可读性这些边角。把这些边角处理完,你的脚本输出就会从"能用"变成"好用"。