以下のインターフェイスをもつロガーを実装する
const terminal = new TerminalLogger();
const spinner1 = terminal.spinner("loading1...").start()
const spinner2 = terminal.spinner("loading2...").start()
terminal.debug("this is logging in the middle")
spinner1.succeed()
spinner2.succeed()
スピナーと通常のロギングの責務をまとめて持たせることで、スピナーの再レンダリングなどのタイミングを隠蔽して、スピナーの途中でログを吐いても画面を汚さない。
複数スピナーが同時にレンダリングされるケースも考慮できる。
あとはスピナーライブラリの依存もカプセル化できるので、たとえばこの記事ではoraをスピナーライブラリとして使うが、そこから別のスピナーに乗り換えたい、などのケースも変更点を小さく実装できる。
実装
具体的な実装は以下。
ログレベルなどの制御は一旦無視。
import { randomUUID } from "crypto";
import ora, { Ora } from "ora";
export class TerminalLogger {
private readonly logger: Console;
private spinners: Map<string, Ora>;
constructor() {
this.logger = console;
this.spinners = new Map();
}
error(msg?: unknown, ...params: unknown[]) {
this.withRerenderSpinners(() => {
this.logger.error(msg, ...params);
});
}
info(msg?: unknown, ...params: unknown[]) {
this.withRerenderSpinners(() => {
this.logger.info(msg, ...params);
});
}
debug(msg?: unknown, ...params: unknown[]) {
this.withRerenderSpinners(() => {
this.logger.debug(msg, ...params);
});
}
spinner(msg?: string) {
const spinner = ora(msg);
const id = randomUUID();
const controller = {
start: (text?: string) => {
spinner.start(text);
return controller;
},
succeed: (text?: string) => {
this.spinners.delete(id);
return spinner.succeed(text);
},
fail: (text?: string) => {
this.spinners.delete(id);
return spinner.fail(text);
},
};
this.spinners.set(id, spinner);
return controller;
}
private withRerenderSpinners(cb: () => void) {
this.spinners.forEach((s) => {
s.clear();
});
cb();
this.spinners.forEach((s) => {
s.render();
});
}
}
まず spinner
メソッドで新しくスピナーが生成されるたびにTerminalLoggerにいま表示中のスピナーインスタンスを保持させるようにしている。
こうしておけば、info
/error
/debug
などのロギングのタイミングで保持しているスピナーすべてイテレーションし、毎回クリア&再レンダーできる。それをやっているのが withRerenderSpinners
メソッドになる。