第一次在别人项目里看到控制台打印出一整张彩色 logo 的时候,我盯着那堆方块字看了一会儿才反应过来——这不是什么特殊 API,就是console.log加一串颜色转义码而已。后来自己动手做,才发现从"打印几个字符"到"打印出一张能看的图",中间隔着一堆细节:字符不是方的、终端会换行、字符串太长会被截断、Windows 和 macOS 渲染还不一样。
这篇就把console.log输出特殊字符图案和自定义图片这件事从头拆一遍。会讲清楚字符画背后的像素映射逻辑、半块字符配真彩色怎么把清晰度翻倍、浏览器 DevTools 里用 CSS 直接贴原图的路子,还有我自己踩过的那些坑。适合想把终端启动日志做得好看点的后端同学、想在前端控制台埋彩蛋的人,以及纯粹觉得这东西好玩想搞明白原理的读者。全篇不依赖什么冷门库,能跑通的代码我都会给完整。
1. 控制台为什么能当画布用:等宽字体与字符网格的映射关系
1.1 终端和 DevTools 的本质都是一张等宽字符表
想明白为什么能在控制台里"画画",先得接受一个事实:终端不是画布,它是一张提前排好格子、每格只能放一个字符的表格。你调用的所有打印能力,最后都落到"往第 N 行第 M 列塞一个什么字符、这个字符什么颜色"上。列与列之间的宽度完全相等,行与行之间的高度也完全相等——这就是等宽字体的意义所在。
这个特性决定了字符画的全部逻辑:我只要控制每个格子里塞哪个字符、前景色和背景色是什么,就能让整张表格看起来像一幅画。它和像素屏的原理其实是一回事,只不过像素尺寸固定且极小,而字符格子大得多、形状也不是正方形。理解了这一点,后面所有技巧都只是在这张表格上做文章。
DevTools 的控制台机制略有不同。浏览器允许你给日志段落挂 CSS 样式,也就是说那一行不一定是纯文本,还可以有背景图、内边距、字号。这条路子跳出了字符网格的限制,能直接显示一张真实的图片,代价是只有打开 DevTools 才能看到,页面本身完全不受影响。
1.2 一个字符不是正方形:宽高比会毁掉你的画面
这是新手最容易翻车的地方。等宽字体里,一个字符的宽度大约是字号的 0.6 倍,而一行的高度通常是字号的 1.0 到 1.2 倍。也就是说,字符格子的宽高比大概是 1:1.7 到 1:2,粗略记成1:2就够了。
如果你把一张正方形的图片按 80 列 × 80 行去转换,打出 80 行字符,结果一定是又瘦又长,像被拉面了一样。原因很简单:横向 80 个格子的物理宽度,差不多只有纵向 80 个格子物理高度的一半。所以纵向的采样行数必须减半,用 80 列去配 40 行左右,画面才不会被拉伸。
这个补偿系数是整件事里最重要的一个参数。我习惯把它写成变量aspect,默认 0.5,遇到不同终端再微调。VS Code 集成终端的默认行高是 1.0,实际宽高比大约 1:1.67,用它跑出来的图会稍微偏高一点点;把aspect调到 0.6 左右会更贴合。这些细节没法一次性写死,得看着效果调。
1.3 亮度到字符的映射:把 0 到 255 压成十几个档位
灰度字符画的核心思路是:先算出每个像素的亮度,再把亮度分成若干档,每档对应一个"看起来黑度差不多"的字符。常见的字符集是" .:-=+*#%@",从最浅的空格到最深的@,一共 10 档。像素越亮越用靠前(稀疏)的字符,越暗越用靠后(密集)的字符。
档位数量直接决定画质。10 档在做 40×20 这种小图时够用,但做到 120 列时会明显出现色带,明暗过渡不够平滑。我一般备两套字符集,小图用短的,大图用长的:
const RAMP_SHORT = ' .:-=+*#%@'; const RAMP_LONG = ' .\'`^",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';长字符集的问题是"字符墨量"的跳跃不均匀。比如i和l的墨量其实差不多,挤在一起就浪费了档位;而@和#的墨量差距又很大。所以选字符集的时候,最好按实际渲染出来的黑度排一遍序,而不是凭感觉敲一串符号。我自己的做法是把候选字符在一张白底上渲染出来,量一下覆盖率,按覆盖率排序,效果比随手写的好得多。
另一个常被讨论的点是"要不要先把 sRGB 做线性化"。很多人说应该先转成线性光再算亮度,理论更严谨。但实际做字符画时我建议不要:sRGB 值本身就是做过感知编码的,直接拿来分档,人眼看到的明暗过渡反而更自然;做线性化之后暗部会被严重压缩,整张图的暗部糊成一片黑。这一点挺反直觉,但多试几张图就能看出来。
2. 图片转字符画的核心链路:采样、灰度、映射三步走
2.1 采样:先定横向列数,再倒推纵向行数
整个转换链路可以拆成三步,第一步是采样。你要先决定输出多少列,通常取终端实际宽度或者你想要的画幅宽度。列数定了之后,纵向行数不是随便定的,而是根据图片原始比例和前面说的宽高比补偿算出来的:
rows = round(原图高度 / 原图宽度 * cols * aspect)这个公式的意思是:先把原图按比例缩放到 cols 列宽,得到理论上的行数,再乘上补偿系数把纵向压扁一半。如果你跳过这一步直接按原图比例打,图必然是纵向拉伸的。
采样方法的选择也有讲究。最省事的做法是drawImage(img, 0, 0, cols, rows),让浏览器帮你把图缩到目标尺寸,然后一次性读取像素。但浏览器在做大幅度缩小时,不同引擎用的插值算法不一样,Chrome 在缩小到很小尺寸时会丢细节,边缘出现锯齿或者细节直接消失。我一般会做分步缩小——每次缩到当前的一半,重复几次再缩到目标尺寸,这样出来的结果平滑得多。
如果你追求更准确的效果,可以读原图完整尺寸的像素数据,然后按块算平均值。块平均相当于自己做了一次区域采样,比插值更能保留整体明暗关系,代价是要遍历的像素多了几倍。图片不大的话,这点开销完全无所谓。
2.2 灰度计算:0.299、0.587、0.114 这组权重的来历
拿到像素的 RGB 之后要算亮度。最常用的是这组权重:
const lum = 0.299 * r + 0.587 * g + 0.114 * b;这组数字来自早期彩色电视的亮度公式,绿色权重最高、蓝色最低,符合人眼对绿光最敏感、对蓝光最不敏感的生理特性。用它算出来的灰度,和人眼感知的明暗最接近。
常见的错误是直接用(r + g + b) / 3取平均。这么算的话,纯蓝和纯黄会得到差不多的亮度值,但人眼看上去纯黄要亮得多,最后画出来蓝色区域的字符密度会明显偏大,整张图显得脏。所以我建议老老实实用加权公式,成本一样,效果差别很大。
还有 alpha 通道的处理。带透明背景的 PNG 直接取 RGB 会得到一片黑色,因为透明区域的像素值通常是 0。正确做法是先做一次背景合成,把透明像素和底色混一下:
const a = data[i + 3] / 255; const r = data[i] * a + 255 * (1 - a); // 合成到白底 const g = data[i + 1] * a + 255 * (1 - a); const b = data[i + 2] * a + 255 * (1 - a);合成到白底还是黑底取决于你的终端背景色。暗色终端就合成到黑底,亮色终端合成到白底。这一步不做,透明 logo 打出来就是一团黑饼。
2.3 前端可用的完整实现
把上面的东西拼起来,就是一份可以直接在浏览器控制台里跑的代码:
const RAMP = ' .:-=+*#%@'; const ASPECT = 0.5; async function imageToAscii(url, cols = 100) { const img = await new Promise((resolve, reject) => { const el = new Image(); el.crossOrigin = 'anonymous'; // 关键,否则读不到像素 el.onload = () => resolve(el); el.onerror = reject; el.src = url; }); const rows = Math.max(1, Math.round((img.height / img.width) * cols * ASPECT)); const canvas = document.createElement('canvas'); canvas.width = cols; canvas.height = rows; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.fillStyle = '#000'; // 与终端背景保持一致 ctx.fillRect(0, 0, cols, rows); ctx.drawImage(img, 0, 0, cols, rows); const { data } = ctx.getImageData(0, 0, cols, rows); const lines = []; for (let y = 0; y < rows; y++) { let line = ''; for (let x = 0; x < cols; x++) { const i = (y * cols + x) * 4; const a = data[i + 3] / 255; const r = data[i] * a; const g = data[i + 1] * a; const b = data[i + 2] * a; const lum = 0.299 * r + 0.587 * g + 0.114 * b; const idx = Math.min(RAMP.length - 1, Math.floor((lum / 255) * RAMP.length)); line += RAMP[idx]; } lines.push(line); } return lines.join('\n'); } imageToAscii('/logo.png', 100).then(console.log);注意crossOrigin那一行。canvas 有同源策略,如果你加载的是跨域图片又没声明跨域属性,getImageData会直接抛安全错误,提示画布被污染。本地图片或者同源接口返回的图没这个问题。如果必须用跨域图,要么服务端配合加上跨域响应头,要么先把图片转成 base64 内联进来。
3. 半块字符加真彩色:让控制台画面清晰度翻倍
3.1 为什么选上半块字符而不是实心方块
纯灰度字符画看着还行,但一旦见过真彩色版本就回不去了。真彩色的关键技巧是用U+2580 上半块字符▀。
这个字符的妙处在于:它只填充了字符格子的上半部分,下半部分是镂空的。终端渲染它的时候,上半部分用前景色,下半部分用背景色。也就是说,一个字符格子能同时承载两个独立的颜色——上半像素和下半像素各一个。
对比一下用实心方块█的做法:一个格子只能显示一个颜色,要做到同样的分辨率,纵向得用两倍的字符行数。而用▀,纵向分辨率直接翻倍,而且行数不变,日志体积也不变。更妙的是,这刚好和前面说的宽高比补偿对上了——一个字符格子本来就要压两个像素行,现在用两个颜色装两个像素,像素还是方的,一点都不用拉伸。
选▀而不是下半块▄,纯粹是习惯问题,两者对称,用哪个都行。但要注意别用█配前景色,那样下半部分永远是终端背景色,画出来全是横条纹。
3.2 ANSI 真彩色序列的拼装方式
终端颜色控制靠的是 ANSI 转义序列,格式是ESC[加参数加字母m,其中ESC是\x1b。
const ESC = '\x1b['; const fg = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`; // 前景色 const bg = (r, g, b) => `${ESC}48;2;${r};${g};${b}m`; // 背景色 const RESET = `${ESC}0m`; // 重置38;2;R;G;B是"前景色,真彩色模式,后面跟三个分量",48是背景色。这套写法在 xterm、iTerm2、Windows Terminal、macOS 终端上都支持。老一点的终端只支持 8 色或 256 色,那就是38;5;N的格式,N 是 0 到 255 的调色板索引。
每打印一个▀,前面先输出一次前景色序列和一次背景色序列,一个字符就带上了两个像素的颜色。一行 80 个字符下来,原始字符串长度大约是 80 × 40 = 3200 字节,再加上重置序列,一行三四个 KB。
3.3 Node 端脚本:从 PNG 到终端输出
下面这份是 Node 里完整可跑的版本,用pngjs读图,自己按块采样:
const fs = require('fs'); const { PNG } = require('pngjs'); const ESC = '\x1b['; const RESET = `${ESC}0m`; const UP = '\u2580'; function sampleBlock(png, x0, y0, bw, bh) { const x1 = Math.min(png.width, Math.round(x0 + bw)); const y1 = Math.min(png.height, Math.round(y0 + bh)); let r = 0, g = 0, b = 0, n = 0; for (let y = Math.round(y0); y < y1; y++) { for (let x = Math.round(x0); x < x1; x++) { const i = (y * png.width + x) * 4; const a = png.data[i + 3] / 255; r += png.data[i] * a; // 合成到黑底 g += png.data[i + 1] * a; b += png.data[i + 2] * a; n++; } } return n ? [r / n, g / n, b / n].map(Math.round) : [0, 0, 0]; } function render(file, cols = 80) { const png = PNG.sync.read(fs.readFileSync(file)); const cellW = png.width / cols; const rows = Math.max(1, Math.round(png.height / cellW / 2)); const cellH = png.height / (rows * 2); const out = []; for (let row = 0; row < rows; row++) { let line = ''; let lastTop = ''; let lastBottom = ''; for (let col = 0; col < cols; col++) { const top = sampleBlock(png, col * cellW, row * 2 * cellH, cellW, cellH); const bottom = sampleBlock(png, col * cellW, (row * 2 + 1) * cellH, cellW, cellH); const tKey = top.join(';'); const bKey = bottom.join(';'); if (tKey !== lastTop) { line += `${ESC}38;2;${tKey}m`; lastTop = tKey; } if (bKey !== lastBottom) { line += `${ESC}48;2;${bKey}m`; lastBottom = bKey; } line += UP; } line += RESET; out.push(line); } process.stdout.write(out.join('\n') + '\n'); } render(process.argv[2] || './logo.png', 80);跑起来就是node render.js ./logo.png。pngjs只负责解码,没有别的依赖,装起来很轻。
3.4 相邻像素同色时的序列省略优化
上面代码里lastTop和lastBottom那两个判断,是我后来加进去的优化,效果很显著。
原始写法是每个像素都无脑输出一次前景色和一次背景色,80 列就是 160 次颜色切换,每次大约 20 字节,一行光颜色序列就 3200 字节。但实际上图片里大片区域颜色是渐变的、相近的,甚至完全一样的。如果相邻两个像素颜色相同,就没必要再输出一次颜色序列——终端会保持上一次的颜色设置,直接打字符就行。
加上这个判断之后,我在几张测试图上量到的体积缩减大概在 30% 到 60% 之间,取决于图片的复杂程度。纯色 logo 省得最多,照片类的最少。这个优化在动态刷新场景下尤其有用,因为每帧都要重新拼字符串,省下来的都是实打实的开销。
还有一个更狠的压缩方式:如果整行的颜色差异很小,可以用 256 色模式降级输出。256 色序列是ESC[38;5;Nm,只有 8 字节左右,比真彩色的 20 字节短一大截。代价是色彩精度下降,渐变区域会出现色阶断层。我的做法是提供两个模式,日志启动画面用 256 色(反正看不了几秒),需要精细展示时用真彩色。
4. 浏览器端另一条路:用 CSS 把真实图片塞进日志
4.1 %c 占位符能做什么
浏览器控制台有个别的地方没有的能力:console.log支持%c占位符,后面跟的字符串会被当作 CSS 应用到这个占位符对应的那一段文本上。这意味着你在控制台里打印的不是纯文本,而是带样式的 DOM 片段。
console.log('%cHello', 'color: #f60; font-size: 24px; font-weight: bold;');%c可以出现多次,后面按顺序跟对应的样式字符串,实现一段话里多种颜色混排。这个能力本身是给调试用的——让你在日志里高亮关键字。但它也可以被拿来干别的事,因为它支持的 CSS 属性里包含background。
4.2 用 padding 和 background 撑出一块图片区域
核心思路是:让%c对应的那段内容是一个空字符,然后把它的font-size设成 0 让文字彻底不可见,再用padding把这个盒子撑到目标尺寸,最后用background铺上图片。
const img = 'https://example.com/logo.png'; console.log( '%c ', [ 'font-size: 0', 'line-height: 0', 'display: inline-block', 'padding: 50px 100px', `background: url("${img}") center / 100% 100% no-repeat` ].join(';') );几个参数的含义:padding: 50px 100px决定了这块区域的最终尺寸,上下各 50 像素、左右各 100 像素,合起来是 200 × 100。background里的100% 100%让图片拉伸填满整个盒子,所以盒子尺寸要尽量贴合图片本身的宽高比,不然会变形。display: inline-block是必须的,inline 元素的垂直 padding 不会撑开行盒,背景会被裁掉。
这里的数字得自己调。不同版本的 DevTools 在行高计算上有点差别,有时候图会显示得很矮,把 padding 加大一点就好。我建议先按图片的一半尺寸起步,看着不对再改。
4.3 base64 内嵌与体积的权衡
上面的写法依赖一个可访问的 URL。如果图片放在内网、需要鉴权,或者你希望这段代码拷到哪里都能跑,那就得把图片转成 base64 内嵌:
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...';好处是完全不依赖网络,坏处是字符串长度暴涨。一张 20KB 的 PNG 转成 base64 大约 27KB,直接塞进源码里会让那个文件变得非常难看,编辑器的语法高亮也会卡。我的做法是:图片小于 10KB 就内嵌,超过就放到静态资源目录里,让别人自己去配 URL。
还有一点要注意,background里的 URL 带特殊字符(比如 base64 里的+、/)时最好用引号包起来,不然 CSS 解析可能出错。
4.4 多段拼接和一图多彩的组合玩法
%c可以多段使用,这就打开了更多玩法。比如左边放一张图片、右边跟一段带样式的文字:
console.log( '%c %c 项目启动成功 ', 'font-size: 0; padding: 16px; background: url("data:image/png;base64,...") center / contain no-repeat', 'color: #4caf50; font-size: 14px; font-weight: bold' );还可以把图片切成几块,用多个%c拼成一行。比如把一张长条形 banner 切成三块,每块用不同的background-position显示。这种做法在需要给不同状态配色的时候有用——同一个图标模板,改一下滤镜或者位置就能出不同效果。
不过我不建议把这条路走得太远。控制台不是网页,DevTools 的样式渲染优先级和你预期的不一样,写太复杂很容易在某些版本上表现异常。控制在两三个%c以内,效果最可控。
5. 那些让我重跑三遍的坑:截断、错位与兼容性
5.1 字符串太长会被 DevTools 截断
这是浏览器端最容易被忽略的问题。Chrome 的 DevTools 对单条日志的字符串长度有限制,超出之后会折叠显示,末尾出现省略号。我实测下来的经验是,长度到一万字符上下就开始出现截断,具体阈值跟版本有关。
一张 100 列 × 50 行的真彩色字符画,一行大约 4000 字节,50 行就是 20 万字节,远超这个限制。所以浏览器里打印大尺寸字符画基本行不通,得拆成多条console.log一行一条。这也是为什么浏览器里的字符画一般都做得很小,或者干脆改用 CSS 图片方案。
Node 端没这个限制,process.stdout.write直接写文件描述符,多少字节都能出去。但如果你把输出重定向到文件再打开,可能会遇到编辑器打不开超大文件的问题——几十万字符的行,很多编辑器会卡死。这个坑我踩过一次,后来给脚本加了个--max-cols上限。
5.2 全角字符和表情符号让整幅画歪掉
终端里字符宽度不是都相等的。ASCII 和大部分拉丁字母占 1 个格子,中文、日文、韩文这些全角字符占 2 个格子,某些表情符号更夸张,可能占 2 格甚至更多,而且不同终端对它们的宽度定义还不统一。
对字符画来说这是致命的:只要一行里混进一个宽度算错的字符,整行的对齐就废了,右侧所有内容全部错位,纵向的直线全部变成斜线。所以字符画的字符集里绝对不能混进全角字符,也不能用表情符号当像素。
顺带说一句,U+2580 这类块元素字符属于半角范畴,宽度是 1,这是它能用来做像素的前提。换成█、▌、░这些也都一样安全,它们同属 Block Elements 区块。
5.3 终端折行:列数必须小于 stdout.columns
这个坑很隐蔽。如果你的输出宽度超过终端实际列数,终端会自动折行。表面上看每个字符都打出来了,但一行被拆成两行显示,图片的宽高比就完全变了,整张图被纵向拉长一倍。
解决办法是动态获取终端宽度,然后按columns - 1来设定输出列数:
const cols = Math.min(process.stdout.columns || 80, 100) - 1;留一列余量是因为很多终端在写满最后一列时也会触发折行,边界情况很烦人。如果你希望输出的图宽度固定(比如存起来当资源),那就得保证所有目标终端的列数都够用,一般取 80 是最保守的选择。
5.4 重定向和 CI 环境下的转义符污染
只要输出被重定向到文件或者管道,process.stdout.isTTY就会变成undefined。这个时候再往里面写 ANSI 序列,结果就是文件里躺着一堆^[[38;2;...这样的乱码,日志系统里全是噪音。
我的处理方式是在入口处做一次判断:
function supportsColor() { if (process.env.NO_COLOR) return false; if (process.env.FORCE_COLOR) return true; return Boolean(process.stdout.isTTY); }不支持颜色的时候就降级到纯灰度字符画,或者干脆换成一行简单的文字提示。CI 日志里堆一堆方块字符也没人看,还不如清爽一点。NO_COLOR和FORCE_COLOR这两个环境变量是社区约定,很多命令行工具都认,跟着用能省不少事。
还有一个 Windows 特有的问题:老版 conhost 对块元素字符的渲染可能出问题,显示成方框或者问号。现代 Windows Terminal 和新版 conhost 都没这毛病,正常情况下不用管。如果确实遇到了,检查一下终端的字体是不是等宽字体,有些非等宽字体确实没有 Block Elements 的字形。
6. 让图案动起来:刷新方式与性能取舍
6.1 光标归位覆盖与清屏重绘
静态图看腻了就想动起来。终端的动态刷新有两条路。
第一条是清屏重绘:每次打印前先输出ESC[2J清空整个屏幕,再把光标移到左上角ESC[H,然后打印整帧。简单粗暴,但整屏清空会导致明显闪烁,尤其在刷新率高的场景下,视觉体验很差。
第二条是光标归位覆盖:不清屏,只把光标移回起始位置,然后用新的内容逐行覆盖旧内容。因为每行长度一致,覆盖之后看起来就是画面在原地变化,没有闪烁。实现方式有两种,一种是靠 readline 模块:
const readline = require('readline'); function draw(frameLines) { readline.cursorTo(process.stdout, 0, 0); process.stdout.write(frameLines.join('\n')); readline.clearScreenDown(process.stdout); }另一种是直接输出上移序列ESC[${rows}A,把光标往上挪 rows 行,再重新打印。两种效果差不多,readline 可读性好一些,手写序列的可控性强一些。我一般用后者,因为在某些终端里 readline 的光标操作会被缓冲影响。
6.2 帧率、字符串拼接开销与预计算
动态刷新的性能瓶颈几乎全在字符串拼接上。以 80 列 × 40 行、每行 3KB 算,一帧大约 120KB 的字符串。如果每秒刷 30 帧,那就是每秒 3.6MB 的字符串生成量,V8 在短时间内能做出来,但垃圾回收压力明显,长时间跑会卡顿。
优化方向有几个。首先是把每帧的字符串拼接从+=换成数组push加join,虽然现代 V8 对字符串拼接有优化,但在超长字符串反复拼接的场景下,数组方式的表现更可控,不会出现那种中后期越来越慢的情况。
其次是预计算。如果图案的颜色是固定的,只是做整体位移或者亮度变化,那完全可以先把每一行的字符串缓存下来,每帧只做拼接不做计算。亮度变化的话可以预先算好几套不同亮度的版本,运行时按帧索引取,这样每帧的成本就只剩一次数组 join。
还有一点是不要无脑追求高帧率。终端刷新本身就有开销,超过 15 帧每秒肉眼就看不出差别了,反而白白浪费 CPU。我的默认值是 15 帧,需要更流畅的滚动效果时才提到 30。
6.3 动态效果适合放在哪些场景
说实话,终端里跑动画的实际用途不多,但有几个场景效果挺好。一个是启动等待动画,程序在长时间加载时打一个循环的进度方块,比单调的省略号友好一些。另一个是状态指示,比如轮询任务时用一个旋转的指示器。
还有一类是纯粹的展示效果,比如在开发服务器启动时打一行彩色的项目名,带一点渐变动画,打开终端的瞬间能看到。这种就是图一乐,但确实能让开发体验好一点。
需要注意的是,如果你输出的东西会被重定向、被日志系统采集、或者被别的程序解析,那千万别加动画。转义序列在文件里就是垃圾字符,会把日志搜索和解析都搞坏。凡是涉及动态刷新的输出,都应该用isTTY严格把关。
7. 工程化落地:字符画该存哪里、怎么用
7.1 预生成静态资源还是运行时计算
刚开始做的时候我是在运行时读图算字符画的,跑几次之后就改了。原因很简单:运行时计算意味着每次启动都要读文件、解码、遍历像素、拼字符串,一张中等尺寸的图能花掉几十毫秒甚至上百毫秒,而且这段时间里程序什么都干不了。
后来改成预生成。写一个小脚本,把图片转成字符画字符串,输出成一个.js文件导出字符串常量,或者输出成.txt在运行时读进来。启动的时候只需要require或者readFileSync,几乎没有开销。
预生成的另一个好处是可控。生成的时候你就能看到最终的字符数和字节数,可以针对性的调整尺寸,避免运行时才发现日志文件被撑爆。我一般会在生成脚本里加一个体积统计,超过 200KB 就警告。
// 生成脚本里的体积检查 const bytes = Buffer.byteLength(output, 'utf8'); if (bytes > 200 * 1024) { console.warn(`输出体积 ${(bytes / 1024).toFixed(1)}KB,建议减小列数或改用 256 色`); }7.2 启动日志、构建提示与终端工具的常见用法
字符画最主流的用法是启动横幅。开发服务器、CLI 工具、构建脚本启动时打一个项目 logo,加一两行版本信息和端口号。这类场景的特点是只打一次,尺寸不宜大,50 到 70 列比较合适,太大了在笔记本的小终端窗口里会折行。
另一类用法是错误提示的视觉强化。比如某些工具在检测到配置错误时,会打一个醒目的符号或者警告图案。这个场景更要注意兼容性,因为错误信息很可能被重定向到日志文件,务必做好isTTY判断。
还有一类是控制台彩蛋,主要在浏览器端。打开 DevTools 看到一段带样式的话或者一个小图标,是不少团队喜欢做的小细节。这类实现建议做得轻量,用 CSS 图片方案或者几行纯文本配色就够了,不要塞大尺寸字符画,会被截断。
7.3 我自己总结的几条取舍经验
第一,尺寸永远优先于精度。一张 80 列的图,即使颜色是 256 色而非真彩色,看起来也比 160 列的真彩色图更好——因为它不会折行,不会超宽,看起来干净。宁可牺牲颜色精度,不要牺牲尺寸适配。
第二,永远留一个"关闭开关"。字符画输出应该能通过环境变量或者命令行参数关掉,因为总有人在不适合的终端里跑你的工具,也总有人觉得这东西碍眼。
第三,别为了炫技牺牲可读性。字符画和真正的日志信息应该分开,横幅打完空一行再接日志。混在一起的话,日志搜索工具会很难受。
第四,测试的时候至少覆盖三种终端:Windows Terminal、macOS 默认终端、以及一个支持 256 色但不支持真彩色的环境。这三种场景覆盖了绝大多数实际问题。
第五,预生成文件里加一行注释标明来源图片和生成参数,半年后回来改的时候你会感谢自己。我自己就遇到过不记得那张图是用多少列生成的,只能重新跑一遍脚本对比。
最后分享一个我常用的小技巧:如果只是想快速给启动日志加点颜色,又不想搞字符画这一整套,那用%c或 ANSI 给几个关键字上色就够了,几行代码,零依赖,效果立竿见影。真正值得做字符画的场景,其实是那些用户每天都会看很多次的工具——把启动横幅做好,日积月累能省下一点心情。