Skip to content

命令行交互原理

重点知识

  • 掌握:readline / events / stream / ansi-escapes / rxjs
  • 掌握命令行交互实现原理,并实现一个可交互的列表
  • 分析 inquirer 源码掌握其中的关键实现

ANSI-escape-code查阅文档:https://handwiki.org/wiki/ANSI_escape_code

readline 源码分析

  • 强制将函数转换为构建函数
js
if (!(this instanceof Interface)) {
  return new Interface(input, output, completer, terminal);
}
  • 获得事件驱动能力
js
EventEmitter.call(this);
  • 监听键盘事件
js
emitKeypressEvents(input, this);

// `input` usually refers to stdin
input.on('keypress', onkeypress);
input.on('end', ontermend);

readline 核心实现原理:

readline

注:readline利用了Generator函数的特性,查看:https://es6.ruanyifeng.com/#docs/generator

代码演示:

js
import * as readline from 'node:readline'; // https://nodejs.org/api/readline.html#readline

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
});

rl.question('your name:', (answer) => {
    console.log(answer)
    rl.close()
})
js
function* g() {
    console.log('read');
    let ch = yield;
    console.log(ch)
    let s = yield;
    console.log(s)
}

const f = g();
console.log(f)
f.next()
f.next('a')
f.next('b')
js
function stepRead(callback) {
    function onkeypress(s) {
        output.write(s);
        line += s;
        switch (s) {
            case '\r':
                input.pause();
                callback(line);
                break;
        }
    }

    const input = process.stdin;
    const output = process.stdout;
    let line = '';


    emitKeypressEvents(input);
    input.on('keypress', onkeypress);
    input.setRawMode(true);
    input.resume();
}



function emitKeypressEvents(stream) {
    function onData(chunk) {
        g.next(chunk.toString());
    }

    const g = emitKeys(stream);
    g.next()
    stream.on('data', onData)
}

function* emitKeys(stream) {
    while (true) {
        let ch = yield;
        stream.emit('keypress', ch)
    }
}

stepRead(function (s) {
    console.log('answer:', s)
})

如何开发命令行交互列表

实现原理

source

获取字符串的核心实现:

js
    getContent = () => {
        if (!this.haveSelected) {
            let title = '\x1B[32m?\x1B[39m \x1B[1m' + this.message + '\x1B[22m\x1B[0m \x1B[0m\x1B[2m(Use arrow keys)\x1B[22m\n';
            this.choices.forEach((choice, index) => {
                if (index === this.selected) {
                    if (index === this.choices.length - 1) {
                        title += '\x1B[36m❯ ' + choice.name + '\x1B[39m ';
                    } else {
                        title += '\x1B[36m❯ ' + choice.name + '\x1B[39m \n';
                    }
                } else {
                    if (index === this.choices.length - 1) {
                        title += `  ${choice.name} `;
                    } else {
                        title += `  ${choice.name} \n`;
                    }
                }
            });
            this.height = this.choices.length + 1;
            return title;
        } else {
            // 输入结束后的逻辑
            const name = this.choices[this.selected].name;
            let title = '\x1B[32m?\x1B[39m \x1B[1m' + this.message + '\x1B[22m\x1B[0m \x1B[36m' + name + '\x1B[39m\x1B[0m \n';
            return title;
        }
    };

架构图

w5-c3-arch.jpg

代码演示:

js
import { range, filter, map } from 'rxjs'; // https://www.npmjs.com/package/rxjs

range(1, 200)
    .pipe(
        filter(x => x % 2 === 1),
        map(x => x + x),
        filter(x => x % 3 === 0),
        filter(x => x % 6 === 0),
        filter(x => x % 9 === 0),
    )
    .subscribe(x => console.log(x));
js
import inquirer from 'inquirer'; // https://www.npmjs.com/package/inquirer

inquirer
    .prompt([
        {
           type: 'input',
           name: 'yourName',
           message: 'your name',
           default: 'noname',
           validate: function (v) {
               return typeof v === 'string'
           },
           transformer: function (v) {
               return 'name:' + v
           },
           filter: function (v) {
              return 'name[' + v + ']'
           }
        },
        {
            type: 'number',
            name: 'num',
            message: 'your number'
        },
        {
            type: 'confirm',
            name: 'choice',
            message: 'your choice',
            default: true
        },
        {
            type: 'list',
            name: 'list',
            message: 'your list',
            default: 0,
            choices: [
                { value: 1, name: 'a'},
                { value: 2, name: 'b'},
                { value: 3, name: 'c'},
            ]
        },
        {
            type: 'rawlist',
            name: 'rawlist',
            message: 'your rawlist',
            default: 0,
            choices: [
                { value: 1, name: 'a'},
                { value: 2, name: 'b'},
                { value: 3, name: 'c'},
            ]
        },
        {
            type: 'expand',
            name: 'expand',
            message: 'your expand',
            default: 'red',
            choices: [
                { key: 'R', value: 'red'},
                { key: 'G', value: 'green'},
                { key: 'B', value: 'grey'},
            ]
        },
        {
            type: 'checkbox',
            name: 'checkbox',
            message: 'your checkbox',
            default: 0,
            choices: [
                { value: 1, name: 'a'},
                { value: 2, name: 'b'},
                { value: 3, name: 'c'},
            ]
        },
        {
            type: 'password',
            name: 'password',
            message: 'your password',
        },
        {
            type: 'editor',
            name: 'editor',
            message: 'your editor',
        },
    ])
    .then((answers) => {
        console.log(answers)
    })
    .catch((error) => {
        if (error.isTtyError) {
            // Prompt couldn't be rendered in the current environment
        } else {
            // Something else went wrong
        }
    });
js
import { EventEmitter } from 'events';
import MuteStream from 'mute-stream'; // https://www.npmjs.com/package/mute-stream
import * as readline from 'node:readline'; // https://nodejs.org/api/readline.html#readline
import { fromEvent } from "rxjs";
import ansiEscapes from 'ansi-escapes'; // https://www.npmjs.com/package/ansi-escapes


const option = {
    type: 'list',
    name: 'name',
    message: 'select your name',
    choices: [
        {
            name: 'zhangSan', value: 'zhangSan'
        },
        {
            name: 'liSi', value: 'liSi'
        }
    ]
}

function Prompt(option) {
    return new Promise((resolve, reject) => {
        try {
            const list = new List(option);
            list.render();
            list.on('exit', function (answer){
               resolve(answer)
            })
        } catch (e) {
            reject(e)
        }
    })
}

class List extends EventEmitter {
    constructor(option) {
        super();
        this.name = option.name;
        this.message = option.message;
        this.choices = option.choices;
        this.input = process.stdin;
        const ms = new MuteStream();
        ms.pipe(process.stdout);
        this.output = ms;
        this.rl = readline.createInterface({
            input: this.input,
            output: this.output
        });
        this.selected = 0;
        this.height = 0;
        this.keypress = fromEvent(this.rl.input, 'keypress').forEach(this.onkeypress);
        this.haveSelected = false; // 是否已经选择完毕
    }

    onkeypress = (keymap) => {
        const key = keymap[1];
        if (key.name === 'down') {
            this.selected ++;
            if (this.selected > this.choices.length - 1) {
                this.selected = 0;
            }
            this.render()
        } else if (key.name === 'up') {
            this.selected --;
            if (this.selected < 0) {
                this.selected = this.choices.length - 1;
            }
            this.render()
        } else if (key.name === 'return') {
            this.haveSelected = true;
            this.render();
            this.close();
            this.emit('exit', this.choices[this.selected])
        }

    };

    render() {
        this.output.unmute();
        this.clean();
        this.output.write(this.getContent());
        this.output.mute();
    };

    getContent = () => {
        if (!this.haveSelected) {
            let title = '\x1B[32m?\x1B[39m \x1B[1m' + this.message + '\x1B[22m\x1B[0m \x1B[0m\x1B[2m(Use arrow keys)\x1B[22m\n';
            this.choices.forEach((choice, index) => {
                if (index === this.selected) {
                    if (index === this.choices.length - 1) {
                        title += '\x1B[36m❯ ' + choice.name + '\x1B[39m ';
                    } else {
                        title += '\x1B[36m❯ ' + choice.name + '\x1B[39m \n';
                    }
                } else {
                    if (index === this.choices.length - 1) {
                        title += `  ${choice.name} `;
                    } else {
                        title += `  ${choice.name} \n`;
                    }
                }
            });
            this.height = this.choices.length + 1;
            return title;
        } else {
            // 输入结束后的逻辑
            const name = this.choices[this.selected].name;
            let title = '\x1B[32m?\x1B[39m \x1B[1m' + this.message + '\x1B[22m\x1B[0m \x1B[36m' + name + '\x1B[39m\x1B[0m \n';
            return title;
        }
    };

    clean = () => {
       const emptyLines = ansiEscapes.eraseLines(this.height);
       this.output.write(emptyLines);
    };

    close = () => {
       this.output.unmute();
       this.rl.output.end();
       this.rl.pause();
       this.rl.close();
    }
}

Prompt(option).then(answers => {
    console.log(answers)
})

Released under the MIT License.