终端Raw模式与ANSI语法高亮实现指南

发布时间:2026/10/31 15:29:06
终端Raw模式与ANSI语法高亮实现指南 终端里做界面最让人又爱又恨的东西就是 ANSI 转义序列。爱它是因为只要一行\e[38;5;208m你就能在任何支持颜色的终端里画出想要的层次恨它是因为大多数 UI 框架会把你的字符串当作纯文本处理帮你转义、帮你计算宽度、帮你套上统一的样式于是你精心拼进去的颜色码全部变成了原样显示的乱码。Sequin 这类终端界面组件库默认就是这个思路它替你管格式你只管给内容。但做日志查看器、diff 面板、REPL 输出回放这类需求时你恰恰需要反过来——自己要控制每一个字节的输出。这时候就得启用 Raw 模式。这篇内容讲的就是这件事Raw 模式打开之后字符串怎么直通渲染层ANSI 序列该怎么组织语法高亮器怎么写宽度和状态残留这两个坑怎么绕以及一套可以直接抄的完整实现。适合已经能跑起基本终端面板、想进一步控制输出细节的人看完全没碰过 ANSI 的也能跟因为我会把最小必要知识先铺一遍。1. 先把“高亮”这件事拆开看1.1 Raw 模式到底改变了什么先把概念对齐。终端面板里的“渲染”其实分两层上层是组件负责布局、分块、滚动下层是终端模拟器负责把字节流翻译成像素。中间的框架一般会插一脚把你的文本做两件事——转义特殊字符以及套上它自己的样式模板。默认模式下你写\e[32mgreen\e[0m框架看到的是一个普通字符串它会把里面的 ESC 字符当成需要转义的普通内容最后你看到的是一串[32mgreen[0m或者它干脆把整段文本统一染成它认为合适的颜色你的绿色被覆盖掉。这不是 bug这是设计的默认行为因为绝大多数场景下用户不需要关心这些。Raw 模式做的事就一句话关掉中间这一脚让你的字符串原样抵达渲染层。开了之后框架不再转义、不再包装样式字符串里的 ESC 字节被当作控制指令直接送给终端。自由度和责任是同时到来的——颜色、缩进、清行、光标移动全部归你管一旦序列写错或者忘了收尾后果也全部由你承担。我用它主要覆盖四类场景带颜色的日志尾部跟踪、代码片段或 diff 的高亮展示、把外部命令的彩色输出原样贴进面板、以及需要精细控制刷新比如只重绘某几行的动态视图。这四类的共同点是颜色的语义来自内容本身框架不可能猜得到只能由调用方给。1.2 ANSI 转义序列的最小知识集不需要背整本 ECMA-48掌握一个子集就够用了。所有序列都从 ESC字节0x1BElixir 里写\e开始后面跟一个引导字符决定序列类型。我们要用的几乎全是 CSI也就是ESC [写作\e[。CSI 之后的数字是参数用分号分隔最后一个字母是终止符。终止符m对应 SGRSelect Graphic Rendition也就是“样式设置”这是高亮的全部基础。注意终止符不能省。\e[32后面不带m终端会一直等后续字节直到遇到一个合法终止符为止表现出来就是后面一大段输出全被吃掉或者错乱。SGR 参数里最常用的部分我列成表写主题配色时直接查参数含义常用写法0重置所有样式\e[0m1/2加粗 / 变暗\e[1m、\e[2m3/4/9斜体 / 下划线 / 删除线\e[4m7反显前景背景互换\e[7m30–37基础前景色\e[33m黄39/49恢复默认前景 / 背景\e[39m40–47基础背景色\e[44m90–97亮色前景高亮版\e[90m亮灰100–107亮色背景\e[100m38;5;n256 色前景n 取 0–255\e[38;5;208m48;5;n256 色背景\e[48;5;208m38;2;r;g;b真彩前景\e[38;2;255;135;0m48;2;r;g;b真彩背景\e[48;2;20;20;20m有个容易混的点38;5;n和38;2;r;g;b都归38管你要么给 256 色模式传一个索引要么给真彩模式传三个分量别混着写。另外参数可以合并\e[1;38;5;208m等于“加粗 橙色前景”一次设定。合并能减少字节数在每秒推几十帧的场景里这点开销不算大但代码会更清爽。1.3 三条染色路线的取舍真要做高亮路口有三条别一头扎进去就写先看清楚各自的代价。路线做法优点代价内置样式 API用框架提供的颜色/文本块接口零学习成本宽度自动算对只能整块上色做不到同一行内多色Raw 手写 SGR自己切词、自己拼序列完全可控粒度到字符宽度、状态、性能全得自己兜调用外部着色器管道调用pygmentize、bat之类输出 ANSI支持语言多规则成熟进程开销大难以逐帧刷新输出宽度不可控我做过一段时间的横向对比如果只是给标题刷个颜色第一条路足够别折腾。如果是展示源码或日志第二条路是主力因为你要的不只是“这段是代码”而是“这个关键字是紫色、这个字符串是绿色、这个注释是灰色”。第三条路只在一次性渲染、不需要动态刷新时才划算——起一个进程换一份高亮成本可以接受但如果面板每秒推一帧那就是每秒起一个进程很快就撑不住了。2. Raw 模式的机制、边界与开启方式2.1 字符串从 Elixir 到终端的完整链路理解链路能省掉很多猜测。你的字符串在 Elixir 里是二进制进入渲染环节后一般会被拼进 iodata二进制与嵌套列表的混合结构避免反复拼接拷贝然后由一个下发通道送给前端渲染层前端再塞进 DOM 的文本节点或者 canvas 里画出来。默认模式在“进入 iodata 之前”这一步会做转义和样式包装Raw 模式跳过这一步同时也会跳过配套的几件事不自动换行、不自动补齐、不自动对齐。这就解释了为什么很多人第一次开 Raw 会遇到同样的现象内容能显示但一旦有中文或者稍微长一点就整块错位而且错位会传染到后面的所有元素。因为框架帮你算宽度的逻辑关了它现在把整段字符串当“一个看不见宽度的整体”或者“按字节数算宽度”来处理你塞进去的每个\e[38;5;208m12 个字节都被算进了可见宽度里。这是下一节的主题。2.2 开启方式与最小可运行示例以我手上这一版为例开关放在构造输出块的地方传raw: true就行。不同版本的函数名可能略有差异核心就是找那个接收字符串、决定“怎么渲染这段文字”的入口把 raw 打开。# 默认模式框架接管格式 Sequin.new() | Sequin.text(\e[32mOK\e[0m 编译通过) # Raw 模式字符串原样下发 Sequin.new(raw: true) | Sequin.text(\e[32mOK\e[0m 编译通过)第一段把 ESC 当成普通字符你看到的是字面上的[32mOK[0m绿不绿取决于框架心情第二段把 ESC 原样送出去你看到的是绿色的OK后面接一段默认色的中文。验证是否真的生效有个小技巧先只发\e[41m \e[0m一个红色背景的空格。Raw 生效的话你会看到一小块红色方块没生效的话你会看到一串字符。这个测试比看颜色更可靠因为它不依赖终端的前景色配置。如果你的版本没有raw:这个键等价做法是找“预格式化文本”或者“直接输出”类的接口思路一样绕开自动包装。别去改框架源码先看看文档里有没有这个逃生口。2.3 宽度与截断Raw 模式的第一号陷阱这是最容易翻车的地方值得单独说。终端里一个字符占几列和它的字节数没关系和它的码点也不完全对等。ASCII 是 1 列中日韩文字和大部分全角符号是 2 列Emoji 通常是 2 列零宽连接符是 0 列。你自己拼的转义序列是 0 列但它的字节数可能很长。所以“长度”这个词在这里有三个含义字节数、码点数、可见列宽。布局只认第三个。标准做法是计算宽度前先把转义序列剥掉再按字符的实际显示宽度累加。剥离用一条正则就够覆盖绝大多数 SGR 和 CSI 序列strip_ansi ~r/\e\[[0-9;]*[A-Za-z]/ def visible_width(text) do text | String.replace(strip_ansi, ) | String.to_charlist() | Enum.reduce(0, fn cp, acc - acc char_cols(cp) end) end # 只覆盖常见的宽字符区间够用且不引入额外依赖 defp char_cols(cp) when cp in 0x1100..0x115F or cp in 0x2E80..0xA4CF when cp in 0xAC00..0xD7A3 or cp in 0xF900..0xFAFF when cp in 0xFE30..0xFE6F or cp in 0xFF00..0xFF60 when cp in 0xFFE0..0xFFE6 or cp in 0x1F300..0x1F64F, do: 2 defp char_cols(_), do: 1截断的难点在于不能把序列切断。你按列数硬切字节很可能切在\e[38;5;2中间剩下的08m就变成了普通文本直接污染画面。正确姿势是按“转义段”和“文本段”分开处理转义段无条件保留文本段按剩余列数取前缀最后补一个\e[0m收尾。这个函数后面我会给出完整版。2.4 SGR 是状态机不是标签对写 HTML 的人习惯span.../span成对出现天然闭合。ANSI 不是这样它是一台有状态的小机器你发一个\e[32m终端就把“前景绿”这个状态记下来直到遇到下一个改变前景的参数或者0重置。这意味着两个后果。第一忘写重置会污染后续所有输出。面板里一个块漏了\e[0m下一个块的边框、标题、甚至整个页面的底色都会变成绿色。排查这类问题很痛苦因为症状出现在下游根因在上游。我的习惯是写一个paint/2函数任何上色都必须经过它函数内部强制在结尾补\e[0m从源头上消灭手工拼序列的机会。第二嵌套时不能简单叠加。比如你给整行设了背景色中间某一段要高亮前景那一段结束后必须把前景恢复到“整行的颜色”而不是默认色否则后半行就掉回了默认前景。稳妥的写法是每个 token 自成闭环开序列 文本 \e[0m让每个染色片段互不干扰。代价是字节数变多收益是永远不用想“我现在处于什么状态”。3. 手写一个能用的 ANSI 高亮器3.1 词法切分规则顺序就是优先级做语法高亮最朴素的思路是“按顺序套正则替换”。这个思路必翻车因为替换是全局的、无序的你先给def上色再去处理字符串字符串里的def也被上了一层最后颜色互相打架。正确做法是从左到右单向扫描每次只在当前位置匹配命中就消费掉这段绝不回头。这一遍扫下来天然解决了嵌套和重叠问题。规则的顺序就是优先级越靠前越优先。经验排序是注释 字符串 数字 关键字 标识符。注释排最前因为# 这里有个 引号这类内容太常见注释内的引号不该被当成字符串起始。字符串排第二因为字符串里可能出现任何关键字。代码长这样rules [ {:comment, ~r/#[^\n]*/}, {:string, ~r/(?:[^\\]|\\.)*/}, {:number, ~r/\b\d(?:\.\d)?\b/}, {:keyword, ~r/\b(def|defp|defmodule|do|end|fn|case|when|if|else|true|false|nil)\b/}, {:call, ~r/\b[a-z_][a-zA-Z0-9_]*[?!]?(?\()/} ] defp match_at_head(input) do Enum.find_value(rules, :none, fn {kind, re} - case Regex.run(re, input, return: :index) do [{0, len} | _] - token::binary-size(len), rest::binary input {:ok, kind, token, rest} _ - nil end end) endreturn: :index让正则返回字节偏移而不是匹配到的字符串这样{0, len}就明确表示“在开头命中了 len 个字节”。这个细节很关键用字符串匹配再回头算位置会踩到多字节字符的坑。3.2 把颜色和语法解耦主题表怎么设计高亮器不该硬编码颜色。语法类别comment、string、keyword和具体颜色之间要隔一层主题表这样换配色只改一张表不动逻辑。主题表的值直接存 SGR 参数列表运行时拼成序列themes %{ dark: %{ comment: [90], string: [38, 5, 114], number: [38, 5, 179], keyword: [1, 38, 5, 176], call: [38, 5, 81], plain: [39] }, light: %{ comment: [38, 5, 245], string: [38, 5, 28], number: [38, 5, 130], keyword: [1, 38, 5, 90], call: [38, 5, 25], plain: [39] } }这里有个实际取舍真彩38;2;r;g;b颜色更准但序列长、对老终端不友好256 色38;5;n兼容性好色板有限但足够区分语法类别。我的选择是主题表里统一用 256 色因为面板经常要在不同机器、不同终端主题下看真彩的对比度很难保证——同一个橙在暗底上很好看换到亮底就糊了。所以准备两套主题、按背景切换比死磕一套真彩更实用。3.3 包裹而非拼接paint 与注入防护前面说过每个 token 自成闭环。这个函数虽小但它是整个体系的安全阀defp paint(token, kind, theme_key) do codes themes | Map.fetch!(theme_key) | Map.get(kind, [39]) \e[ Enum.join(codes, ;) m escape_user(token) \e[0m end # 用户内容里如果自带 ESC一律剥掉防止破坏外层状态 defp escape_user(text), do: String.replace(text, \e, )escape_user那行别省。高亮器处理的内容经常来自外部——日志文件、数据库里的报错信息、别人贴进来的片段里面完全可能藏着 ESC 字节。如果不剥一段精心构造的文本就能把你的面板改成任意样子或者把后面的所有元素染色。剥掉 ESC 会损失一点“原样显示”的保真度但换来的是可预测性我认为非常值。3.4 完整的扫描器与跑通验证把上面几块拼起来就是一个不依赖任何第三方库、两百行以内搞定的高亮器defmodule Fmt.Highlight do rules [ {:comment, ~r/#[^\n]*/}, {:string, ~r/(?:[^\\]|\\.)*/}, {:number, ~r/\b\d(?:\.\d)?\b/}, {:keyword, ~r/\b(def|defp|defmodule|do|end|fn|case|when|if|else|true|false|nil)\b/}, {:call, ~r/\b[a-z_][a-zA-Z0-9_]*[?!]?(?\()/} ] themes %{ dark: %{comment: [90], string: [38, 5, 114], number: [38, 5, 179], keyword: [1, 38, 5, 176], call: [38, 5, 81], plain: [39]}, light: %{comment: [38, 5, 245], string: [38, 5, 28], number: [38, 5, 130], keyword: [1, 38, 5, 90], call: [38, 5, 25], plain: [39]} } def render(text, theme \\ :dark) do scan(text, [], theme) | Enum.reverse() | IO.iodata_to_binary() end defp scan(, acc, _theme), do: acc defp scan(input, acc, theme) do case match_at_head(input) do {:ok, kind, token, rest} - scan(rest, [paint(token, kind, theme) | acc], theme) :none - cp::utf8, rest::binary input scan(rest, [cp::utf8 | acc], theme) end end defp match_at_head(input) do Enum.find_value(rules, :none, fn {kind, re} - case Regex.run(re, input, return: :index) do [{0, len} | _] - token::binary-size(len), rest::binary input {:ok, kind, token, rest} _ - nil end end) end defp paint(token, kind, theme) do codes Map.get(Map.fetch!(themes, theme), kind, [39]) \e[ Enum.join(codes, ;) m String.replace(token, \e, ) \e[0m end end调用就一行输出直接塞给 Raw 模式的文本块code def hello(name), do: \hi #{name}\ # 打招呼 IO.puts(Fmt.Highlight.render(code, :dark))跑起来应该看到def是加粗紫、hello是青蓝、字符串是绿、注释是灰。如果全是默认色先确认是不是忘了开raw: true——这是我见过次数最多的“高亮器没生效”原因占了大概七成。3.5 性能边界什么时候必须加缓存朴素扫描是逐字符推进的遇到:none就消费一个码点继续。对一个几百行的文件做一次全量高亮在我这边大概是一两毫秒量级完全可以接受但如果你打算每帧重绘整屏、每秒三十帧那就是每秒几十毫秒花在重复计算同样的内容上白白浪费。两个优化按性价比排序。第一个是只高亮变化的部分。日志场景里历史行不会变新行才需要处理。做法是维护一个已经渲染好的行列表新内容到了只处理增量把结果追加进去整个帧再拼一次。这能省掉 90% 以上的重复计算改动量也不大。第二个是缓存到行级别。用一个 map 存“行内容 → 渲染结果”命中就跳过扫描。这招在处理滚动回看、反复切窗口时特别有效但要注意给 map 设一个容量上限否则长时间运行的进程会慢慢把内存吃光。我一般是超过两千条就清掉最早的。注意别去预编译正则之后再指望它解决一切。Regex在 Elixir 里编译后复用确实比每次编译快但真正的大头是“扫描次数”不是“单次匹配速度”。优化方向搞错改半天看不到效果。4. 实操搭一个会动的日志高亮面板4.1 环境与依赖准备环境这块要求不高。Elixir 1.15 以上、OTP 26 以上基本都能跑主要图的是String.to_charlist/1和相关 Unicode 处理的稳定性。如果是笔记本环境里跑比如 Livebook直接装依赖、开一页新的就行如果是独立应用把Sequin加进mix.exs的依赖列表mix deps.get拉一下。# mix.exs 片段 defp deps do [ {:sequin, ~ 0.1} ] end版本号按你实际能拉到的最新稳定版填别照抄。装完之后先跑一个最小验证开 raw、发一个红底空格确认能看到色块。这一步花两分钟能帮你排掉后面一半的诡异问题——很多“颜色不对”最后都定位到“raw 压根没生效”。4.2 数据管道读取、切窗口、高亮、推帧整个面板的数据流是单向的串起来就是四条读取从文件尾部按块读或者订阅一个日志输出流拿到新的一批行。切窗口只保留最后 N 行N 取决于面板高度早的丢弃。窗口滑动是视觉效果的关键。高亮对窗口内每一行跑Fmt.Highlight.render/2拼成一块带换行符的文本。推帧开 raw 的文本块把整块内容塞进去触发重绘。defmodule Panel do alias Fmt.Highlight max_lines 40 def frame(lines, theme) do body lines | Enum.take(-max_lines) | Enum.map_join(\n, Highlight.render(1, theme)) \e[2J\e[H body end end开头那个\e[2J\e[H是清屏加归位作用相当于“擦干净再画”。这两个序列是判断你 Raw 模式是否真的掌控输出的最好证据如果清屏没生效内容是一层层滚上去的说明序列没被识别如果清屏生效但内容闪烁严重那是帧率或者重绘量的问题后面讲。4.3 参数实测刷新间隔、行数与色深参数没有标准答案但有钱包——每一次不必要的重绘都要花 CPU 和带宽。这几个数是我在几种场景下反复调整后比较稳的组合场景刷新间隔窗口行数色深说明日志跟踪500ms 合并推送30–40256 色别逐行推攒一批一起刷静态源码展示不刷新全量256 色一次性渲染加缓存即可滚动回看按需视口 20256 色上下文多留一点滚动手感好diff 面板300ms视口 10256 色变化频繁但不剧烈刷新间隔我试过 100ms效果是画面流得很顺但 CPU 占用一直挂在 20% 下不来风扇开始叫。改到 500ms 之后占用降到个位数肉眼几乎看不出延迟差异。原因很简单人眼对“每半秒来一批新行”的接受度远高于“每 100 毫秒重画一次整屏”的代价。窗口行数也别贪多40 行以上就要小心——全量重绘的开销是线性增长的而屏幕根本显示不下那么多。4.4 我在这个环节踩的四个坑坑一清屏序列和滚动混用。一开始我用\e[2J清屏再加内容发现日志短的时候没问题日志一长画面就开始跳。后来才明白问题出在“清屏之后光标位置”和“框架自己也在调整滚动位置”这两件事打架。改成只更新可见区域、不清屏跳动就没了。经验如果框架本身提供了容器滚动就别再自己发清屏指令二选一。坑二真彩在浅色终端上惨不忍睹。调试时在暗色主题下配的色换到浅色终端一看注释灰和背景糊成一片。这个没法靠调一个参数解决只能准备两套主题并且提供一个手动切换入口。坑三截断切在序列中间。前文提过我是真踩了一遍。表现很迷惑大部分行正常偶尔某一行后面拖出一串m或者[0。定位办法是把渲染输出里的 ESC 替换成可见的ESC打印出来一眼就能看到断在哪。修法就是按段处理别按字节切。坑四多行内容里的反引号和内联样式。如果你的日志本身带 markdown 风格标记高亮器会把它当普通文本染色视觉上很乱。这类内容建议在进高亮器之前先做一遍清洗或者干脆给这类行套一个“纯文本”类别只上基础前景色。5. 常见问题与排查速查5.1 样式串味、颜色残留的排查顺序症状是“某个块之后所有内容都变色了”。别急着改渲染逻辑按这个顺序查通常三步内能定位先看有没有漏\e[0m。搜索你的代码里所有手写的\e[确认每一处都有对应的重置如果用统一的paint/2包装那大概率不是这里。再看有没有序列被切断。异常截断会导致后半截变成普通文本或者残留一个悬空的状态设置。用ESC可见化打印出来对照。最后看嵌套恢复。整行设了背景色、内层 token 结束后回了默认色后半行就会掉回默认前景。这种情况不是“漏重置”而是“重置过头”症状是半行正常半行不对很好区分。5.2 对齐错位与越界的定位方法症状是右边框对不齐、长行把布局顶出去、或者换行位置莫名其妙。核心原因永远只有一个你在用字节数或者码点数冒充列宽。验证办法很土但有效造一行“纯 ASCII 20 字符”和一行“10 个中文字符”两者可见宽度都应该是 20如果渲染出来宽度不一样说明宽度计算没考虑双宽字符。修好了这个再去检查截断是否保留了完整的转义段。5.3 复制粘贴带出一堆控制符这个坑挺尴尬的面板里看着很漂亮用户一选中复制粘到别处就是一堆[38;5;81m。这不是你的错是终端的选中行为把 ESC 字节也复制走了。解法是提供一份“无格式副本”渲染时保留一份原始纯文本或者用剥掉序列的正则现算放在一个可复制的区域里。我一般是把这份副本放在面板下方标题写清楚“纯文本”省得用户来回折腾。5.4 一页速查表现象最可能的根因处置看到字面量[32mRaw 模式没开开raw: true用红底色块验证颜色展示但整块错位宽度把序列算进去了先剥序列再算列宽某一行后全变色漏\e[0m或序列被截断统一走paint/2按段截断半行颜色不对内层重置回默认没回外层每个 token 自成闭环画面闪烁全量重绘频率过高合并推送间隔放到 300–500ms清屏后画面跳动清屏和框架滚动打架只更新可见区别手动清屏浅色终端上看不清主题按暗底调的准备两套主题手动切复制出来带控制符终端选中包含 ESC额外提供无格式副本最后说一个我用了很久的习惯。任何新写的 ANSI 片段先单独丢进一个最小的 Raw 块里看效果别一头扎进复杂布局里调。因为高亮器的错误和布局的错误长得很像——都是“位置不对”“颜色不对”混在一起排查会让人怀疑人生。把上色和布局分开验证两个都单独确认没毛病再合起来这个习惯帮我省下的时间比任何优化技巧都多。另外主题表里我会特意留一个plain: [39]的条目给那些“不想被染色但必须走同一条渲染路径”的内容用这样所有输出都经过同一个入口不会出现某条支路绕过收尾逻辑导致状态泄漏。这个小心思看着不起眼但在长跑的面板里它是保持画面干净的关键。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询