我们在使用npm-check-updates,或者pkg-fetch的时候,会发现在loading的状态下控制台会出现一个进度条。
这个是通过progress这个npm包来实现的,很多知名的库(ant-design、sentry-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
取值,并使用了默认值兜底。从这里我们可以看出,只有fmt
和total
是属于必填项。
以下是针对options
的配置说明:
curr
:当前进度total
:完成任务所需的总步数width
:进度条在界面上显示的宽度,默认等同于total
stream
:输出流,默认为process.stderr
head
:进度条头部的字符,默认与完成字符相同complete
:完成部分所用的字符,默认为 "="incomplete
:未完成部分所用的字符,默认为 "-"renderThrottle
:渲染间隔时间的最小值(以毫秒为单位),默认为 16clear
:完成时是否清除进度条,默认为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
也是有入参的。
它接受两个参数 len
和 tokens
。如果 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
会调用render
,this.tokens
已经被tick
赋值了一次,为什么render
还要赋值一次。
这是一个历史遗留问题,因为在某个版本之前,的确是使用render
来赋值tokens
。但是为了性能优化,避免多次重复渲染,增加了渲染间隔,在这个提交,把this.tokens
放到tick
中执行,但实际render
可以赋值this.tokens
并没有去掉。
因此,我们可以看到,如果渲染时间小于renderThrottle
,是不进行渲染的,renderThrottle
在前面提过,可以在构造函数初始化的时候通过配置传入,默认16ms。
当然,如果force
是true
,那么就不必遵循这个性能优化,也可以进行渲染。在tick
中,执行this.curr >= this.total
的逻辑时候,调用render
的force
就是true
。因为这个tick
是非常重要的,关系到进度条是否显示完成样式(比如percent
是100%
,因此不能被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
,如果出现多个,将不予替换。
这部分逻辑首先通过tty
的columns
属性计算了进度条可用空间(columns
是控制台宽,这个以后会讲),虽然使用 0 来兜底,不过一般情况availableSpace
都将是一个正整数。
接着,给Windows
下做了兼容处理。当在Windows
命令行环境下,输出字符串的长度等于命令行的列数时,cmd
会自动换行。
所以在这段代码中,减去一个字符的目的是为了避免发生自动换行。
然后计算了进度条的实际宽度,它会取 this.width
和 availableSpace
之间的较小值,确保进度条在可用空间内显示,不会超出界限。
接着通过将当前进度与实际宽度相乘,计算出已完成部分的长度。这里的 ratio
我们上文计算出来。是当前进度与总进度的比率,表示完成的百分比。
然后使用 Array.join()
方法创建了一个由 this.chars.complete
组成的字符串,其长度等于 completeLength
。这样生成了已完成的进度部分。
类似地,生成了未完成的部分,长度为 width - completeLength
,使用 this.chars.incomplete
来填充。
complete
和 incomplete
我们在构造函数选项中进行了初始化。
逻辑还没结束,如果已完成的部分长度大于 0,那么将头部字符 this.chars.head
加到已完成的字符串的末尾。
比如我们在构造函数初始化的时候,将head
设置为>
。
那么进度条就是如下样子。
最后,将complete
和 incomplete
合并,替换文案中的: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
的部分逻辑一样,将进度条清除干干净净。
如果clear
是false
,那么只会写入一个换行符。
看到这里,我们大概理解,node-progress
实际上是对node:tty
的api操作。
那么我们需要深入了解下,node:tty
是什么,以及它具体做了什么。
node:tty
一般情况下,我们没必要手动创建node:tty
的实例,那么我们如何获取它的实例呢?
Node.js会自动进行检测,如果检测到当前的运行时是文本终端,那么process.stdin
就是tty.ReadStream
的实例。
而process.stdout
与process.stderr
就是tty.WriteStream
的实例。
那么我们如何确定当前的运行时是文本终端呢?
在node-progress
的render
中,我们学习到,可以通过检测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
时,它会被识别为非交互式环境。
因为它的输出被重定向到另一个程序时,该输出不再被视为直接与用户在终端上交互,而是作为另一个程序的输入,因此被认为是非交互式环境,也就是当前的运行时不是文本终端。
原始模式
除了isTTY
,tty.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
事件,当columns
和rows
发送改变的时候,会触发这个事件。
需要注意。这个事件没有回调函数,所以我们需要从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)
然后运行下。
还行。