Nodejs开发进阶B-进程和线程

本章节讨论的内容是nodejs中执行进程和管理相关的概念、管理和操作。它主要通过Process、Cluster、ChildProcess等模块实现和提供。另外我们也会讨论WorkThread工作线程相关的内容。

Process进程

nodejs程序中,使用Process模块和对象来表示和管理当前的进程。对于每一个正在运行中的nodejs程序,process就是当前其在操作系统中系统进程的一个映射,nodejs运行环境会将其封装为一个precess对象。使用此对象,可以方便开发者获取当前进程的信息并实施对其控制的操作比如退出程序等等。

nodejs程序的进程模型

在nodejs程序中,进程这个概念,应该是从操作系统出发而言的,代表系统中当前程序的实例,也是在操作系统级别进行资源分配和调度的最小单位。每个进程可以拥有自己的内存空间、文件描述符、信号处理程序等资源。

在操作系统中,进程之间的操作是隔离的,通常它们会相对独立的运行(如不能之间互相访问对方的内存和对象)。当然,为了提供灵活性和协调进程间的工作,操作系统也会提供进程间通信(InterProcess Communication,IPC)的机制。IPC有很多方式和实现,如共享内存、消息传递、管道和信号等等。

在这个时候,nodejs程序还是一个普通的应用程序。真正让nodejs程序突显优势的,是它对线程的管理方式。和一般程序通过并发处理机制,使用多线程来提高性能的思路不同,nodejs程序,或者说其设计的执行机制,使用单线程异步执行方式,来获得最高的IO类程序的执行性能和效率。这一点我们在本系列前面的章节中已经有所讨论,这里只是再次强调一下,这会作为后续nodejs进程和群集操作的一个基础。

process类和对象

使用REPL可以查看Process的对象模型,可比一般的对象复杂多了。但我们在一般应用中通常会应用到的一些属性和方法包括:

  • 平台信息: 包括当前nodejs各组成的版本、架构、平台等等(version和versions属性)
  • 加载模块列表: 当前程序加载的nodejs模块列表,用于确认是否加载的正确的程序模块
  • 环境信息: 主要就是操作系统相关的环境信息,重要的项目包括路径
  • 状态信息: 这些信息对于性能和资源占用评估比较重要,包括CPU时间、内存的占用和构成等等
  • 进程控制: 主要就是进程的退出操作,使用exit()方法

在程序中, process是内置对象,无需声明和引用,可以直接使用。

在实践中,process常见的应用方式和场景包括启动参数、环境变量加载和控制、系统资源占用评估等等。下面就这些内容重点说明。

启动参数

所有的nodejs应用程序,本质上都是从node程序加载启动的。在这个启动的过程中,可以通过启动参数,来控制程序启动的过程和设置,以满足更加丰富和灵活的业务应用需求。程序启动后,可以通过process.argv这个内置的属性变量,来访问这些启动参数。

例如,这里有一个js程序文件p1.js,使用node从命令行来启动这个程序,再加上几个参数的命令行的形式可能如下:

js 复制代码
// 启动命令

node p1 1 2 3 4

// 这时的 process.args内容为:
[ '/usr/bin/node', '/home/yanjh/Develop/p1', '1', '2', '3', '4' ]

这里的要点如下:

  • process.args 的内容是一个字符串数组,其中有两个默认固定参数
  • 第一个参数就是node程序本身
  • 第二个参数是当前js程序文件的名词,可以省略.js扩展名
  • 也可以使用 . 代指当前文件夹下的index.js文件,作为默认启动文件
  • 后面的参数,就是空格分隔的参数,转换为参数列表成员
  • nodejs没有限制任何参数的格式,完全由应用程序决定如何进行解析和处理

下面是几个可以使用参数来进行程序执行控制的场景:

  • 如果有命令行输出内容,可以控制输出的内容和格式
  • 网络应用启动,配置侦听的地址和端口
  • 配置启动密钥,用于解密配置信息(如数据库连接字符串)
  • 配置默认的工作文件夹
  • ...

process还有一个执行参数属性, execArgv,这实际上是node这个主命令程序的可选参数,比如一些启动选项等等。

env环境变量

除了args启动参数之外,我们也可以使用环境变量技术,来对nodejs应用程序的启动和运行进行配置和控制。它通过process.env属性的设置和访问来实现。需要理解的是,env主要从当前操作系统的运行环境中获取和继承配置信息,和启动命令没有直接关系。

我们可以在REPL中,直接看到process.env的内容:

cmd 复制代码
$ node
Welcome to Node.js v20.10.0.
Type ".help" for more information.
> process.env
{
  SHELL: '/bin/bash',
  LANGUAGE: 'zh_CN:zh',
  PWD: '/home/yanjh/Develop',
  LOGNAME: 'yanjh',
  XDG_SESSION_TYPE: 'tty',
  MOTD_SHOWN: 'pam',
  HOME: '/home/yanjh',
  ...

其中一些可能会比较有用的项目就是当前用户,当前工作目录和用户文件夹(有文件权限)等等。有个"_"属性,代表node主程序,可以看到执行文件的位置。

此外,process的env对象,是可以修改的,也就是说,可以当全局变量使用?

exit、abort和kill

可以使用process.exit()主动退出当前node程序,参数是退出代码,默认为0。abort方法用于立即强制退出。

Process有一个kill方法,可以用于向目标进程发送系统信号量如SIGINT或者SIGTERM,就和系统kill指令类似。这里设置这个功能,主要可能是用于管理子进程使用的。

这里简单解释一下SIGINT和SIGTERM的区别,SIGINT通常来自Ctrl-C操作,具有一定的强制性,也比较快;而SIGTERM则是来自系统管理,正常终止进程,可以等待进程清理和释放资源后退出,一般侵害性比较弱。

资源使用

process提供了一些方法,可以用于评估进程对资源的使用和相关信息:

  • cpuUsage: 当前进程的用户态和系统态的CPU时间(毫秒)
  • memoryUse/memoryUse.rss: 当前进程内存占用,rss是驻留内存即实际物理内存占用
  • constrainedMemory: 当前系统可用内存
  • resourceUsage:当前进程资源使用情况,更丰富详细

状态管理

process提供了一系列事件侦听接口,可以进行相关的状态管理。典型的示例代码如:

js 复制代码
const process = require('node:process');

process.on('exit', (code) => {
  setTimeout(() => {
    console.log('This will not run');
  }, 0);
});

这段代码,侦听Process的exit事件,并作出相关的处理。 其他经常使用的事件包括:

  • exit/beforExit: 进程退出,
  • message: 如果有IPC通道,此事件可以监听和接收其他进程发来的消息
  • worker: 工作线程创建
  • uncaughtException: 未捕获异常,可以覆盖默认错误处理机制,如程序退出
  • waring: 处理警告,可以使用emitWarning主动产生警告事件
  • SIGINT/SIGTERM: 系统信号事件,不是所有信号都可用

其他杂项

process还有一些笔者觉得比较有趣的地方,值得分享:

  • nextTick方法:用于快速异步调用
  • hrtime: 高精度时间
  • permission.has: 用于判断文件是否有相关权限
  • pid/ppid: 当前进程ID和父进程ID
  • report: 进程报告,非常详细的运行状态和统计

Cluster 群集

前面已经提到,nodejs主要使用了单线程的运行模式,大大简化了程序的运行管理和开发工作。但并不代表它不能使用现代化硬件平台的多处理器架构。为此,它提出的解决方案是:群集和工作进程。

下面这段典型的HTTP服务程序的代码可以帮助我们很好的理解其中的一些基础概念和流程:

js 复制代码
const cluster = require('node:cluster');

if (cluster.isPrimary) {
  // Keep track of http requests
  let numReqs = 0;
  setInterval(() => {
    console.log(`numReqs = ${numReqs}`);
  }, 1000);

  // Count requests
  function messageHandler(msg) {
    if (msg?.cmd === 'notifyRequest') numReqs ++;
  }

  // Start workers and listen for messages containing notifyRequest
  const numCPUs = require('node:os').cpus();

  for (let i = 0; i < numCPUs; i++) cluster.fork();
  for (const id in cluster.workers) cluster.workers[id].on('message', messageHandler);
} else {
  // Worker processes have a http server.
  require('node:http')((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');

    // Notify primary about the request
    process.send({ cmd: 'notifyRequest' });
  }).listen(8000);
}

下面我们展开讨论相关的要素。

  • cluster对象

当在主程序入口,使用require引入cluster对象的时候,当前这个程序,就可以进入和使用群集模式来进行工作了。群集模式包括一个主进程和若干个工作进程(Work Process)。

例如上面的代码,在笔者的开发PC上运行的时候,可以清楚的看到启动了很多node程序(一共13个,因为有12个逻辑处理器)。

  • 主进程 Primary Process

默认情况下启动的当然就是主进程了。可以使用cluster的isPrimary属性,来判断和确定当前的进程是否是主进程。isMaster是老版本使用的属性,已经过时不推荐使用了。

如果当前进程是主进程,可以使用cluster.fork()方法,来创建工作进程,这里使用的术语是fork(分叉)。大概的意思就是先创建主进程,然后基于主进程,创建工作进程。

fork方法支持一个env参数,可以由主进程设置工作进程的环境变量。fork可以多次调用,来创建多个工作进程。这些工作进程对于操作系统而言都是平等的,它会根据情况分配计算资源来协调这些进程的执行。一般而言,对于多处理器系统,我们会建议创建和处理器(实际或者逻辑处理器)数量差不多相同的工作进程,来充分利用多处理器硬件和操作系统的协调机制和其计算资源。

fork方法执行会返回一个worker对象,代表在主进程视角,看到的这个新创建的工作进程。从而可以进行相关的操作和管理。后续也可以使用workers工作进程列表来完成相关操作。

  • 工作进程列表

cluster会维护一个工作进程列表, cluster.workers。成功创建的工作进程都会加入这个列表,方便主进程进行管理,包括工作进程的关闭,事件侦听的处理等等。

  • 主进程事件

主进程cluster可以使用事件消息来进行状态的管理和处理。常用的相关事件包括 setup(主进程设置), fork(工作进程创建), listening (工作进程网络侦听),online(工作进程上线),disconnect(工作进程断开),exit(工作进程退出),message(工作进程消息)等等。

  • 工作进程 Work Process

由cluster分叉出来的进程就是工作进程。开发者可以在这个进程中,完成实际的业务功能,典型的就是实际的Web应用服务。这时编写的代码就是完全正常的应用程序。

cluster也提供了isWorker属性,来标识和判断当前进程是否工作进程。

  • 进程间通信

前面已经提到,无论是主进程还是工作进程,在操作系统的角度而言,都是独立的进程,要协调它们的运行,或者需要在它们之间传输和共享数据,就需要使用IPC机制。

这个机制在cluster中的实现非常简单。就是以下三点:

1 接收数据:需要在进程对象(主进程和工作进程)上,注册并实现onMessage事件方法;

2 工作进程到主进程:使用process.send(message)方法

3 主进程到工作进程:在主进程中,使用工作进行实例(来自fork或者workers)的send(message)方法

  • 工作进程设置

默认情况下,cluster.fork()使用当前的程序文件,来执行分叉并启动工作进程。但这个其实是可选的,就是使用设置方法 cluster.setupPrimary(),通过参数和设置,可以选择其他的工作进程的程序入口。如下面的代码,可以设置从另一个worker.js文件,来启动工作进程。

js 复制代码
cluster.setupPrimary({
  exec: 'worker.js',
  args: ['--use', 'https'],
  silent: true,
});
cluster.fork(); // https worker

合理规划主进程和工作进程的程序文件,可以使程序架构更加合理和更容易维护。

  • 共享网络端口侦听

可能有一点Web应用开发经验的人,会有一个小小的疑问。就是在操作系统中,一个进程,只能侦听一个网络端口,当很多工作进程启动并设置网络端口侦听的时候,理论上会发生冲突。

但是,nodejs cluster模式,已经很好的解决了这个问题。经过查阅相关资料和询问Bard,笔者了解到它的大致的实现方式和工作的原理如下。

在linux系统上,cluster.fork()方法,是基于基于fork()系统调用实现,它可以基于现有的线程创建一个工作线程副本。这个机制,可以让工作进程和主线程共享文件描述符,这里面就包括了网络端口(sockets)。当网络请求到来的时候,由主线程分配,将其分发到合适的工作线程上来进行处理。所以,从操作系统的角度来看,这个侦听端口,是绑定到主线程上的,这样就不会在工作线程之间造成冲突。

在Windows平台上,其实现方式和Linux上稍有不同,简单而言,它在主线程上实现侦听,并使用"命名管道"来向工作线程传输数据,也可以达到类似的效果。

平滑重启

利用cluster的主从进程模式,可以实现Web应用的所谓"平滑重启"的功能,减少网络中断,提高应用体验。

还是以Web服务应用为例。代码更新后,需要重新启动服务,但又希望尽量减少对当前网络服务的影响。就可以"分批"来重启服务实例。方法就是,持续检查工作进程是否正在处理请求,如果没有或者等待请求处理完成,就关闭当前工作进程,然后重新启动新的工作进程,加载新的应用代码,依次对所有工作进行进行类似的处理,直到所有工作进程都使用新的代码重新启动。

由于在这一过程中,可以控制不完全关闭网络服务,这样对于用户而言,完全感觉不到服务的中断或者卡顿,就做到了无感的系统更新。

当然,要实现这样好的效果,需要在开发和部署时就很好的进行规划和实现,来支持这一操作模式。

子进程 ChildProcess

不要将这个名词,和前面的进程/工作进程,或者多线程搞混了。这里的"子进程"其实是nodejs提供了另外一个功能,就是在nodejs程序中,执行一个操作系统内的外部程序或者命令的方式。这个功能,在使用nodejs程序作为"胶水程序"来粘合和连接各种不同的应用程序的时候,特别有用和有效。

例如,我们要开发一个生成和传输文件的程序,大致的流程是需要先从数据库中读取数据,结合数据报告模板生成pdf文件,然后需要将这些pdf文件,使用SSH协议传输到一个远程的目标文件服务器上。这个文件传输的过程,就完全可以使用系统内置的SSH协议支持和文件传输SCP命令来完成,不需要借助一个第三方SCP程序库。

再如,笔者曾经开发过的一个Postgres数据库表内容导出程序,也是这样实现的。并没有自己编写了数据库连接程序,执行程序和文件写入程序,而是直接调用了psql的数据复制命令,简单而又高效。这些,都是nodejs程序很好的结合和执行系统命令的应用案例。

下面我们将会看到,在nodejs中,是如何实现和操作的。具体而言,nodejs的childProcess模块,提供了三种命令执行的模式: spawn、exec和fork。 这些方法,都可以创建独立的工作进程,并且都可以以异步回调或者同步执行的形式来执行。

除了可以执行外部程序之外,ChildProcess当然也可以直接使用执行文件的方式,来执行nodejs程序。但笔者理解这种方式的本质,还是通过调用nodejs主程序,然后执行js文件的方式来执行的,和执行外部或者系统命令的方式在底层是一致的。这时,我们就可以理解,为什么说clusert的fork方法,是基于childProcess的fork技术来实现的了。

spawn 繁衍

笔者不是特别理解这种命名方式。spawn在生物学中是繁衍的意思(星际争霸中,虫族的发展方式就是spawn)。nodejs中,childProcess的spawn方法可以用于执行系统命令和其他的可执行文件,并且这种方式可以处理输入输出流。

我们一般在操作系统中,执行命令行模式的程序,都是通过某个"外壳(shell)"环境调用的(比如Linux系统的bash),这个shell就提供了一个交互接口,可以用于在执行过程中,输入指令或者数据,并且可以输出处理结果、操作进度和程序状态等信息。

使用spawn模式执行程序,开发者就需要自己编写代码来处理这些内容,笔者觉得本质上就是在模拟实现一个shell。我们以下面的代码为例来进行说明,为了方便讨论,同时例举了exec的执行代码:

js 复制代码
const { spawn, exec } = require('node:child_process');
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
  console.error(`stderr: ${data}`);
});

ls.on('close', (code) => {
  console.log(`child process exited with code ${code}`);
});

exec("ls -lh /usr", (error,stdout,stderr)=>{
  if (error) console.error(`exec error: ${error}`);
  if (stdout) console.log(`stdout: ${stdout}`);
  if (stderr) console.error(`stderr: ${stderr}`);
});

// 执行结果:
yanjh@yanjh-BoYue-Series:~/Develop$ node c
stdout: 总用量 112K
drwxr-xr-x   2 root root  36K 1月   2 16:52 bin
drwxr-xr-x   2 root root 4.0K 7月  26 08:58 games
drwxr-xr-x  40 root root 4.0K 1月   2 16:52 include
drwxr-xr-x 111 root root  12K 1月   2 16:52 lib
drwxr-xr-x   2 root root 4.0K 7月  26 08:34 lib32
drwxr-xr-x   2 root root 4.0K 12月 14 15:15 lib64
drwxr-xr-x   9 root root 4.0K 12月 14 14:56 libexec
drwxr-xr-x   2 root root 4.0K 7月  26 08:34 libx32
drwxr-xr-x  10 root root 4.0K 7月  26 08:34 local
drwxr-xr-x   2 root root  20K 12月 22 14:23 sbin
drwxr-xr-x 265 root root  12K 12月 22 14:23 share
drwxr-xr-x   6 root root 4.0K 12月 14 15:00 src

stdout: 总用量 112K
drwxr-xr-x   2 root root  36K 1月   2 16:52 bin
drwxr-xr-x   2 root root 4.0K 7月  26 08:58 games
drwxr-xr-x  40 root root 4.0K 1月   2 16:52 include
drwxr-xr-x 111 root root  12K 1月   2 16:52 lib
drwxr-xr-x   2 root root 4.0K 7月  26 08:34 lib32
drwxr-xr-x   2 root root 4.0K 12月 14 15:15 lib64
drwxr-xr-x   9 root root 4.0K 12月 14 14:56 libexec
drwxr-xr-x   2 root root 4.0K 7月  26 08:34 libx32
drwxr-xr-x  10 root root 4.0K 7月  26 08:34 local
drwxr-xr-x   2 root root  20K 12月 22 14:23 sbin
drwxr-xr-x 265 root root  12K 12月 22 14:23 share
drwxr-xr-x   6 root root 4.0K 12月 14 15:00 src

child process exited with code 0

简单说明一下:

  • 使用spawn方法,可以创建一个子进程对象
  • 子进程的内容就是执行系统的ls命令(linux系统),带有相应的参数,列表/use目录中的内容
  • 参数的定义方式,其实和ls命令是一样的,但使用数组表达
  • 实现了相关事件的回调方法,可以分别在标准输出、错误输出和程序退出时进行相关处理

exec 执行

exec的方法更加直接简单一点,可以直接执行一个系统命令。但和spawn方法不同,它封装的不是那个命令程序,而是系统的默认shell,并且在shell中执行这个命令,然后处理标准输出和错误信息。

相关示例代码已经在spawn的示例当中进行了展示来方便进行比较,它们的执行结果其实是一样的。有趣的是,在这段代码中,先执行的是exec方法,说明它们是异步执行方式,我们随后会讨论相关的机制。

看到上面的示例,我们应该能够感受到,相对而言,spawn的功能和控制更加底层一点,更接近操作系统的调用模式;而exec更加简便易用,更像普通的程序方法调用。

exceFile 执行文件

关于execFile方法,nodejs技术文档是这样表述的。execFile和exec方法基本相同,唯一差异点在于它默认不使用shell,而是直接创建子进行并执行程序,所以执行效率略高于exec方法。

这样,我们就可以简单的理解,如果不考虑处理输入输出的情况,我们可以更简单的使用execFile,它可以看成exec的一个精简版本。

子进程的功能非常强大,所以在实践中,要特别注意它可能引起的安全问题进城以在生产环境中使用,要特别注意命令行执行的内容和参数检查,注意防备注入攻击,限制nodejs使用的账号权限等。

fork 分叉

fork方法,是spawn方法的一个特别情况,它被用于执行nodejs进程。这个方法,相对于标准spawn方法的扩展包括:

  • 它会在子进程建立之后,建立额外的IPC通道,来辅助进行进程间的通信
  • 每个进程都拥有自己的V8实例环境
  • 不支持shell选项
  • 可以用于支持nodejs cluster的fork方法,来利用多处理器体系提升nodejs应用服务性能

WorkThread 工作线程

可能经常有人会在不深入了解JS程序工作原理的情况下,诟病JS程序单线程的工作模式。所以有一个说法就是,nodejs更适合于IO密集类的应用,而对CPU密集类的应用如计算等等的执行效率有所欠缺。此外,虽然nodejs引入了cluster机制,可以使用多进程,来执行大量任务的并行处理,也引入了spawn可以创建单独的线程来模拟并行处理的效果,但这些独立的线程会工作在独立的V8实例环境当中,相对资源占用比较大,创建大量的线程,对操作系统整体会造成比较大的负担。

可能是为了完善在这方面的支持,在nodejs的比较新的版本中,引入了WorkThread工作线程的功能和特性。在nodejs的技术文档中,它强调WorkThread对于执行CPU敏感的JavaScript操作很有帮助,但基本无助于IO敏感的工作,而这正是Nodejs内置的异步IO执行擅长的场景,应当根据业务特点,合理的规划和使用这些技术。另外它还提到,和child_process或者cluster不同,WorkThreads可以共享内存,它们通过传输ArrayBuffer实例,或者共享SharedArrayBuffer实例来实现,所以可能看起来过程和效果类似,但和进程间IPC相比,工作线程间的数据传输效率和性能更高。

笔者尚未有机会对这个特性在实践中有实际的应用和经验,只能结合对技术文档和示例代码的解读,表述一下自己的一些认识和想法。

首先熟悉前端开发的人员,初一接触,马上就会有似曾相识的感觉,这不就是WebWorker嘛。因为它的基本工作流程就是,基于js代码文件,创建一个worker,并通过消息通道,在worker和主线程程序间发送和接收消息,从而完成操作任务。我们可以通过下面的示例代码来理解这个过程:

js 复制代码
const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.once('message', (message) => {
    console.log("From Worker:",message);  // Prints 'Hello, world!'.
  });
  worker.postMessage('Ping');
} else {
  // When a message from the parent thread is received, send it back:
  parentPort.once('message', (message) => {
    console.log("From Main:", message);
    parentPort.postMessage("Pong!");
  });
};

这段代码的解读和要点总结如下:

  • 此处为了简单,主线程和工作线程是同一个js代码文件
  • 可以使用isMainThread属性,来判断当前线程是否主线程
  • 要创建工作线程,使用new Worker方法,创建worker实例
  • 在主线程中,可以使用workder的on侦听方法,来接收来自工作线程的消息,并通过postMessage方法向工作线程发送消息
  • 在工作线程中,可以使用parentPort对象指代主线程,并使用类似的方式,接收主线程消息和向主线程发送消息

通过阅读nodejs相关技术文档,笔者还发现了一些关于WorkThread值得分享的内容:

  • MessageChannel: 消息通道,可以手动建立消息通道实例,并且在通道内部双方收发信息
  • BroadcastChannel:广播通道,在主线程和工作线程之间建立消息广播机制

小结

本文的主要内容是nodejs有关于进程和执行管理相关的问题。具体包括process(进程)、cluster(群集)、执行方法(spawn\exec\fork)和workthread工作线程等。目的是让开发人员能够熟悉和了解有效使用nodejs进程管理来提升系统性能,处理不同类型的计算任务、集成外部程序和应用和进行系统架构优化。

相关推荐
Jiaberrr6 分钟前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
杨哥带你写代码16 分钟前
足球青训俱乐部管理:Spring Boot技术驱动
java·spring boot·后端
A尘埃1 小时前
SpringBoot的数据访问
java·spring boot·后端
yang-23071 小时前
端口冲突的解决方案以及SpringBoot自动检测可用端口demo
java·spring boot·后端
Marst Code1 小时前
(Django)初步使用
后端·python·django
代码之光_19801 小时前
SpringBoot校园资料分享平台:设计与实现
java·spring boot·后端
编程老船长1 小时前
第26章 Java操作Mongodb实现数据持久化
数据库·后端·mongodb
IT果果日记2 小时前
DataX+Crontab实现多任务顺序定时同步
后端
安冬的码畜日常2 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
太阳花ˉ2 小时前
html+css+js实现step进度条效果
javascript·css·html