エスケープシーケンスコンソール(CLI)サーバーサイドローカル環境同期・非同期

【Node.js】 コンソール(CLI)で進捗をプログレスバー表示してみる

更新日:2021/06/09

Node.jsの実行結果をコンソールに出力するとき、時間のかかる処理の進捗をプログレスバーで表現するのは常套手段と言えます。

そこで今回は、Node.jsでプログレスバー表示する方法をお伝えします。

 

単純なプログレスバー

まずは、現在のカーソル位置に進捗状況を表示する単純なプログレスバーを作成してみます。

完成イメージを作成してみたので、実行してみてください。

プログレスバーを表示するコード

次のコードは、プログレスバーを文字列で作成してコンソール出力しています。

プログレスバーを表示する関数

  // maxCount : 進捗100%のときの数値
  // progressLength : 進捗(■と□)の合計数
  // title : 進捗の前に表示するタイトル
const simpleProgress = (maxCount,progressLength,title)=>{
    let nowCount = 0;

    return  addCount =>{
        nowCount += addCount;

        const per = Math.min( nowCount / maxCount , 1.0 );
        const count = nowCount <= 0 ? 0 : Math.floor(per * progressLength );

        process.stdout.write( `${ title }${
            "■".repeat( count ) + "□".repeat( progressLength - count)
        }${ Math.floor(per * 100) }%\r` );
        return per >= 1;
    };
};

simpleProgress関数を実行すると、nowCount変数をプライベートにもつクロージャ的な関数を返します。

返された関数を呼び出すと、引数がnowCountに加算されます。
そしてnowCountを元に進捗率perを計算します。
このときperが1以上になると進捗が100%を超えてしまうので、Math.min()で最大値を1.0に限定しています。
そして、ここでは■の数を計算して、プログレスバーを文字列で作成しています。

次に作成した文字列をコンソール出力しています。

コンソール出力は通常はconsole.log()を使用しますが、これは最後に改行を表すエスケープシーケンス"\n"を付加します。
今回は改行されると不都合なので、process.stdout.write()を使用します。
こちらは文字列をそのまま出力するので、勝手に改行しません。

出力した文字列で重要なのが、最後の"\r"です。
これはCR(キャリッジリターン)を表すエスケープシーケンスです。
この文字コンソールは、『カーソルの位置を行の先頭に移動させる』という意味として判断して、その通りに実行します。
ただし、カーソルが移動するだけで、既存の文字はそのまま残ります。

\rで行頭に戻る

この状態で再度文字を出力すると、既存の文字が上書きされます。
ただし出力した文字が既存の文字より短い場合、その分の既存文字が残ります。
必要なら、次のエスケープシーケンスのどれかを使って既存の文字を削除してください。

■行を削除するエスケープシーケンス
範囲エスケープシーケンス
カーソルから行末まで削除\x1b[0K
カーソルから行頭まで削除\x1b[1K
行全体を削除\x1b[2K

今回は、同じ文字数なので削除していません。
削除しない分だけ高速で処理されます。

備考:回転で進捗を表示するコード

少し主題から外れますが、上記のコードを回転で進捗表示するように変更してみます。

こちらも完成イメージを掲載しておきます。

コードは進捗の組み立てを変更しているだけで、要点は同じです。

回転で進捗を表示する関数


const simpleProgress = (maxCount,progressLength,title)=>{
    let nowCount = 0;
    let dispCount = 0;

    const dispChar = [ "|","/","―","\"];

    return  addCount =>{
        nowCount += addCount;

        const per =  nowCount / maxCount ;
        process.stdout.write( `${ title } ${ dispChar[dispCount] } ... ${ Math.floor(per * 100) }%\r`);
        dispCount = ++dispCount >= dispChar.length ? 0 : dispCount;

        return per >= 1;
    };
};

引数progressLengthが使用されていませんが、最初のsimpleProgress関数をそのまま置き換えているので残してあります。

お好みに合わせて、いろいろ変更してみてください。

プログレスバーの進捗を管理するコード

作成したsimpleProgress関数を使って、プログレスバー表示をおこなってみます。

今回は、タイマーを使って処理をおこなっている雰囲気を演出しています。

プログレスバーの進捗を管理するコード


    // カーソルの非表示・終了時のカーソル復帰
process.stdout.write( "\x1b[?25l" );
process.on("exit", ()=>process.stdout.write( "\x1b[?25h" ));
process.on("SIGINT", ()=>process.exit(0));

const progress = simpleProgress( 500 , 20 ,"進捗状況" );

console.log( "処理を開始しました。" );
progress(0);

const timerFunc = ()=>{
    const step = Math.floor(Math.random() * 20) + 10;
    if( progress(step) === true ) {
        process.stdout.write("\n終了しました");
        return;
    }
    setTimeout( timerFunc , 100 );
};
setTimeout( timerFunc , 100 );

最初の3行は、コンソール上のカーソル非表示と、プログラム終了時にカーソルを表示する処理をおこなっています。

カーソルの表示・非表示は次のページを参考にしてみてください。
【Node.js】 コンソール(CLI)のカーソル表示・非表示切り替える

後はsetTimeoutで定期的にプログレスバーを進めているだけです。

 

複数のプログレスバーを同時に表示する

今度は複数のプログレスバーを連携させてみます。

まずは、完成イメージを実行してみてください。

サブ処理が100%になる毎にメイン処理の進捗が進むようなイメージで、今回は作成してみます。

基本的な仕組み

まずは、基本的な仕組みを解説します。

  1. プログレスバーの位置(行数)を記憶しておく
  2. カーソルを常に、最終行+1行目に保持する
  3. プログレスバー表示時
    1. カーソルをプログレスバーの行まで移動する
    2. プログレスバーを出力する
    3. カーソルを最終行+1行目に戻す

つまり、カーソルを常に最終行+1行目に置いておき、プログレスバー表示するときだけバーの位置にカーソルを移動させ、バーを表示させたら元の位置に戻します。

カーソルの移動は、次のエスケープシーケンスから選択して使用します。

■カーソルを移動するエスケープシーケンス
方向エスケープシーケンス
上に移動\x1b[nA
上に移動して行頭に移動\x1b[nF
下に移動\x1b[nB
下に移動して行頭に移動\x1b[nE

上の表でnは移動させたい行数です。

複数のプログレスバーを管理する関数コード

では、実際のコードです。

複数のプログレスバーを管理する関数


const multiProgress = ()=>{
    let endLine = 0;

    const progressMap = new WeakMap();
    const progressBody = function (){};
    progressBody.prototype={
        addCount:  function(count){ return progressText( count , this )},
        reset:  function() {
            progressText( 0  , this ,true);
            return this;
        }
    };

    const createProgress = ( maxCount,progressLength , label )=>{
        const p = new progressBody();
        progressMap.set( p , {
            maxCount:maxCount,progressLength:progressLength,label:label,
            nowCount:0,line:endLine,
        });
        process.stdout.write( "\n" );
        endLine++;
        return p.reset();
    };

    const progressText = (addCount,progressObj,resetFlg = false)=>{
        const p = progressMap.get( progressObj );
        const lineUp = endLine - p.line; // カーソルの移動量
        const nowCount = resetFlg ? (p.nowCount = 0 ) : ( p.nowCount += addCount );

        const per = Math.min( nowCount / p.maxCount , 1.0 );
        const dispCount = nowCount <= 0 ? 0 : Math.floor(per * p.progressLength );

        process.stdout.write( `\x1b[${ lineUp }F` ); // カーソルUP
        if( resetFlg ) process.stdout.write( "\x1b[2K" ); // 行消去
        process.stdout.write( `${ p.label }${
            "■".repeat( dispCount ) + "□".repeat( Math.max(p.progressLength - dispCount,0))
        }${ Math.floor(per * 100) }%\r` );
        process.stdout.write( `\x1b[${ lineUp }E` ); // カーソルDOWN
        return per >= 1;
    };

    return  {
        writeMessage:message => {
            process.stdout.write( message );process.stdout.write( "\n" );
            endLine ++;
        },
        addProgress:( maxCount,progressLength , label )=>createProgress(maxCount,progressLength , label),
    };
};

今回は出力した行数を把握するために、プログレスバー以外の行も関数multiProgressを経由して表示しています。

関数multiProgressを実行すると、最終付近でreturnしているオブジェクトが返ります。

そのオブジェクトのaddProgressメソッドを実行する度に、プログレスバーを管理するためのオブジェクトprogressBodyを取得することができます。
そしてprogressBodyのaddCountを実行すると、プログレスバーの進捗が進みます。

WeakMap()は、オブジェクトをキーとしてデータを格納しておけるJavaScriptの組み込みオブジェクトです。
ここでは、プログレスバーの情報(進捗100%のときの数値やタイトル、進捗数など)を保持しています。

WeakMapについては、次のページをみてください。
【JavaScript】 WeakMapでオブジェクトとデータの関連付けをおこなう

ちなみに関数内で、通常の関数定義(function)とアロー関数での定義が混在しているのは理由があります。
基本的にはアロー関数を使用していますが、アロー関数はthisが使用できません。
そのため、thisを使いたいときだけ通常の関数定義を使用しています。

アロー関数については、次のページを読んでみてください。
【JavaScript】 アロー関数は何者!?かっこいいだけじゃない!

プログレスバーの進捗を管理するコード

作成したmultiProgress関数を使って、プログレスバー表示をおこなってみます。
やっていることは、単純なプログレスバーのときと同じです。

プログレスバーの進捗を管理するコード


process.stdout.write( "\x1b[?25l" );
process.on("exit", ()=>process.stdout.write( "\x1b[?25h" ));
process.on("SIGINT", ()=>process.exit(0));

const progress = multiProgress(  );

progress.writeMessage( "処理を開始しました。" );
const progressMain = progress.addProgress( 500 , 15 , "メイン処理:" );
progress.writeMessage( "サブの進捗状況" );
const progressSub = progress.addProgress( 500 , 20 , "サブ処理:" );

const timerFunc = ()=>{
    const step = Math.floor(Math.random() * 20) + 10;
    if( progressSub.addCount(step) === true ) {
        const stepMain = Math.floor(Math.random() * 300) + 100;
        if( progressMain.addCount(stepMain) === true ) {
            process.stdout.write("\n終了しました");
            return ;
        }
        progressSub.reset();
    }
    setTimeout( timerFunc , 100 );
};
setTimeout( timerFunc , 100 );

更新日:2021/06/09

書いた人(管理人):けーちゃん

スポンサーリンク

記事の内容について

null

こんにちはけーちゃんです。
説明するのって難しいですね。

「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。

裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。

掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。

ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php

 

このサイトは、リンクフリーです。大歓迎です。