侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

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

lumozx
2023-11-28 / 0 评论 / 0 点赞 / 11 阅读 / 36914 字

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

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

更重要的是它零依赖。

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

node-progress

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

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

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属性来检查进度条是否结束。

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

构造函数

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

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方法略长,我们按照逻辑分两段来解析。

数据装填

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剩下的逻辑。

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有值。

那么就循环替换。

比如

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

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

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

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,说明运行时是文本终端。

$ 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可以即时获取到控制台的文案。

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再次获取。

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转义码来实现输出不同的颜色。

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转义码来实现不同颜色的进度条。

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

稍微更改下代码。

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

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

然后运行下。

还行。

0
博主关闭了所有页面的评论