inquirer.js 原理分析

最近要寫一些腳本工具,爲了方便作一些簡單的交互,忽然想起 Vue-Cli 裏的那種交互不錯,而後看了一下,是用了inquirer.js,那麼就簡單地聊聊諸如Vue-Cli等工具用到的Inquirer.js如何工做的吧。javascript

這裏先把inquirer.js的github連接奉上:
github.com/SBoudrias/I…html

而後開始分析分析之旅vue

1. 先從用法和效果看起

這裏先貼一些 Vue-Cli 的截圖吧:
java

image.png
image.png

好,再來看看實際代碼樣例:

以列表做例子 list.js

'use strict';
let inquirer = require('inquirer');

inquirer
  .prompt([
    {
      type: 'list',
      name: 'action',
      message: '你如今想幹嗎',
      choices: [
        '打代碼',
        new inquirer.Separator(),
        {
          name: '叫個小姐姐上門',
          disabled: '不能夠!'
        },
        '上廁所',
      ]
    },
    {
      type: 'list',
      name: 'os',
      message: '你打代碼的系統是啥',
      choices: ['macOS', 'Windows', 'Centos', 'Ubuntu', 'FreeBSD', 'Others'],
      filter: function(val) {
        return val.toLowerCase();
      }
    }
  ])
  .then(answers => {
    console.log(JSON.stringify(answers, null, ' '));
  });
複製代碼

效果以下

image.png

2. 再來看看如何實現的

先從哪裏看起

先看看項目中的 package.json 看看它有沒有依賴什麼庫。node

{
  "private": true,
  "devDependencies": {
    "@babel/core": "^7.4.5",
    "@babel/preset-env": "^7.4.5",
    "babel-jest": "^24.8.0",
    "codecov": "^3.5.0",
    "eslint": "^5.16.0",
    "eslint-config-prettier": "^5.0.0",
    "eslint-config-xo": "^0.26.0",
    "eslint-plugin-prettier": "^3.1.0",
    "husky": "^2.4.1",
    "jest": "^24.8.0",
    "lerna": "^3.15.0",
    "lint-staged": "^8.2.1",
    "prettier": "^1.18.2"
  },
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "bootstrap": "lerna bootstrap --no-ci",
    "pretest": "eslint .",
    "test": "jest --coverage && lerna exec npm test --scope inquirer"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.json": [
      "prettier --write",
      "git add"
    ],
    "*.js": [
      "eslint --fix",
      "git add"
    ]
  },
  "jest": {
    "coverageDirectory": "./coverage/",
    "collectCoverage": true
  },
  "dependencies": {}
}
複製代碼

看了以後發現這裏的 package.json 並非咱們npm安裝時的json,直接進 packages/inquirer/ 裏看到的纔是真的。git

{
  "name": "inquirer",
  "version": "6.4.1",
  "description": "A collection of common interactive command line user interfaces.",
  "author": "Simon Boudrias <admin@simonboudrias.com>",
  "files": [
    "lib",
    "README.md"
  ],
  "main": "lib/inquirer.js",
  "keywords": [
    "command",
    "prompt",
    "stdin",
    "cli",
    "tty",
    "menu"
  ],
  "engines": {
    "node": ">=6.0.0"
  },
  "devDependencies": {
    "chai": "^4.2.0",
    "chalk-pipe": "^2.0.0",
    "cmdify": "^0.0.4",
    "mocha": "^5.0.0",
    "mockery": "^2.1.0",
    "nyc": "^13.1.0",
    "sinon": "^7.1.1"
  },
  "scripts": {
    "test": "nyc mocha test/**/* -r ./test/before",
    "posttest": "nyc report --reporter=text-lcov > ../../coverage/nyc-report.lcov",
    "prepublishOnly": "cp ../../README.md .",
    "postpublish": "rm -f README.md"
  },
  "repository": "SBoudrias/Inquirer.js",
  "license": "MIT",
  "dependencies": {
    "ansi-escapes": "^3.2.0",
    "chalk": "^2.4.2",
    "cli-cursor": "^2.1.0",
    "cli-width": "^2.0.0",
    "external-editor": "^3.0.3",
    "figures": "^2.0.0",
    "lodash": "^4.17.11",
    "mute-stream": "0.0.7",
    "run-async": "^2.2.0",
    "rxjs": "^6.4.0",
    "string-width": "^2.1.0",
    "strip-ansi": "^5.1.0",
    "through": "^2.3.6"
  }
}
複製代碼

看主文件

接着看到 "main": "lib/inquirer.js" ,那麼咱們就從這裏看起。github

// lib/inquirer.js
'use strict';
/** * Inquirer.js * A collection of common interactive command line user interfaces. */

var inquirer = module.exports;

/** * Client interfaces */

inquirer.prompts = {};

inquirer.Separator = require('./objects/separator');

inquirer.ui = {
  BottomBar: require('./ui/bottom-bar'),
  Prompt: require('./ui/prompt')
};

/** * Create a new self-contained prompt module. */
inquirer.createPromptModule = function(opt) {
  var promptModule = function(questions) {
    var ui = new inquirer.ui.Prompt(promptModule.prompts, opt);
    var promise = ui.run(questions);

    // Monkey patch the UI on the promise object so
    // that it remains publicly accessible.
    promise.ui = ui;

    return promise;
  };

  promptModule.prompts = {};

  /** * Register a prompt type * @param {String} name Prompt type name * @param {Function} prompt Prompt constructor * @return {inquirer} */

  promptModule.registerPrompt = function(name, prompt) {
    promptModule.prompts[name] = prompt;
    return this;
  };

  /** * Register the defaults provider prompts */

  promptModule.restoreDefaultPrompts = function() {
    this.registerPrompt('list', require('./prompts/list'));
    this.registerPrompt('input', require('./prompts/input'));
    this.registerPrompt('number', require('./prompts/number'));
    this.registerPrompt('confirm', require('./prompts/confirm'));
    this.registerPrompt('rawlist', require('./prompts/rawlist'));
    this.registerPrompt('expand', require('./prompts/expand'));
    this.registerPrompt('checkbox', require('./prompts/checkbox'));
    this.registerPrompt('password', require('./prompts/password'));
    this.registerPrompt('editor', require('./prompts/editor'));
  };

  promptModule.restoreDefaultPrompts();

  return promptModule;
};

/** * Public CLI helper interface * @param {Array|Object|Rx.Observable} questions - Questions settings array * @param {Function} cb - Callback being passed the user answers * @return {inquirer.ui.Prompt} */

inquirer.prompt = inquirer.createPromptModule();

// Expose helper functions on the top level for easiest usage by common users
inquirer.registerPrompt = function(name, prompt) {
  inquirer.prompt.registerPrompt(name, prompt);
};

inquirer.restoreDefaultPrompts = function() {
  inquirer.prompt.restoreDefaultPrompts();
};

複製代碼

而後咱們從用法上 inquirer.prompt(questions) 去看,一步步往下,找到 inquirer.prompt = inquirer.createPromptModule();npm

往裏面看 promptModule 知道實際調用等於 promptModule(questions) 。編程

追prompt.js

接着應該往下研究 inquirer.ui.Prompt 即 ./ui/prompt.js 了。json

// lib/ui/prompt.js
'use strict';
var _ = require('lodash');
var { defer, empty, from, of } = require('rxjs');
var { concatMap, filter, publish, reduce } = require('rxjs/operators');
var runAsync = require('run-async');
var utils = require('../utils/utils');
var Base = require('./baseUI');

/** * Base interface class other can inherits from */

class PromptUI extends Base {
  constructor(prompts, opt) {
    super(opt);
    this.prompts = prompts;
  }

  run(questions) {
    // Keep global reference to the answers
    this.answers = {};

    // Make sure questions is an array.
    if (_.isPlainObject(questions)) {
      questions = [questions];
    }

    // Create an observable, unless we received one as parameter.
    // Note: As this is a public interface, we cannot do an instanceof check as we won't
    // be using the exact same object in memory.
    var obs = _.isArray(questions) ? from(questions) : questions;

    this.process = obs.pipe(
      concatMap(this.processQuestion.bind(this)),
      publish() // Creates a hot Observable. It prevents duplicating prompts.
    );

    this.process.connect();

    return this.process
      .pipe(
        reduce((answers, answer) => {
          _.set(this.answers, answer.name, answer.answer);
          return this.answers;
        }, {})
      )
      .toPromise(Promise)
      .then(this.onCompletion.bind(this));
  }

  /** * Once all prompt are over */

  onCompletion() {
    this.close();

    return this.answers;
  }

  processQuestion(question) {
    question = _.clone(question);
    return defer(() => {
      var obs = of(question);

      return obs.pipe(
        concatMap(this.setDefaultType.bind(this)),
        concatMap(this.filterIfRunnable.bind(this)),
        concatMap(() =>
          utils.fetchAsyncQuestionProperty(question, 'message', this.answers)
        ),
        concatMap(() =>
          utils.fetchAsyncQuestionProperty(question, 'default', this.answers)
        ),
        concatMap(() =>
          utils.fetchAsyncQuestionProperty(question, 'choices', this.answers)
        ),
        concatMap(this.fetchAnswer.bind(this))
      );
    });
  }

  fetchAnswer(question) {
    var Prompt = this.prompts[question.type];
    this.activePrompt = new Prompt(question, this.rl, this.answers);
    return defer(() =>
      from(
        this.activePrompt.run().then(answer => ({ name: question.name, answer: answer }))
      )
    );
  }

  setDefaultType(question) {
    // Default type to input
    if (!this.prompts[question.type]) {
      question.type = 'input';
    }

    return defer(() => of(question));
  }

  filterIfRunnable(question) {
    if (question.when === false) {
      return empty();
    }

    if (!_.isFunction(question.when)) {
      return of(question);
    }

    var answers = this.answers;
    return defer(() =>
      from(
        runAsync(question.when)(answers).then(shouldRun => {
          if (shouldRun) {
            return question;
          }
        })
      ).pipe(filter(val => val != null))
    );
  }
}

module.exports = PromptUI;

複製代碼

沒看出來有操做標準輸入輸出的代碼,大膽猜想都是在父類 Base 中完成基本 stdin 和 stdout 的操做(通常來講確實要這樣😉)。

UI操做基類

// lib/ui/baseUI.js
'use strict';
var _ = require('lodash');
var MuteStream = require('mute-stream');
var readline = require('readline');

/** * Base interface class other can inherits from */

class UI {
  constructor(opt) {
    // Instantiate the Readline interface
    // @Note: Don't reassign if already present (allow test to override the Stream)
    if (!this.rl) {
      this.rl = readline.createInterface(setupReadlineOptions(opt));
    }

    this.rl.resume();

    this.onForceClose = this.onForceClose.bind(this);

    // Make sure new prompt start on a newline when closing
    process.on('exit', this.onForceClose);

    // Terminate process on SIGINT (which will call process.on('exit') in return)
    this.rl.on('SIGINT', this.onForceClose);
  }

  /** * Handle the ^C exit * @return {null} */

  onForceClose() {
    this.close();
    process.kill(process.pid, 'SIGINT');
    console.log('');
  }

  /** * Close the interface and cleanup listeners */

  close() {
    // Remove events listeners
    this.rl.removeListener('SIGINT', this.onForceClose);
    process.removeListener('exit', this.onForceClose);

    this.rl.output.unmute();

    if (this.activePrompt && typeof this.activePrompt.close === 'function') {
      this.activePrompt.close();
    }

    // Close the readline
    this.rl.output.end();
    this.rl.pause();
    this.rl.close();
  }
}

function setupReadlineOptions(opt) {
  opt = opt || {};

  // Default `input` to stdin
  var input = opt.input || process.stdin;

  // Add mute capabilities to the output
  var ms = new MuteStream();
  ms.pipe(opt.output || process.stdout);
  var output = ms;

  return _.extend(
    {
      terminal: true,
      input: input,
      output: output
    },
    _.omit(opt, ['input', 'output'])
  );
}

module.exports = UI;
複製代碼

這裏能夠看到用了兩個庫,一個是第三方的 mute-stream ,一個是 node 官方的 readline 。
readline 文檔安排一下先:英文文檔    中文文檔

簡單來講, readline 就是作可讀流可寫流的操做庫, mute-stream 就是作可寫流的靜默寫操做庫。
從 baseUI.js 代碼來看,它主要作了如下幾件事情:

  • constructor 建立 readline 的實例,恢復 input 流,監聽進程退出事件;
  • 觸發 onForceClose 時,先調用了 close 函數,最終關閉進程關閉;
  • close 函數,先把事件的監聽移除(防止內存泄漏等),再把 output 流解除靜默輸出狀態;而後對活躍的 prompt 進行關閉,再對 readline 結束並關閉;

回頭再看

咱們這裏先無論控制檯中的交互如何作,先把上面的 baseUI.js 作的事情有個大概的認識先。

回過頭來,看看 inquirer.js 裏關於 prompt 的關鍵代碼 var promise = ui.run(questions); ,整個問答交互的核心應該就是在這個 run 函數裏面,咱們來看看。

可是這裏又涉及到另外一個庫 rxjs 。

這裏引用一句話描述 rxjs :

RxJS 是一套處理異步編程的 API 庫。

因爲篇幅有限,這裏就先不展開說了。有興趣的能夠先查查這個庫的一些資料。

注意下這行代碼: var obs = _.isArray(questions) ? from(questions) : questions; 
這裏的意思實際上是,若是 questions 是一個數組的話,就把它們轉換成 observable 。

run 函數處理過程以下流程圖:

而後再往 fetchAnswer 這個函數,發現有點意思的是:對於每種不一樣詢問類型,都是直接 new 出不一樣的插件,而後所有所有交給插件內部完成,而後只接受返回結果。

這樣作的好處就是可以很是容易地擴展不一樣的類型,甚至是作定製化等,都很是的方便。

接下來咱們要繼續分析的話就要到插件內部去看了。

3. 插件分析

接下來咱們來分析裏面的插件,就以一個舉例便可。這裏以 list.js 作例子。
老規矩,上代碼。

// lib/prompts/list.js
'use strict';
/** * `list` type prompt */

var _ = require('lodash');
var chalk = require('chalk');
var figures = require('figures');
var cliCursor = require('cli-cursor');
var runAsync = require('run-async');
var { flatMap, map, take, takeUntil } = require('rxjs/operators');
var Base = require('./base');
var observe = require('../utils/events');
var Paginator = require('../utils/paginator');

class ListPrompt extends Base {
  constructor(questions, rl, answers) {
    super(questions, rl, answers);

    if (!this.opt.choices) {
      this.throwParamError('choices');
    }

    this.firstRender = true;
    this.selected = 0;

    var def = this.opt.default;

    // If def is a Number, then use as index. Otherwise, check for value.
    if (_.isNumber(def) && def >= 0 && def < this.opt.choices.realLength) {
      this.selected = def;
    } else if (!_.isNumber(def) && def != null) {
      let index = _.findIndex(this.opt.choices.realChoices, ({ value }) => value === def);
      this.selected = Math.max(index, 0);
    }

    // Make sure no default is set (so it won't be printed)
    this.opt.default = null;

    this.paginator = new Paginator(this.screen);
  }

  /** * Start the Inquiry session * @param {Function} cb Callback when prompt is done * @return {this} */

  _run(cb) {
    this.done = cb;

    var self = this;

    var events = observe(this.rl);
    events.normalizedUpKey.pipe(takeUntil(events.line)).forEach(this.onUpKey.bind(this));
    events.normalizedDownKey
      .pipe(takeUntil(events.line))
      .forEach(this.onDownKey.bind(this));
    events.numberKey.pipe(takeUntil(events.line)).forEach(this.onNumberKey.bind(this));
    events.line
      .pipe(
        take(1),
        map(this.getCurrentValue.bind(this)),
        flatMap(value => runAsync(self.opt.filter)(value).catch(err => err))
      )
      .forEach(this.onSubmit.bind(this));

    // Init the prompt
    cliCursor.hide();
    this.render();

    return this;
  }

  /** * Render the prompt to screen * @return {ListPrompt} self */

  render() {
    // Render question
    var message = this.getQuestion();

    if (this.firstRender) {
      message += chalk.dim('(Use arrow keys)');
    }

    // Render choices or answer depending on the state
    if (this.status === 'answered') {
      message += chalk.cyan(this.opt.choices.getChoice(this.selected).short);
    } else {
      var choicesStr = listRender(this.opt.choices, this.selected);
      var indexPosition = this.opt.choices.indexOf(
        this.opt.choices.getChoice(this.selected)
      );
      message +=
        '\n' + this.paginator.paginate(choicesStr, indexPosition, this.opt.pageSize);
    }

    this.firstRender = false;

    this.screen.render(message);
  }

  /** * When user press `enter` key */

  onSubmit(value) {
    this.status = 'answered';

    // Rerender prompt
    this.render();

    this.screen.done();
    cliCursor.show();
    this.done(value);
  }

  getCurrentValue() {
    return this.opt.choices.getChoice(this.selected).value;
  }

  /** * When user press a key */
  onUpKey() {
    var len = this.opt.choices.realLength;
    this.selected = this.selected > 0 ? this.selected - 1 : len - 1;
    this.render();
  }

  onDownKey() {
    var len = this.opt.choices.realLength;
    this.selected = this.selected < len - 1 ? this.selected + 1 : 0;
    this.render();
  }

  onNumberKey(input) {
    if (input <= this.opt.choices.realLength) {
      this.selected = input - 1;
    }

    this.render();
  }
}

/** * Function for rendering list choices * @param {Number} pointer Position of the pointer * @return {String} Rendered content */
function listRender(choices, pointer) {
  var output = '';
  var separatorOffset = 0;

  choices.forEach((choice, i) => {
    if (choice.type === 'separator') {
      separatorOffset++;
      output += ' ' + choice + '\n';
      return;
    }

    if (choice.disabled) {
      separatorOffset++;
      output += ' - ' + choice.name;
      output += ' (' + (_.isString(choice.disabled) ? choice.disabled : 'Disabled') + ')';
      output += '\n';
      return;
    }

    var isSelected = i - separatorOffset === pointer;
    var line = (isSelected ? figures.pointer + ' ' : ' ') + choice.name;
    if (isSelected) {
      line = chalk.cyan(line);
    }

    output += line + ' \n';
  });

  return output.replace(/\n$/, '');
}

module.exports = ListPrompt;

複製代碼

留意一下 lib/ui/prompt.js 裏的 this.activePrompt.run() ,在 ListPrompt 並無聲明,應該是來自父類方法,上代碼看看。

// lib/prompts/base.js
'use strict';
/** * Base prompt implementation * Should be extended by prompt types. */

var _ = require('lodash');
var chalk = require('chalk');
var runAsync = require('run-async');
var { filter, flatMap, share, take, takeUntil } = require('rxjs/operators');
var Choices = require('../objects/choices');
var ScreenManager = require('../utils/screen-manager');

class Prompt {
  constructor(question, rl, answers) {
    // Setup instance defaults property
    _.assign(this, {
      answers: answers,
      status: 'pending'
    });

    // Set defaults prompt options
    this.opt = _.defaults(_.clone(question), {
      validate: () => true,
      filter: val => val,
      when: () => true,
      suffix: '',
      prefix: chalk.green('?')
    });

    // Make sure name is present
    if (!this.opt.name) {
      this.throwParamError('name');
    }

    // Set default message if no message defined
    if (!this.opt.message) {
      this.opt.message = this.opt.name + ':';
    }

    // Normalize choices
    if (Array.isArray(this.opt.choices)) {
      this.opt.choices = new Choices(this.opt.choices, answers);
    }

    this.rl = rl;
    this.screen = new ScreenManager(this.rl);
  }

  /** * Start the Inquiry session and manage output value filtering * @return {Promise} */

  run() {
    return new Promise(resolve => {
      this._run(value => resolve(value));
    });
  }

  // Default noop (this one should be overwritten in prompts)
  _run(cb) {
    cb();
  }

  /** * Throw an error telling a required parameter is missing * @param {String} name Name of the missing param * @return {Throw Error} */

  throwParamError(name) {
    throw new Error('You must provide a `' + name + '` parameter');
  }

  /** * Called when the UI closes. Override to do any specific cleanup necessary */
  close() {
    this.screen.releaseCursor();
  }

  /** * Run the provided validation method each time a submit event occur. * @param {Rx.Observable} submit - submit event flow * @return {Object} Object containing two observables: `success` and `error` */
  handleSubmitEvents(submit) {
    var self = this;
    var validate = runAsync(this.opt.validate);
    var asyncFilter = runAsync(this.opt.filter);
    var validation = submit.pipe(
      flatMap(value =>
        asyncFilter(value, self.answers).then(
          filteredValue =>
            validate(filteredValue, self.answers).then(
              isValid => ({ isValid: isValid, value: filteredValue }),
              err => ({ isValid: err })
            ),
          err => ({ isValid: err })
        )
      ),
      share()
    );

    var success = validation.pipe(
      filter(state => state.isValid === true),
      take(1)
    );
    var error = validation.pipe(
      filter(state => state.isValid !== true),
      takeUntil(success)
    );

    return {
      success: success,
      error: error
    };
  }

  /** * Generate the prompt question string * @return {String} prompt question string */

  getQuestion() {
    var message =
      this.opt.prefix +
      ' ' +
      chalk.bold(this.opt.message) +
      this.opt.suffix +
      chalk.reset(' ');

    // Append the default if available, and if question isn't answered
    if (this.opt.default != null && this.status !== 'answered') {
      // If default password is supplied, hide it
      if (this.opt.type === 'password') {
        message += chalk.italic.dim('[hidden] ');
      } else {
        message += chalk.dim('(' + this.opt.default + ') ');
      }
    }

    return message;
  }
}

module.exports = Prompt;
複製代碼

原來 run() 是調用了子類方法 _run() ,那麼咱們接着分析吧。

流程圖

run() 函數

劃重點!!! render() 函數

其實分析到這裏,流程已經挺清晰的了,可是問題來了:

當咱們選擇好答案按回車以後,會自動把選項收起來,進行下一個問題,它是如何實現重繪的呢?難道它還能把已經輸出的流數據收回去?🤔

我想,你們都看出來了,奧妙確定在 this.screen.render(message); 的 screen 裏面。

screen分析

list.js 裏的 screen 並無在該類中進行初始化,那麼必然是在其父類當中。

父類 base 代碼已經在上面貼過了,其中裏面大量用到的 chalk ,簡單地說就是控制檯輸出帶顏色的庫,貼個圖大家立刻就懂了。

image.png

父類的構造函數中,找到關鍵代碼 this.screen = new ScreenManager(this.rl); 而 ScreenManager 來自 ../utils/screen-manager 。老規矩走起。

// lib/utils/screen-manage.js
'use strict';
var _ = require('lodash');
var util = require('./readline');
var cliWidth = require('cli-width');
var stripAnsi = require('strip-ansi');
var stringWidth = require('string-width');

function height(content) {
  return content.split('\n').length;
}

function lastLine(content) {
  return _.last(content.split('\n'));
}

class ScreenManager {
  constructor(rl) {
    // These variables are keeping information to allow correct prompt re-rendering
    this.height = 0;
    this.extraLinesUnderPrompt = 0;

    this.rl = rl;
  }

  render(content, bottomContent) {
    this.rl.output.unmute();
    this.clean(this.extraLinesUnderPrompt);

    /** * Write message to screen and setPrompt to control backspace */

    var promptLine = lastLine(content);
    var rawPromptLine = stripAnsi(promptLine);

    // Remove the rl.line from our prompt. We can't rely on the content of
    // rl.line (mainly because of the password prompt), so just rely on it's
    // length.
    var prompt = rawPromptLine;
    if (this.rl.line.length) {
      prompt = prompt.slice(0, -this.rl.line.length);
    }

    this.rl.setPrompt(prompt);

    // SetPrompt will change cursor position, now we can get correct value
    var cursorPos = this.rl._getCursorPos();
    var width = this.normalizedCliWidth();

    content = this.forceLineReturn(content, width);
    if (bottomContent) {
      bottomContent = this.forceLineReturn(bottomContent, width);
    }

    // Manually insert an extra line if we're at the end of the line.
    // This prevent the cursor from appearing at the beginning of the
    // current line.
    if (rawPromptLine.length % width === 0) {
      content += '\n';
    }

    var fullContent = content + (bottomContent ? '\n' + bottomContent : '');
    this.rl.output.write(fullContent);

    /** * Re-adjust the cursor at the correct position. */

    // We need to consider parts of the prompt under the cursor as part of the bottom
    // content in order to correctly cleanup and re-render.
    var promptLineUpDiff = Math.floor(rawPromptLine.length / width) - cursorPos.rows;
    var bottomContentHeight =
      promptLineUpDiff + (bottomContent ? height(bottomContent) : 0);
    if (bottomContentHeight > 0) {
      util.up(this.rl, bottomContentHeight);
    }

    // Reset cursor at the beginning of the line
    util.left(this.rl, stringWidth(lastLine(fullContent)));

    // Adjust cursor on the right
    if (cursorPos.cols > 0) {
      util.right(this.rl, cursorPos.cols);
    }

    /** * Set up state for next re-rendering */
    this.extraLinesUnderPrompt = bottomContentHeight;
    this.height = height(fullContent);

    this.rl.output.mute();
  }

  clean(extraLines) {
    if (extraLines > 0) {
      util.down(this.rl, extraLines);
    }

    util.clearLine(this.rl, this.height);
  }

  done() {
    this.rl.setPrompt('');
    this.rl.output.unmute();
    this.rl.output.write('\n');
  }

  releaseCursor() {
    if (this.extraLinesUnderPrompt > 0) {
      util.down(this.rl, this.extraLinesUnderPrompt);
    }
  }

  normalizedCliWidth() {
    var width = cliWidth({
      defaultWidth: 80,
      output: this.rl.output
    });
    return width;
  }

  breakLines(lines, width) {
    // Break lines who're longer than the cli width so we can normalize the natural line
    // returns behavior across terminals.
    width = width || this.normalizedCliWidth();
    var regex = new RegExp('(?:(?:\\033[[0-9;]*m)*.?){1,' + width + '}', 'g');
    return lines.map(line => {
      var chunk = line.match(regex);
      // Last match is always empty
      chunk.pop();
      return chunk || '';
    });
  }

  forceLineReturn(content, width) {
    width = width || this.normalizedCliWidth();
    return _.flatten(this.breakLines(content.split('\n'), width)).join('\n');
  }
}

module.exports = ScreenManager;
複製代碼

render 流程圖走一個。

留意一下它是如何清除渲染內容的,看 clean() 函數。追到 lib/utils/readline.js 發現這行代碼:
rl.output.write(ansiEscapes.eraseLines(len)); 使用了 ansi-escapes 庫。

4. 最終答案

發現這個庫的代碼好短,感受要解開了!

'use strict';
const x = module.exports;
const ESC = '\u001B[';
const OSC = '\u001B]';
const BEL = '\u0007';
const SEP = ';';
const isTerminalApp = process.env.TERM_PROGRAM === 'Apple_Terminal';
const fs = require('fs');

x.cursorTo = (x, y) => {
	if (typeof x !== 'number') {
		throw new TypeError('The `x` argument is required');
	}

	if (typeof y !== 'number') {
		return ESC + (x + 1) + 'G';
	}

	return ESC + (y + 1) + ';' + (x + 1) + 'H';
};

x.cursorMove = (x, y) => {
	if (typeof x !== 'number') {
		throw new TypeError('The `x` argument is required');
	}

	let ret = '';

	if (x < 0) {
		ret += ESC + (-x) + 'D';
	} else if (x > 0) {
		ret += ESC + x + 'C';
	}

	if (y < 0) {
		ret += ESC + (-y) + 'A';
	} else if (y > 0) {
		ret += ESC + y + 'B';
	}

	return ret;
};

x.cursorUp = count => ESC + (typeof count === 'number' ? count : 1) + 'A';
x.cursorDown = count => ESC + (typeof count === 'number' ? count : 1) + 'B';
x.cursorForward = count => ESC + (typeof count === 'number' ? count : 1) + 'C';
x.cursorBackward = count => ESC + (typeof count === 'number' ? count : 1) + 'D';

x.cursorLeft = ESC + 'G';
x.cursorSavePosition = ESC + (isTerminalApp ? '7' : 's');
x.cursorRestorePosition = ESC + (isTerminalApp ? '8' : 'u');
x.cursorGetPosition = ESC + '6n';
x.cursorNextLine = ESC + 'E';
x.cursorPrevLine = ESC + 'F';
x.cursorHide = ESC + '?25l';
x.cursorShow = ESC + '?25h';

x.eraseLines = count => {
	let clear = '';

	for (let i = 0; i < count; i++) {
		clear += x.eraseLine + (i < count - 1 ? x.cursorUp() : '');
	}

	if (count) {
		clear += x.cursorLeft;
	}

	// fs.writeFileSync('log.txt', JSON.stringify(clear), {flag: 'a'});
	return clear;
};

x.eraseEndLine = ESC + 'K';
x.eraseStartLine = ESC + '1K';
x.eraseLine = ESC + '2K';
x.eraseDown = ESC + 'J';
x.eraseUp = ESC + '1J';
x.eraseScreen = ESC + '2J';
x.scrollUp = ESC + 'S';
x.scrollDown = ESC + 'T';

x.clearScreen = '\u001Bc';

x.clearTerminal = process.platform === 'win32' ?
	`${x.eraseScreen}${ESC}0f` :
	// 1. Erases the screen (Only done in case `2` is not supported)
	// 2. Erases the whole screen including scrollback buffer
	// 3. Moves cursor to the top-left position
	// More info: https://www.real-world-systems.com/docs/ANSIcode.html
	`${x.eraseScreen}${ESC}3J${ESC}H`;

x.beep = BEL;

x.link = (text, url) => {
	return [
		OSC,
		'8',
		SEP,
		SEP,
		url,
		BEL,
		text,
		OSC,
		'8',
		SEP,
		SEP,
		BEL
	].join('');
};

x.image = (buf, opts) => {
	opts = opts || {};

	let ret = OSC + '1337;File=inline=1';

	if (opts.width) {
		ret += `;width=${opts.width}`;
	}

	if (opts.height) {
		ret += `;height=${opts.height}`;
	}

	if (opts.preserveAspectRatio === false) {
		ret += ';preserveAspectRatio=0';
	}

	return ret + ':' + buf.toString('base64') + BEL;
};

x.iTerm = {};

x.iTerm.setCwd = cwd => OSC + '50;CurrentDir=' + (cwd || process.cwd()) + BEL;
複製代碼

其實有經驗的同窗一眼就看出來是怎麼回事了:

就是利用ANSI控制碼控制終端操做。能夠看看經常使用ANSI控制碼

好比說:
我先輸出內容: abc\n123\n666\nXXX 
而後我在一秒後再輸出:\u001B[2K\u001B[1A\u001B[2K\u001B[1A\u001B[2K\u001B[1A\u001B[2Kcontent

最終輸出結果就是:
(一開始)
abc
123
666
XXX
(一秒後)
abc
content

簡單地描述這兩個控制碼:
\u001B[2K :清除整行,光標不動;
\u001B[nA :光標上移n行;

通過控制碼的組合和邏輯的控制,便可實現 inquirer.js 的交互功能。
草草結束,文章太長寫起來有點卡了。

Github 倉庫地址:github.com/scott-leung…
原本想本身實現一個簡單的 inquirer.js 的,可是時間緣由,後面有時間再說吧☺️。


參考文章:
[1] RxJS v6 學習指南
[2] 經常使用ANSI控制碼表

歡迎轉載,轉載時請標註來源出處。Scott Leung(響螢)

相關文章
相關標籤/搜索