控制台的进度条是如何实现的

我们在使用npm-check-updates,或者pkg-fetch的时候,会发现在loading的状态下控制台会出现一个进度条。

这个是通过progress这个npm包来实现的,很多知名的库(ant-designsentry-cli@electron/get等)使用它来实现进度条效果。

更重要的是它零依赖。

让我们来看看它是如何实现这个进度条的。

node-progress

虽然它的包名叫做progress,但是在github上它的仓库叫做node-progress,我们以仓库名字作为唯一标识,来跟其他的进度条实现进行区分。

按照README的描述,它的基本使用方式如下。

js 复制代码
const ProgressBar = require('progress');

const bar = new ProgressBar(':bar', { total: 10 });
const timer = setInterval(function () {
  bar.tick();
  if (bar.complete) {
    console.log('\ncomplete\n');
    clearInterval(timer);
  }
}, 100);

它可以通过new ProgressBar来创建一个实例,ProgressBar构造函数接收了两个参数:格式字符串、配置项。

通过tick来更新进度条,并且可以通过complete属性来检查进度条是否结束。

看起来比较简单,为了更直观一些,我们看一下它的源码。

构造函数

js 复制代码
function ProgressBar(fmt, options) {
  this.stream = options.stream || process.stderr; // 设置进度条输出流,默认为 stderr

  if (typeof(options) == 'number') { // 检查 options 是否为数字类型
    var total = options;
    options = {};
    options.total = total;
  } else {
    options = options || {}; // 如果 options 不存在,则设置为空对象
    if ('string' != typeof fmt) throw new Error('format required'); // 如果 fmt 不是字符串类型,则抛出错误
    if ('number' != typeof options.total) throw new Error('total required'); // 如果 options.total 不是数字类型,则抛出错误
  }

  // 设置进度条的格式、当前进度、总进度、宽度等属性
  this.fmt = fmt; // 进度条格式
  this.curr = options.curr || 0; // 当前进度,默认为 0
  this.total = options.total; // 总进度
  this.width = options.width || this.total; // 进度条的宽度,默认为总进度
  this.clear = options.clear // 是否清空进度条
  this.chars = {
    complete   : options.complete || '=', // 完成时的字符,默认为 '='
    incomplete : options.incomplete || '-', // 未完成时的字符,默认为 '-'
    head       : options.head || (options.complete || '=') // 进度条头部字符,默认与完成字符相同
  };
  // 控制渲染频率,设置最后一次渲染的时间、回调函数、标记等
  this.renderThrottle = options.renderThrottle !== 0 ? (options.renderThrottle || 16) : 0;
  this.lastRender = -Infinity;
  this.callback = options.callback || function () {};
  this.tokens = {}; // 用于存储标记的对象
  this.lastDraw = ''; // 上一次渲染的字符串
}

我们可以看到,构造函数的开头就设定了输出流,可以由选项指定,或者采用默认的process.stderr。也就是node:tty模块,我们在下一节详细了解。

如果options是一个数字,那么默认为total

在这里我们可以看到,入参校验是比较简陋的,只在options非数字的情况下校验了fmt是否是字符串,如果options是数字,fmt是逻辑上是不设防的。

之后进行options取值,并使用了默认值兜底。从这里我们可以看出,只有fmttotal是属于必填项。

以下是针对options的配置说明:

  • curr:当前进度
  • total:完成任务所需的总步数
  • width:进度条在界面上显示的宽度,默认等同于 total
  • stream:输出流,默认为 process.stderr
  • head:进度条头部的字符,默认与完成字符相同
  • complete:完成部分所用的字符,默认为 "="
  • incomplete:未完成部分所用的字符,默认为 "-"
  • renderThrottle:渲染间隔时间的最小值(以毫秒为单位),默认为 16
  • clear:完成时是否清除进度条,默认为 false
  • callback:进度条完成时的可选回调函数

这些属性用于控制进度条的格式、当前进度、总进度和显示样式。

现在初始化完成,我们看一下更新逻辑,也就是tick是如何工作的。

tick

js 复制代码
ProgressBar.prototype.tick = function(len, tokens){
  if (len !== 0)
    len = len || 1; // 如果长度不为零,则默认为 1

  // 交换tokens
  if ('object' == typeof len) tokens = len, len = 1;
  if (tokens) this.tokens = tokens; // 设置tokens

  // 开始时间,用于计算预估剩余时间
  if (0 == this.curr) this.start = new Date;

  this.curr += len; // 增加当前进度

  // 尝试渲染
  this.render(); // 尝试渲染进度条

  // 进度完成
  if (this.curr >= this.total) {
    this.render(undefined, true); // 渲染最终状态
    this.complete = true; // 设置完成标志
    this.terminate(); // 终止进度条显示
    this.callback(this); // 调用回调函数
    return;
  }
};

这段代码定义了 tick 方法。它用于增加进度。我们从源码可以看到,tick也是有入参的。

它接受两个参数 lentokens。如果 len 不是 0,则默认为 1。也就是说,如果不传len,那么默认就是 1 。

如果 len 是一个对象类型,说明它是一个 tokens,那么会将它赋值给tokens入参,并将 len 设置为 1。

然后根据 tokens 更新 this.tokens

如果当前进度是开始阶段,那么就记录起始时间 start,然后使用len增加当前进度 this.curr,并尝试渲染进度条。

如果当前进度达到或超过总进度 this.total,则渲染最终状态的进度条,并执行一些完成时的操作,比如终止进度条并调用回调函数。

从上面的逻辑,我们可以看到,它的渲染逻辑是放在render里面的,并且如果进度完成,那么会执行一次特殊的渲染,并进行一系列收尾工作。比如将complete设置为true,执行callback等。我们在示例里面看到的complete变化,实际上就是在tick函数中改变的。

那么,这里就引出了两个问题:

  • 什么是tokens?
  • render是如何渲染出进度条的?

我们这些问题,都可以在render方法中得到解答。

render

render方法略长,我们按照逻辑分两段来解析。

数据装填

js 复制代码
ProgressBar.prototype.render = function (tokens, force) {
  force = force !== undefined ? force : false; // 是否强制渲染

  if (tokens) this.tokens = tokens; // 设置标记

  if (!this.stream.isTTY) return;

  var now = Date.now(); // 获取当前时间
  var delta = now - this.lastRender; // 时间差
  if (!force && (delta < this.renderThrottle)) { // 判断是否需要渲染
    return;
  } else {
    this.lastRender = now;
  }

  var ratio = this.curr / this.total; // 计算进度比例
  ratio = Math.min(Math.max(ratio, 0), 1); // 确保比例在 0 到 1 之间

  var percent = Math.floor(ratio * 100); // 计算百分比
  var incomplete, complete, completeLength; // 未完成、已完成和完成长度
  var elapsed = new Date - this.start; // 经过时间
  var eta = (percent == 100) ? 0 : elapsed * (this.total / this.curr - 1); // 预计剩余时间
  var rate = this.curr / (elapsed / 1000); // 计算速率

  /* 用百分比和时间戳填充进度条模板 */
  var str = this.fmt
    .replace(':current', this.curr) // 当前进度
    .replace(':total', this.total) // 总进度
    .replace(':elapsed', isNaN(elapsed) ? '0.0' : (elapsed / 1000).toFixed(1)) // 经过时间
    .replace(':eta', (isNaN(eta) || !isFinite(eta)) ? '0.0' : (eta / 1000).toFixed(1)) // 预计剩余时间
    .replace(':percent', percent.toFixed(0) + '%') // 百分比
    .replace(':rate', Math.round(rate)); // 速率
}

我们发现,他依然接受两个入参,一个是我们上文得出来的tokens,另一个是force,代表是否强制渲染,默认是false

如果传入tokens,那么就会覆盖this.tokens。那么这里就有一个问题,根据前文,我们知道tick会调用renderthis.tokens已经被tick赋值了一次,为什么render还要赋值一次。

这是一个历史遗留问题,因为在某个版本之前,的确是使用render来赋值tokens。但是为了性能优化,避免多次重复渲染,增加了渲染间隔,在这个提交,把this.tokens放到tick中执行,但实际render可以赋值this.tokens并没有去掉。

因此,我们可以看到,如果渲染时间小于renderThrottle,是不进行渲染的,renderThrottle在前面提过,可以在构造函数初始化的时候通过配置传入,默认16ms。

当然,如果forcetrue,那么就不必遵循这个性能优化,也可以进行渲染。在tick中,执行this.curr >= this.total的逻辑时候,调用renderforce就是true。因为这个tick是非常重要的,关系到进度条是否显示完成样式(比如percent100%,因此不能被renderThrottle优化掉),因此需要重新调用一次render,确保最终状态的进度条被完整地显示。

然后,进行数据计算,包括当前进度、总进度、经过时间、预计剩余时间、百分比、速率等。

接着,进行数据填充,替换字符串中的标记,例如 :current:total 等,替换成上面数据计算出来的结果。

这个实际就是官方内置的token

  • :bar:进度条本身
  • :current:当前的进度数
  • :total:总进度数
  • :elapsed:已经经过的时间(秒)
  • :percent:完成的百分比
  • :eta:预计剩余时间
  • :rate:每秒钟的完成进度数

我们可以把他们放置在字符串任意位置,render会使用replace在渲染前,把他们替换成具体的数据。

但这还没结束,因为这只填充了具体数据,而字符串中的:bar还没有被填充。

他们与渲染有关。

进度渲染

我们接着看render剩下的逻辑。

js 复制代码
ProgressBar.prototype.render = function (tokens, force) {
  // 略
  // 计算进度条可用空间
  var availableSpace = Math.max(0, this.stream.columns - str.replace(':bar', '').length);
if(availableSpace && process.platform === 'win32'){
    availableSpace = availableSpace - 1; // 在 Windows 下减 1
  }

  var width = Math.min(this.width, availableSpace); // 计算宽度

 //  以下假设用户只有一个 ':bar' 标记
  completeLength = Math.round(width * ratio); // 计算完成长度
  complete = Array(Math.max(0, completeLength + 1)).join(this.chars.complete); // 已完成部分
  incomplete = Array(Math.max(0, width - completeLength + 1)).join(this.chars.incomplete); // 未完成部分

  // 添加头部到已完成字符串
  if(completeLength > 0)
    complete = complete.slice(0, -1) + this.chars.head;

  // 填充实际进度条
  str = str.replace(':bar', complete + incomplete);

  // 替换额外的token
  if (this.tokens) for (var key in this.tokens) str = str.replace(':' + key, this.tokens[key]);
  if (this.lastDraw !== str) {
    this.stream.cursorTo(0); // 光标移到行首
    this.stream.write(str); // 输出进度条
    this.stream.clearLine(1); // 清除行
    this.lastDraw = str; // 记录最后输出
  }

我们注意到,代码使用的replace而非replaceAll,也就是说整个逻辑,默认只有一个token,如果出现多个,将不予替换。

这部分逻辑首先通过ttycolumns属性计算了进度条可用空间(columns是控制台宽,这个以后会讲),虽然使用 0 来兜底,不过一般情况availableSpace都将是一个正整数。

接着,给Windows下做了兼容处理。当在Windows命令行环境下,输出字符串的长度等于命令行的列数时,cmd会自动换行。

所以在这段代码中,减去一个字符的目的是为了避免发生自动换行。

然后计算了进度条的实际宽度,它会取 this.widthavailableSpace 之间的较小值,确保进度条在可用空间内显示,不会超出界限。

接着通过将当前进度与实际宽度相乘,计算出已完成部分的长度。这里的 ratio 我们上文计算出来。是当前进度与总进度的比率,表示完成的百分比。

然后使用 Array.join() 方法创建了一个由 this.chars.complete 组成的字符串,其长度等于 completeLength。这样生成了已完成的进度部分。

类似地,生成了未完成的部分,长度为 width - completeLength,使用 this.chars.incomplete 来填充。

completeincomplete 我们在构造函数选项中进行了初始化。

逻辑还没结束,如果已完成的部分长度大于 0,那么将头部字符 this.chars.head 加到已完成的字符串的末尾。

比如我们在构造函数初始化的时候,将head设置为>

那么进度条就是如下样子。

最后,将completeincomplete合并,替换文案中的:bar

这个时候,如果this.tokens有值。

那么就循环替换。

比如

js 复制代码
var bar = new ProgressBar(':current: :token1 :token2', { total: 3 })
bar.tick({
  'token1': "Hello",
  'token2': "World!\n"
})
bar.tick(2, {
  'token1': "Goodbye",
  'token2': "World!"
})

// 1: Hello World!
// 3: Goodbye World!

所以,我们可以回答上文的问题:tokens是什么?

tokens是给我们在tick的时候可以更新的占位符。

然后,通过tty的api进行绘制,如果得出来的字符串跟上次绘制相同,那么就不进行绘制。

首先,将光标移到行首,然后将整合的文案输出到控制台。这个时候,使用clearLine清除光标右侧的字符。也就是起到了禁止进度条右侧输入文字的功能。最后将结果缓存,从来下次对比。

至此,主要流程就结束了,本质进度条就是不断擦除写入的之前文案,然后在相同位置写入新的文案,从而造成进度条增长的假象。

但是我们并没有将整个库全部了解。

比如更新进度条的方法,不止是tick,还有update。如果想要在进度输出文案,可以使用interrupt,以及在render里面使用的terminate

其他方法

update

js 复制代码
ProgressBar.prototype.update = function (ratio, tokens) {
  var goal = Math.floor(ratio * this.total); // 计算目标进度,乘以总进度并向下取整,得到预期的完成数值
  var delta = goal - this.curr; // 计算需要增加的进度值

  this.tick(delta, tokens); // 调用 tick 方法,根据计算出的增量更新进度条
};

update实际上是对tick的包装,只不过tick接受的具体的值,而update内部通过ratio计算出具体的值,然后调用tick

interrupt

js 复制代码
ProgressBar.prototype.interrupt = function (message) {
  // 清除当前行
  this.stream.clearLine();
  // 将光标移动到行首
  this.stream.cursorTo(0);
  // 输出消息文本
  this.stream.write(message);
  // 在写入消息后终止当前行
  this.stream.write('\n');
  // 重新显示进度条及其上次输出内容
  this.stream.write(this.lastDraw);
};

我们看到,他的实现原理是通过clearLine清除一整行的内容(clearLine不传递任何参数,它默认会清除当前所在的行,即清除光标所在的行,光标会留在当前行的起始位置)。

然后将传入的message写入,再加一个换行字符。然后使用this.lastDraw把刚刚删掉的文案再次写入。

terminate

js 复制代码
ProgressBar.prototype.terminate = function () {
  if (this.clear) { // 如果设置了清除选项
    if (this.stream.clearLine) { // 如果输出流支持 clearLine 方法
      this.stream.clearLine(); // 清除当前行
      this.stream.cursorTo(0); // 将光标移动到行首
    }
  } else { // 如果未设置清除选项
    this.stream.write('\n'); // 在输出流中写入换行符,即换行显示进度条
  }
};

terminate将在进度条结束的时候自动调用,如果在构造函数传入了clear选项,那么就会同interrupt的部分逻辑一样,将进度条清除干干净净。

如果clearfalse,那么只会写入一个换行符。

看到这里,我们大概理解,node-progress实际上是对node:tty的api操作。

那么我们需要深入了解下,node:tty是什么,以及它具体做了什么。

node:tty

一般情况下,我们没必要手动创建node:tty的实例,那么我们如何获取它的实例呢?

Node.js会自动进行检测,如果检测到当前的运行时是文本终端,那么process.stdin就是tty.ReadStream的实例。

process.stdoutprocess.stderr就是tty.WriteStream的实例。

那么我们如何确定当前的运行时是文本终端呢?

node-progressrender中,我们学习到,可以通过检测process.stderr.isTTY是否为true。如果是true,说明运行时是文本终端。

shell 复制代码
$ node -p -e "Boolean(process.stderr.isTTY)"
true
$ node -p -e "Boolean(process.stderr.isTTY)" | cat
false

在上面的例子中,Node.js的进程的输出通过管道 | 传递给另一个命令 cat时,它会被识别为非交互式环境。

因为它的输出被重定向到另一个程序时,该输出不再被视为直接与用户在终端上交互,而是作为另一个程序的输入,因此被认为是非交互式环境,也就是当前的运行时不是文本终端。

原始模式

除了isTTYtty.ReadStream还有一对api

  • isRaw,是否是原始模式
  • setRawMode,设置为原始模式

什么是原始模式?

正常情况下,我在控制台输入任何值,如果有进程在监听的话,需要我按下回车之后,键入的内容才会被进程监听到。

这种模式被称为结果模式。

如果我在控制台键入任何值,没有按下回车的情况下,我输入的值会被立即监听到,这就是原始模式。

比如Vite5.0之前,就是通过原始模式来监听控制台输入,例如r键重启开发服务器。

后来这个PR将原始模式改为结果模式,因此实现了Vite5.0中,需要额外按下Enter键才能对应的命令。

我们可以通过process.stdin.setRawMode(true),来将结果模式设置为原始模式,如此,我们使用on可以即时获取到控制台的文案。

js 复制代码
process.stdin.setRawMode(true);
process.stdin.on('data', (d) => {
    const input = d.toString();
    console.log('Input:', input);
  });

对应的,我们可以通过process.stdin.isRaw来获取是否是原始模式。

clearLine / clearScreenDown / cursorTo / moveCursor

我们上文稍微提到了clearLine,但具体的作用是什么呢?

首先它接受两个参数

  • dir 方向,-1 是光标左边,1 是光标右边,0 是整行
  • callback,可选,结束的回调函数

虽然文档上dir是可选参数,但实际上,如果dir不传入的话,默认是 0 ,也就是整行。

当执行clearLine()方法的时候,会清除dir方向上的所有文案。

与此同时,还有个类似的api ------ clearScreenDown,它可以清除终端屏幕当前光标位置到屏幕底部的内容。

只接受一个可选参数callback

但是问题来了,他们清除都是基于光标定位,那么让用户自己去移动光标显然是不太优雅的,因此,我们需要搭配光标移动方法 ------ cursorTo

node-progress上,我们经常见到cursorTo(0)搭配clearLine()来实现清除一行的效果。但实际上,cursorTo接受三个参数

  • x x坐标
  • y 可选参数,不传默认为光标所在的y
  • callback 回调函数

默认情况,控制台左上角为0 , 0 坐标。

但是,关于涉及到坐标计算确实是比较麻烦,很多情况下,我们并不真的需要绝对坐标。而是将当前光标进行相对移动。

比如往上移动两行,往左移动两位。确实没必要计算绝对坐标,因此我们可以使用moveCursor方法。

moveCursor方法跟cursorTo一样接受三个参数

  • dx 相对x坐标
  • dy 相对y坐标
  • callback 回调函数

默认情况,控制台当前光标为0 , 0 坐标。

虽然文档中dy不是可选参数,但不传的话,默认为0,表现同cursorTo一样,都是当前一行。

columns / rows / getWindowSize

虽然上文提到计算坐标比较麻烦,但并非没有对应方法,其中一个属性我们已经在node-progress见过了,就是columns

columns是一个属性,它会记录当前具有的列数。每当触发resize事件时,则会更新此属性。

相对的rows会记录当前具有的行数。每当触发resize事件时,则会更新此属性。

因此,我们可以使用process.stdout.on('resize',callback),来监听resize事件,当columnsrows发送改变的时候,会触发这个事件。

需要注意。这个事件没有回调函数,所以我们需要从process.stdout再次获取。

js 复制代码
process.stdout.on('resize', () => {
  console.log('changed!');
  console.log(`${process.stdout.columns}x${process.stdout.rows}`);
});

color

在 Node.js 中,无论在何种情况下,process.stdout 都代表标准输出流,通常用于向控制台或终端输出信息。

所以说,它是一个流的实例,在大多数情况下表现为Writable Stream,因此理所当然具备流的方法和特性。只是在文本终端环境下,process.stdout 可能会与 node:tty模块相关联,因为它用于向终端输出。

流的相关知识较多,我们可能在未来单独讲,我们只注意一个------process.stdout用到的write

我们知道,write方法接受三个参数,第一个参数并非只是纯字符串,我们还可以通过ANSI转义码来实现输出不同的颜色。

js 复制代码
const colors = {
    reset: '\x1b[0m',
    red: '\x1b[31m',
    green: '\x1b[32m',
    yellow: '\x1b[33m',
  };
  
  process.stdout.write(`${colors.red}这是红色文本${colors.reset}\n`);
  process.stdout.write(`${colors.green}这是绿色文本${colors.reset}\n`);
  process.stdout.write(`${colors.yellow}这是黄色文本${colors.reset}\n`);
  

通过源码,我们知道,process.stdout就是使用write来实现进度条的渲染。

那么我们就可以通过ANSI转义码来实现不同颜色的进度条。

js 复制代码
const colors = {
  reset: "\x1b[0m",
  red: "\x1b[31m",
  green: "\x1b[32m",
  yellow: "\x1b[33m",
}
const callback = () => {
  console.log(colors.reset)
}
const bar = new ProgressBar("[:bar] :current/:total", { total: 10,callback })
console.log(colors.red)
const timer = setInterval( () => {
  bar.tick()
  if (bar.complete) {
    clearInterval(timer)
  } else if (bar.curr === 5) {
    bar.interrupt(colors.yellow)
  } else if (bar.curr === 8) {
    bar.interrupt(colors.green)
  }
}, 100)

我们在不同进度通过interrupt不断写入ANSI转义码,来让之后write的文案渲染不同的颜色,最后通过callback清除我们设定的ANSI转义码。

但这样有个问题,还记得interrupt的源码吗?interrupt会自动输入一个换行符。所以导致虽然ANSI转义码不会输出任何值,但会多一个换行。

所以还得使用万能的ANSI转义码,既然会多一个换行,那么我们提前删除一个换行不就可以了。

正好这里有一个可以删除一行的ANSI转义码\x1b[1A\x1b[2K

稍微更改下代码。

js 复制代码
// bar.interrupt(colors.yellow)
bar.interrupt(`\x1b[1A\x1b[2K` + colors.yellow) 

// bar.interrupt(colors.green)
bar.interrupt(`\x1b[1A\x1b[2K`+ colors.green)

然后运行下。

还行。

相关推荐
Martin -Tang7 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发8 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
老码沉思录1 小时前
写给初学者的React Native 全栈开发实战班
javascript·react native·react.js
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂1 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
红中马喽4 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
Black蜡笔小新5 小时前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html