实现一个更快的终端彩色文本格式化工具库

1,814 阅读6分钟

终端中打印的五颜六色的彩色文本,你知道是怎么实现的吗,你都知道或用过哪些相关的工具库呢?来一起了解一下吧!

在前端项目开发中,说到控制台终端彩色文本格式化,你可能会想到 chalkpicocolorsansi-colors 甚至是 colors.js 等流行工具库。它们在 npmjs 上的周下载量都分别长期保持在千万到亿级别。彩色文本格式化是 CLI 类前端开发工具中必备的基础功能需求,然而其实现方式其实是非常简单的。

作为前端开发工具链中不可或缺的基础模块,此类工具库的实现原理与性能一直受到开发者社区的高度关注。如早期高度流行的 colors.js 多年来仍一直被广泛应用,但不少人对其扩展 String  原型链的实现方式颇有微词。后来居上的 chalk 库,也会因为其间接依赖稍多、复杂度稍高而时常受到吐槽,于是接着出现了以超轻量、无依赖、跑分高著称的 ansi-colorspicocolorskleur 等同类库。以至于 chalk 5 甚至将分离出去的两个功能相对独立的依赖库又进行了内置以实现零依赖、对主要功能实现逻辑重构以取得更高的跑分。

可以看到,作为基础类核心工具库,其在实现方式、执行性能等方面,如同安卓手机界堆硬件一般的内卷。基础工具卷实现与性能,受益的是所有开发者。换个角度来说,实现同样的能力CPU转的少了一些,在普遍应用海量累加的结果下,或许也能间接的节省不少电能消耗呢。

扯远了。铺垫了这么多,只是为了下面要介绍的内容和更高的跑分做个背景预热。

1 字符串拼接实现终端打印彩色文本样式

几年前在开发一个微型 CLI 工具时,希望打印彩色的字符串,但又觉得仅打印一两行信息,不值得多引入一个依赖库(甚至间接依赖近N个库)。

当时这方面流行的开源库主要有 colors.jschalk。在翻了一下 colors.js 的源码后,发现终端彩色字符的格式化实际上非常简单,简单的按字符串规则拼接即可。参考如下示例:

console.log(`\x1B[31m我有低保,交朋友吗?\x1B[39m`);
console.log(`\x1B[32m绿码,放心通过!\x1B[39m`);
console.log(`\x1B[32m\x1B[4m星星睡不着的时候,是不是也在数人类。\x1B[24m\x1B[39m`);

字符串拼接的方式很简洁的解决了当时的需求,令我很满意。如果你在开发的 CLI 类工具,此类需求简单、且希望拥有较好的启动加载速度,也可以选择这么做。

如果你想了解终端中颜色控制指令相关的内容,可参考阅读:终端中的彩色文本控制指令的格式构成简介

2 clc: 设计一个极简的自用工具库

后来我又涉及一些其它 CLI 工具的开发,对控制台打印彩色文本的使用逐渐增多。

字符串拼接方式简单、零依赖,可以获得较好的启动加载性能,但当高频重度应用时则显得过于繁琐。于是参考 colors.jsstyles 列表 写了一个极简单的工具方法,大致如下:

var isDisabled = false;
// 定义 ansi-colors 支持的颜色列表
var colorList = {
  // color
  black: [30, 39],
  red: [31, 39],
  green: [32, 39],
  yellow: [33, 39],
  blue: [34, 39],
  magenta: [35, 39],
  cyan: [36, 39],
  white: [37, 39],
  gray: [90, 39],
  // more...
};

function enable() { isDisabled = false; }
function disable() { isDisabled = true; }

// 定义一个 color 格式化方法
function color(str, colorType) {
  if (str === '' || str == null) return '';
  if (isDisabled) return String(str);

  var typecfg = colorList[colorType];
  return typecfg ? '\x1b[' + typecfg[0] + 'm' + str + '\x1b[' + typecfg[1] + 'm' : str;
}

// 使用 color 方法
console.log(color('ERROR', 'red'), `接口[${color('/xxx', 'cyan')}]调用异常!`);

可以看到,其在实现与使用上都是超级简单的。在后续高频使用过程中,发现调用 color 方法的方式也稍显繁琐,于是又继续做了一些扩展:

function log(str, colorType) {
  console.log(color(str, colorType));
}

Object.keys(colorList).forEach(function (key) {
  color[key] = function (str) { return color(str, key); };

  log[key] = function () {
    var arr = [];
    for (var i = 0; i < arguments.length; i++) arr.push(String(arguments[i]));
    console.log(color(arr.join(' '), key));
  };
});

module.export = { log, color };

上面的代码中新增了 log 方法用于直接打印彩色文字,并将所有色彩类型扩展至 colorlog 函数的属性中。这样就可以实现简化的用法了。

由于在多个不同的项目中都会用到它,在来回复制了几次后,就决定将其发布到 npm 上。于是就有了 console-log-color 这个包。

从名称上就可以看出,我最开始的目的是想将高频调用的 console.log 给一起简化一下。

console-log-colors 工具包使用示例:

import { log, color } from 'console-log-colors';

const { green, magenta, underline, cyan, cyanBright } = color;

log.red('我有故人抱剑去,斩尽春风未曾归。');
log.green('别人笑我穿的厚,', underline('我笑他人冻的透'));
console.log(magenta('我觉得我好花心,你每天的样子我都好喜欢!'));
console.log(green('小时候哭着哭着就笑了,'), green(underline('长大后笑着笑着就哭了。')));
console.log(cyan('那天她夸我很会笑,'), bold(cyanBright('那十秒灵魂大概已卖掉。')));

它十分简洁,用法也够简单,没有 color.js 扩展 String 原型链的包袱,也不像 chalk 那样过于复杂。clc 基本可以满足我近几年来所有的 CLI 工具相关的开发需求。

后续不断的关注到 colorettepicocolorskleur 等库的出现,每一个新出来时,都会声称其比 chalk 快了 XYZ 倍。但 clc 已经够我用了,除了声称的性能优势,它们都并没有什么新的东西。

3 clc 重构:极致的性能优化、支持嵌套调用、链式调用

直到最近在某 CLI 框架及工具开发中遇到了嵌套调用样式异常的问题,在问题分析的过程中注意到了 kleur 文档中的跑分对比,一时心血来潮,参考其逻辑对 console-log-colors 也做了一下跑分,但发现结果比较一般,这让我起了兴趣,毕竟 color(str, colorType) 的实现已经够简单了。于是从简洁和高性能实现的角度做了进一步的详细对比分析与改进,便有了下面的内容。

3.1 性能优化:为什么 picocolors 足够快?

后来居上的 picocolorskleur 都以实现简洁性能高博得了大量关注。如 vue-cli 中采用的是 chalk(当然也由于 webpack 生态工具链对chalk高度的依赖,这是最好的选择),而 vite 生态工具链中则普遍采用的是 picocolors

为什么 picocolors 足够快?经查阅分析其源码,大致认为有如下几点原因:

  • 放弃支持链式调用:它确实太慢了
  • 够用就好:仅支持主要安全色,全部源码仅五十多行
  • 面向未来的 ES 语法,不考虑向前兼容
  • 极致的预处理(createColors方法):初始化时生成所有颜色的格式化方法,尽可能的避免不必要的计算
// picocolors 的 createColors 方法示例:
createColors = (enabled = isColorSupported) => {{
  red: enabled ? formatter("\x1b[31m", "\x1b[39m") : String
  // more...
});

那么,在保持接口逻辑不变的前提下,可以怎样对 clc 做性能上的改进与扩展呢?参考 pococolors / kleur / colorette 等的逻辑实现,我总结了如下几个要点:

  • 尽可能的减少动态逻辑判断、函数生成等
  • 变量缓存,避免对对象属性的高频读取
  • 预处理生成终态最简函数

用一句话来说就是:

如果一个状态的值在特定条件下是保持不变的,那么就不必在每次函数调用时去判断它

基于这几点对 color(str, colorType) 方法作了逻辑优化,最终实现参考如下:

function getFn(colorType) {
  var cfg = colorList[colorType];

  // 禁用模式下,直接返回极简的函数
  if (!cfg || !isSupported) return function (str) { return String(str) };

  var open = cfg[0], close = cfg[1];

  // 利用闭包缓存局部变量,返回一个极简的方法
  return function m(str) {
    if (str === '' || str == null) return '';
    return open + str + close;
  }
}

// init
Object.keys(colorList).forEach(function (key) { clc[key] = color[key] = getFn(key));

可以看到,如此处理后,当调用一个如 color.red 这样的方法时,实际上只执行了两行语句,其性能可想而知。这一点后面会有具体的数据对比。

3.2 支持嵌套调用

样式嵌套指的是对于一段复杂文本作色彩样式处理的同时,还对其子串作多次其他色彩样式处理。示例:

function fixture(color) {
  return color.red(`a red ${color.green("green")} red ${color.red("red")} red`);
}

fixtrue 方法展示了一段文本中,red 样式中包含了 greenred 子文本样式,这里的子串 greenred 处就发生了嵌套。

由于 greenred 的结束字符串相同,外层的 red 实际上在遇到 green 的结尾字符时就也会结束。这就导致了后续本该显示为红色的文本全部都显示为了错误的默认色。

解决方案也比较简单:

  • 在执行字符串与颜色指令字符(open和close)拼接之前做预处理,查找同类型的闭合字符(close),在其后插入起始字符(open)
  • 取出插入 open 之后的字符串,作递归查找替换处理
  • 返回处理后的字符串再作拼接

支持嵌套样式的逻辑实现示例:

function red(str) {
  const open = '\x1B[31m'; // red open
  const close = '\x1B[39m'; // red close
  // 递归方式查找相同的 close 字符,如果找到则在其后添加 open 字符
  const index = str.indexOf(redClose, open.length);
  if (index > -1) str = replaceClose(str, open, close, index);
  return open + str + end;
}

// 递归查找与替换示例
function replaceClose(str, open, close, idx) {
  // close 之后的位置插入 open
  const start = str.substring(0, idx) + open;
  // 取出 close 之后的字符串
  let rest = str.substring(idx + close.length);

  // 余下的字符串继续作递归查找与替换
  const nextIdx = rest.indexOf(close);
  if (nextIdx > -1) rest = replaceClose(rest, open, close, nextIdx);

  // 最后拼接返回
  return start + rest;
}

以前面 fixture 方法中的样式嵌套示例为例,经过兼容替换处理后,最终的输出字符会多加上两个 close 结束字符:

// 兼容处理前 - 存在嵌套调用样式与预期不一致问题
// \x1B[31ma red \x1B[32mgreen\x1B[39m         red \x1B[31mred\x1B[39m         red\x1B[39m
// 兼容处理后 - 效果与预期一致
// \x1B[31ma red \x1B[32mgreen\x1B[39m\x1B[31m red \x1B[31mred\x1B[39m\x1B[31m red\x1B[39m

从下图中的执行结果对比可以看出其差别:

3.3 支持链式调用:ProxyObject.definePropertyReflect.defineProperty

链式调用的方式是这样的:

import { red } from 'console-log-colors';

const styled = red.bgWhiteBright.bold.underline('red bold underline');
console.log(styled);

根据其用法可以很容易的联想到 Object getter 特性。例如采用 Object.definePropertycolor 方法实现链式调用:

function extend(fn, useGetter) {
  Object.keys(colorList).forEach(function (key) {
    if (useGetter) {
      Object.defineProperty(fn, key, {
        get() { return extend(function m(s) { return fn(color[key](s)) }, true) }
      });
    } else if (!fn[key]) {
      fn[key] = extend(function m(s) { return fn(color[key](s)) }, true);
    }
  });
  return fn;
}

// init
Object.keys(colorList).forEach(function (key) { color[key] = extend(getFn(key), false) });

从上面的示例中可以看到,通过定义 get 方法的方式,即简单的实现了无限层级链式调用支持。

但是也可以看到,由于每一次的 get 读取都是动态生成的,而且是对 colorList 遍历生成下一层级的所有方法,故其性能不会太高。实际上,链式层级越深则性能越差,其跑分结果也是惨不忍睹的。下面是以上逻辑实现的一组跑分结果对比:

# All Colors
Proxy:                   x 455,528 ops/sec ±0.87% (90 runs sampled)
Object.defineProperty:   x 1,548,299 ops/sec ±0.74% (88 runs sampled)
Reflect.defineProperty:  x 1,719,196 ops/sec ±0.78% (88 runs sampled)

# Chained colors
Proxy:                   x 45,787 ops/sec ±0.76% (84 runs sampled)
Object.defineProperty:   x 492 ops/sec ±2.20% (78 runs sampled)
Reflect.defineProperty:  x 517 ops/sec ±2.00% (78 runs sampled)

如果每次生成的结果都能够缓存起来,那就可以在简化初始化复杂度的同时支持动态扩展。对于这种场合,上 Proxy 再合适不过了。示例:

function extend(fn) {
  return new Proxy(fn, {
    get(target, key) {
      if (!target[key] && (key in colorList)) {
        // 在对象属性上缓存起来
        target[key] = extend(function p(s) { return fn(color[key](s)) });
      }

      return target[key];
    }
  });
}

// init
Object.keys(colorList).forEach(function (key) { color[key] = extend(getFn(key)) });

基于 Proxy 的特性用少量的代码即实现了符合预期的逻辑:仅在发生属性的 get 调用时,才动态生成需被调用的方法,并在链式上一层级函数对象的属性上缓存起来。

如果将 Object getter 的动态生成结果缓存起来,那么也可以实现相同的效果,并可以支持到 ES5 的运行环境。将前面的代码稍微改进一下:

var fncache = {};
function extend(fn, keys) {
  var prefix = keys.join('');

  Object.keys(colorList).forEach(function (key) {
      var cachekey = prefix + key;

      Object.defineProperty(fn, key, {
        get() {
          if (!fncache[cachekey]) {
            fncache[cachekey] = extend(function m(s) { return fn(color[key](s)) }, keys.concat(key));
          }

          return fncache[cachekey];
         }
      });
  });
  return fn;
}

// init
Object.keys(colorList).forEach(function (key) { color[key] = extend(getFn(key), [key]) });

基于 Proxy 和基于 Object getter 的方案在逻辑上基本一致,但在对比测试中发现,对 Proxy 对象的属性访问方式,性能上比 Object getter 要差了近两倍。

另外,在 ES6 中新增的 Reflect.definePropertyObject.defineProperty 语法与功能基本一致,经过多次的反复测试对比,看上去Reflect.defineProperty 的“跑分”更高一些。

get() 性能:Proxy < Object.defineProperty < Reflect.defineProperty

基于上述的测试结果考虑,最终选择结合 ReflectObject 实现链式调用的具体逻辑。于是最终的实现逻辑大致如下:

// 完整实现可参考: https://github.com/lzwme/console-log-colors/blob/master/src/index.js#L66
var TObject = typeof Reflect === 'undefined' ? Object : Reflect;
var fncache = {};

function extend(fn, keys, useGetter) {
  // 链式调用二级以上使用 Proxy 的方案。但实测跑分结果相当,最终决定不使用 Proxy
  // if (useGetter && typeof Proxy !== 'undefined') {
  //   return new Proxy(fn, {
  //     get(target, key) {
  //       if (!target[key] && (key in colorList)) {
  //         target[key] = extend(function p(s) { return fn(color[key](s)) }, true);
  //       }
  //       return target[key];
  //     }
  //   });
  // }
  var prefix = keys.join('');

  Object.keys(colorList).forEach(function (key) {
      var cachekey = prefix + key;

      TObject.defineProperty(fn, key, {
        get() {
          if (!fncache[cachekey]) {
            fncache[cachekey] = extend(function m(s) { return fn(color[key](s)) }, keys.concat(key));
          }
          return fncache[cachekey];
         }
      });
  });

  return fn;
}

// init
Object.keys(colorList).forEach(function (key) { clc[key] = color[key] = extend(getFn(key), [key], false) });

3.4 支持浏览器控制台

前端开发的同学应该都知道,Chrome 浏览器控制台 console.log 支持 CSS 语法的彩色文本打印。如:

console.log('%cHello guys!', 'color:#3f9');

从 Chrome 69 开始,其 Console 控制台也开始支持 ansi color styles。故仅需对少许逻辑作简单得浏览器环境兼容,即可直接在浏览器中使用。

试试打开 Chrome Console 控制台并执行如下代码:

  // <script src=​"https:​/​/​cdn.jsdelivr.net/​npm/​console-log-colors@latest/​src/​index.js">​</script>​
var s = document.createElement('script');
s.src = "https://cdn.jsdelivr.net/npm/console-log-colors@latest/src/index.js";
s.onload = () => {
    console.log(clc);
    console.log(`${clc.cyan('[INFO]')} ${clc.yellow('Hello guys!')}`);
}
document.body.appendChild(s);

经过实际测试,简单的文本着色是符合预期的,但在嵌套使用时会有一些差异,主要表现在结尾符号指令方面。 另外,其对 ANSI colors 24位 RGB 格式的语法支持的更好。例如你可以直接在 Chrome Console 面板中执行如下示例以体验:

console.log('\x1b[38;2;25;154;48m 透过你的眼 \x1b[38;2;255;154;48m离别也是诗 \x1b[0m');
console.log('\x1b[48;2;48;154;255m \x1b[38;2;255;255;255m 偶尔还是需要出去走一走,才知道躺在床上多么舒服 \x1b[0m');

3.5 strip: ANSI colors 字符移除

当需要对 congsole.log 打印的日志信息同时落地至文件时,则需要在写入文件前将ansi color styles渲染相关的字符全部移除掉,否则打开日志文件,其内容会像火星文一样。

基于ANSI escapes code 的构成规则,可以写一个正则表达式实现文本替换过滤。示例:

// 对 3/4 位颜色表示法格式的字符指令全局替换移除
function strip(str) { return str.replace(/\x1b\[\d+m/gm, '') }

4 跑个分吧:极致优化后的性能对比

实话说,这个跑分结果出来是非常出乎意料的,因为它太高了,导致我反复做了许多次重试与对比才确认。

下面是在一台普通 Macbook Air 上的一次执行结果参考:

# All Colors
+ console-log-colors x 1,773,905 ops/sec ±0.65% (91 runs sampled)
  ansi-colors        x 153,603 ops/sec ±0.92% (93 runs sampled)
  cli-color          x 35,879 ops/sec ±0.71% (94 runs sampled)
  picocolors         x 692,485 ops/sec ±0.92% (91 runs sampled)
  colorette          x 215,972 ops/sec ±0.92% (89 runs sampled)
  chalk              x 361,315 ops/sec ±3.65% (90 runs sampled))
  kleur              x 485,399 ops/sec ±0.80% (93 runs sampled)

# Chained colors
+ console-log-colors x 60,202 ops/sec ±0.79% (94 runs sampled)
  ansi-colors        x 14,001 ops/sec ±7.89% (83 runs sampled))
  cli-color          x 14,339 ops/sec ±0.89% (90 runs sampled)
  chalk              x 179,431 ops/sec ±0.97% (90 runs sampled)
  kleur              x 62,768 ops/sec ±0.88% (91 runs sampled)

# Nested colors
+ console-log-colors x 101,060 ops/sec ±0.93% (89 runs sampled)
  ansi-colors        x 34,329 ops/sec ±11.88% (73 runs sampled)
  cli-color          x 12,013 ops/sec ±1.58% (89 runs sampled)
  picocolors         x 100,250 ops/sec ±0.71% (93 runs sampled)
  colorette          x 93,476 ops/sec ±0.94% (89 runs sampled))
  chalk              x 71,938 ops/sec ±1.02% (90 runs sampled)
  kleur              x 87,196 ops/sec ±0.80% (91 runs sampled)

若有兴趣,可以拉取项目代码(console-log-colors)后执行命令 npm run benchmark 亲自试一试。

5 扩展参考

本文正在参加「金石计划 . 瓜分6万现金大奖」