乐趣区

veecli脚手架实践中

前言

书接上回 vee-cli 脚手架实践 (上)

上回主要介绍了脚本命令的一些分发配置,本篇主要在于介绍创建文件的模板拉取、拷贝,主要是 create.js 下的具体逻辑

模板拉取

依赖包

[包目录结构]

  • axios (发送请求,promise 封装)
  • ora (等待 loading)
  • inquirer (询问选择命令)
  • download-git-repo (从 github 上拉取仓库)
  • ncp (拷贝文件到指定目录下)

[目录描述] 通过 axios 发送请求,获取 github 上的仓库和版本号 (ps: 见 github 开发者文档 -repo),通过 inquirer 与开发者进行命令行交互,ora 优化用户体验,download-git-repo 将仓库下载到对应的目录下,通常为.template 下,再通过 ncp 将下载的文件直接拷贝到指定的目录下

逻辑代码

获取仓库和版本

const fetchRepoList = async () => {const { data} = await axios.get(`${repoUrl}/repos`);
    return data;
}

const fetchTagList = async (repo) => {const { data} = await axios.get(`${tagUrl}/${repo}/tags`);
    return data;
}

其中固定参数可全部放入到 constants.js 中进行导出

功能函数

const waitLoading = (fn, message) => async (...args) => {const spinner = ora(message);
    spinner.start();
    const result = await fn(...args);
    spinner.succeed();
    return result;
}

const download = async (repo, tag) => {let api = `${baseUrl}/${repo}`;
    if(tag) {api += `#${tag}`;
    }
    const dest = `${downloadDirectory}/${repo}`;
    await downloadGitRepoPro(api, dest);
    return dest;
}

其中下载 github 仓库的函数是以回调函数的形式书写的,我们希望都通过 promise 的形式返回,这样可以使用 async/await 来编写代码,这里用到了 promisify(ps: 关于这个函数面试中也经常被问题到,有需要的同学可参考这篇文章如何实现 promisify 编程题 - 原理部分),另外对于多个函数传参,使用了函数柯里化的高阶函数 (ps: 函数柯里化和反柯里化也常考编程题 - 方法库部分)

导出模块

module.exports = async (projectName) => {
    // 获取仓库
    const repos = await waitLoading(fetchRepoList, 'fetching template ...')();
    const reposName = repos.map(item => item.name);
    const {repo} = await Inquirer.prompt({
        name: 'repo',
        type: 'list',
        message: 'please choice a template to create project',
        choices: reposName
    })
    // 获取版本号
    const tags = await waitLoading(fetchTagList, 'fetching tags ...')(repo);
    const tagsName = tags.map(item => item.name);
    const {tag} = await Inquirer.prompt({
        name: 'tag',
        type: 'list',
        message: 'please choice a template to create project',
        choices: tagsName
    });

    const result = await waitLoading(download, 'download template ...')(repo,tag);
    console.log(result);

    // 直接下载
    await ncpPro(result, path.resolve(projectName));
    // 模板渲染后再拷贝
}

这里将拉取的模板直接下载到了当前目录下,对于需要编译的部分将在下篇中介绍

相关包源码分析

本篇设计的包较多,由于篇幅有限,从中选取几个核心包的核心代码亮点进行分析

ora

class Ora {constructor(options) {if (typeof options === 'string') {
            options = {text: options};
        }

        this.options = {
            text: '',
            color: 'cyan',
            stream: process.stderr,
            discardStdin: true,
            ...options
        };

        this.spinner = this.options.spinner;

        this.color = this.options.color;
        this.hideCursor = this.options.hideCursor !== false;
        this.interval = this.options.interval || this.spinner.interval || 100;
        this.stream = this.options.stream;
        this.id = undefined;
        this.isEnabled = typeof this.options.isEnabled === 'boolean' ? this.options.isEnabled : isInteractive({stream: this.stream});

        // Set *after* `this.stream`
        this.text = this.options.text;
        this.prefixText = this.options.prefixText;
        this.linesToClear = 0;
        this.indent = this.options.indent;
        this.discardStdin = this.options.discardStdin;
        this.isDiscardingStdin = false;
    }

    get indent() {return this._indent;}

    set indent(indent = 0) {if (!(indent >= 0 && Number.isInteger(indent))) {throw new Error('The `indent` option must be an integer from 0 and up');
        }

        this._indent = indent;
    }

    _updateInterval(interval) {if (interval !== undefined) {this.interval = interval;}
    }

    get spinner() {return this._spinner;}

    set spinner(spinner) {
        this.frameIndex = 0;

        if (typeof spinner === 'object') {if (spinner.frames === undefined) {throw new Error('The given spinner must have a `frames` property');
            }

            this._spinner = spinner;
        } else if (process.platform === 'win32') {this._spinner = cliSpinners.line;} else if (spinner === undefined) {
            // Set default spinner
            this._spinner = cliSpinners.dots;
        } else if (cliSpinners[spinner]) {this._spinner = cliSpinners[spinner];
        } else {throw new Error(`There is no built-in spinner named '${spinner}'. See https://github.com/sindresorhus/cli-spinners/blob/master/spinners.json for a full list.`);
        }

        this._updateInterval(this._spinner.interval);
    }

    get text() {return this[TEXT];
    }

    get prefixText() {return this[PREFIX_TEXT];
    }

    get isSpinning() {return this.id !== undefined;}

    updateLineCount() {
        const columns = this.stream.columns || 80;
        const fullPrefixText = (typeof this[PREFIX_TEXT] === 'string') ? this[PREFIX_TEXT] + '-' : '';
        this.lineCount = stripAnsi(fullPrefixText + '--' + this[TEXT]).split('\n').reduce((count, line) => {return count + Math.max(1, Math.ceil(wcwidth(line) / columns));
        }, 0);
    }

    set text(value) {this[TEXT] = value;
        this.updateLineCount();}

    set prefixText(value) {this[PREFIX_TEXT] = value;
        this.updateLineCount();}

    frame() {const {frames} = this.spinner;
        let frame = frames[this.frameIndex];

        if (this.color) {frame = chalk[this.color](frame);
        }

        this.frameIndex = ++this.frameIndex % frames.length;
        const fullPrefixText = (typeof this.prefixText === 'string' && this.prefixText !== '') ? this.prefixText +' ':'';
        const fullText = typeof this.text === 'string' ? '' + this.text :'';

        return fullPrefixText + frame + fullText;
    }

    clear() {if (!this.isEnabled || !this.stream.isTTY) {return this;}

        for (let i = 0; i < this.linesToClear; i++) {if (i > 0) {this.stream.moveCursor(0, -1);
            }

            this.stream.clearLine();
            this.stream.cursorTo(this.indent);
        }

        this.linesToClear = 0;

        return this;
    }

    render() {this.clear();
        this.stream.write(this.frame());
        this.linesToClear = this.lineCount;

        return this;
    }

    start(text) {if (text) {this.text = text;}

        if (!this.isEnabled) {if (this.text) {this.stream.write(`- ${this.text}\n`);
            }

            return this;
        }

        if (this.isSpinning) {return this;}

        if (this.hideCursor) {cliCursor.hide(this.stream);
        }

        if (this.discardStdin && process.stdin.isTTY) {
            this.isDiscardingStdin = true;
            stdinDiscarder.start();}

        this.render();
        this.id = setInterval(this.render.bind(this), this.interval);

        return this;
    }

    stop() {if (!this.isEnabled) {return this;}

        clearInterval(this.id);
        this.id = undefined;
        this.frameIndex = 0;
        this.clear();
        if (this.hideCursor) {cliCursor.show(this.stream);
        }

        if (this.discardStdin && process.stdin.isTTY && this.isDiscardingStdin) {stdinDiscarder.stop();
            this.isDiscardingStdin = false;
        }

        return this;
    }

    succeed(text) {return this.stopAndPersist({symbol: logSymbols.success, text});
    }

    fail(text) {return this.stopAndPersist({symbol: logSymbols.error, text});
    }

    warn(text) {return this.stopAndPersist({symbol: logSymbols.warning, text});
    }

    info(text) {return this.stopAndPersist({symbol: logSymbols.info, text});
    }

    stopAndPersist(options = {}) {
        const prefixText = options.prefixText || this.prefixText;
        const fullPrefixText = (typeof prefixText === 'string' && prefixText !== '') ? prefixText +' ':'';
        const text = options.text || this.text;
        const fullText = (typeof text === 'string') ? '' + text :'';

        this.stop();
        this.stream.write(`${fullPrefixText}${options.symbol || ' '}${fullText}\n`);

        return this;
    }
}

核心的旋转是一个 spinners 的 json 文件,维护的一个 ora 大类

inquirer

class StateManager {constructor(configFactory, initialState, render) {
    this.initialState = initialState;
    this.render = render;
    this.currentState = {
      loadingIncrement: 0,
      value: '',
      status: 'idle',
    };

    // Default `input` to stdin
    const input = process.stdin;

    // Add mute capabilities to the output
    const output = new MuteStream();
    output.pipe(process.stdout);

    this.rl = readline.createInterface({
      terminal: true,
      input,
      output,
    });
    this.screen = new ScreenManager(this.rl);

    let config = configFactory;
    if (_.isFunction(configFactory)) {config = configFactory(this.rl);
    }

    this.config = config;

    this.onKeypress = this.onKeypress.bind(this);
    this.onSubmit = this.onSubmit.bind(this);
    this.startLoading = this.startLoading.bind(this);
    this.onLoaderTick = this.onLoaderTick.bind(this);
    this.setState = this.setState.bind(this);
    this.handleLineEvent = this.handleLineEvent.bind(this);
  }

  async execute(cb) {let { message} = this.getState();
    this.cb = cb;

    // Load asynchronous properties
    const showLoader = setTimeout(this.startLoading, 500);
    if (_.isFunction(message)) {message = await runAsync(message)();}

    this.setState({message, status: 'idle'});

    // Disable the loader if it didn't launch
    clearTimeout(showLoader);

    // Setup event listeners once we're done fetching the configs
    this.rl.input.on('keypress', this.onKeypress);
    this.rl.on('line', this.handleLineEvent);
  }

  onKeypress(value, key) {const { onKeypress = _.noop} = this.config;
    // Ignore enter keypress. The "line" event is handling those.
    if (key.name === 'enter' || key.name === 'return') {return;}

    this.setState({value: this.rl.line, error: null});
    onKeypress(this.rl.line, key, this.getState(), this.setState);
  }

  startLoading() {this.setState({ loadingIncrement: 0, status: 'loading'});
    setTimeout(this.onLoaderTick, spinner.interval);
  }

  onLoaderTick() {const { status, loadingIncrement} = this.getState();
    if (status === 'loading') {this.setState({ loadingIncrement: loadingIncrement + 1});
      setTimeout(this.onLoaderTick, spinner.interval);
    }
  }

  handleLineEvent() {const { onLine = defaultOnLine} = this.config;
    onLine(this.getState(), {
      submit: this.onSubmit,
      setState: this.setState,
    });
  }

  async onSubmit() {const state = this.getState();
    const {validate, filter} = state;
    const {validate: configValidate = () => true } = this.config;

    const {mapStateToValue = defaultMapStateToValue} = this.config;
    let value = mapStateToValue(state);

    const showLoader = setTimeout(this.startLoading, 500);
    this.rl.pause();
    try {const filteredValue = await runAsync(filter)(value);
      let isValid = configValidate(value, state);
      if (isValid === true) {isValid = await runAsync(validate)(filteredValue);
      }

      if (isValid === true) {this.onDone(filteredValue);
        clearTimeout(showLoader);
        return;
      }

      this.onError(isValid);
    } catch (err) {this.onError(err.message + '\n' + err.stack);
    }

    clearTimeout(showLoader);
    this.rl.resume();}

  onError(error) {
    this.setState({
      status: 'idle',
      error: error || 'You must provide a valid value',
    });
  }

  onDone(value) {this.setState({ status: 'done'});
    this.rl.input.removeListener('keypress', this.onKeypress);
    this.rl.removeListener('line', this.handleLineEvent);
    this.screen.done();
    this.cb(value);
  }

  setState(partialState) {this.currentState = Object.assign({}, this.currentState, partialState);
    this.onChange(this.getState());
  }

  getState() {return Object.assign({}, defaultState, this.initialState, this.currentState);
  }

  getPrefix() {const { status, loadingIncrement} = this.getState();
    let prefix = chalk.green('?');
    if (status === 'loading') {
      const frame = loadingIncrement % spinner.frames.length;
      prefix = chalk.yellow(spinner.frames[frame]);
    }

    return prefix;
  }

  onChange(state) {const { status, message, value, transformer} = this.getState();

    let error;
    if (state.error) {error = `${chalk.red('>>')} ${state.error}`;
    }

    const renderState = Object.assign(
      {prefix: this.getPrefix(),
      },
      state,
      {
        // Only pass message down if it's a string. Otherwise we're still in init state
        message: _.isFunction(message) ? 'Loading...' : message,
        value: transformer(value, { isFinal: status === 'done'}),
        validate: undefined,
        filter: undefined,
        transformer: undefined,
      }
    );
    this.screen.render(this.render(renderState, this.config), error);
  }
}

命令行的输入输出主要是 process.stdin 和 process.stdout,然后通过 readline 进行获取,其核心主要就是维护一个 StateManager 类,对于选取的内容进行获取和映射

download-git-repo

function download (repo, dest, opts, fn) {if (typeof opts === 'function') {
    fn = opts
    opts = null
  }
  opts = opts || {}
  var clone = opts.clone || false
  delete opts.clone

  repo = normalize(repo)
  var url = repo.url || getUrl(repo, clone)

  if (clone) {
    var cloneOptions = {
      checkout: repo.checkout,
      shallow: repo.checkout === 'master',
      ...opts
    }
    gitclone(url, dest, cloneOptions, function (err) {if (err === undefined) {rm(dest + '/.git')
        fn()} else {fn(err)
      }
    })
  } else {
    var downloadOptions = {
      extract: true,
      strip: 1,
      mode: '666',
      ...opts,
      headers: {
        accept: 'application/zip',
        ...(opts.headers || {})
      }
    }
    downloadUrl(url, dest, downloadOptions)
      .then(function (data) {fn()
      })
      .catch(function (err) {fn(err)
      })
  }
}

其核心是 download 和 git-clone 的包,其中 git-clone 是 git 的 js 对应的 api,通过 git clone 下来的仓库来进行流的读写操作

ncp

function ncp (source, dest, options, callback) {
  var cback = callback;

  if (!callback) {
    cback = options;
    options = {};}

  var basePath = process.cwd(),
      currentPath = path.resolve(basePath, source),
      targetPath = path.resolve(basePath, dest),
      filter = options.filter,
      rename = options.rename,
      transform = options.transform,
      clobber = options.clobber !== false,
      modified = options.modified,
      dereference = options.dereference,
      errs = null,
      started = 0,
      finished = 0,
      running = 0,
      limit = options.limit || ncp.limit || 16;

  limit = (limit < 1) ? 1 : (limit > 512) ? 512 : limit;

  startCopy(currentPath);
  
  function startCopy(source) {
    started++;
    if (filter) {if (filter instanceof RegExp) {if (!filter.test(source)) {return cb(true);
        }
      }
      else if (typeof filter === 'function') {if (!filter(source)) {return cb(true);
        }
      }
    }
    return getStats(source);
  }

  function getStats(source) {
    var stat = dereference ? fs.stat : fs.lstat;
    if (running >= limit) {return setImmediate(function () {getStats(source);
      });
    }
    running++;
    stat(source, function (err, stats) {var item = {};
      if (err) {return onError(err);
      }

      // We need to get the mode from the stats object and preserve it.
      item.name = source;
      item.mode = stats.mode;
      item.mtime = stats.mtime; //modified time
      item.atime = stats.atime; //access time

      if (stats.isDirectory()) {return onDir(item);
      }
      else if (stats.isFile()) {return onFile(item);
      }
      else if (stats.isSymbolicLink()) {
        // Symlinks don't really need to know about the mode.
        return onLink(source);
      }
    });
  }

  function onFile(file) {var target = file.name.replace(currentPath, targetPath);
    if(rename) {target =  rename(target);
    }
    isWritable(target, function (writable) {if (writable) {return copyFile(file, target);
      }
      if(clobber) {rmFile(target, function () {copyFile(file, target);
        });
      }
      if (modified) {
        var stat = dereference ? fs.stat : fs.lstat;
        stat(target, function(err, stats) {
            //if souce modified time greater to target modified time copy file
            if (file.mtime.getTime()>stats.mtime.getTime())
                copyFile(file, target);
            else return cb();});
      }
      else {return cb();
      }
    });
  }

  function copyFile(file, target) {var readStream = fs.createReadStream(file.name),
        writeStream = fs.createWriteStream(target, { mode: file.mode});
    
    readStream.on('error', onError);
    writeStream.on('error', onError);
    
    if(transform) {transform(readStream, writeStream, file);
    } else {writeStream.on('open', function() {readStream.pipe(writeStream);
      });
    }
    writeStream.once('finish', function() {if (modified) {
            //target file modified date sync.
            fs.utimesSync(target, file.atime, file.mtime);
            cb();}
        else cb();});
  }

  function rmFile(file, done) {fs.unlink(file, function (err) {if (err) {return onError(err);
      }
      return done();});
  }

  function onDir(dir) {var target = dir.name.replace(currentPath, targetPath);
    isWritable(target, function (writable) {if (writable) {return mkDir(dir, target);
      }
      copyDir(dir.name);
    });
  }

  function mkDir(dir, target) {fs.mkdir(target, dir.mode, function (err) {if (err) {return onError(err);
      }
      copyDir(dir.name);
    });
  }

  function copyDir(dir) {fs.readdir(dir, function (err, items) {if (err) {return onError(err);
      }
      items.forEach(function (item) {startCopy(path.join(dir, item));
      });
      return cb();});
  }

  function onLink(link) {var target = link.replace(currentPath, targetPath);
    fs.readlink(link, function (err, resolvedPath) {if (err) {return onError(err);
      }
      checkLink(resolvedPath, target);
    });
  }

  function checkLink(resolvedPath, target) {if (dereference) {resolvedPath = path.resolve(basePath, resolvedPath);
    }
    isWritable(target, function (writable) {if (writable) {return makeLink(resolvedPath, target);
      }
      fs.readlink(target, function (err, targetDest) {if (err) {return onError(err);
        }
        if (dereference) {targetDest = path.resolve(basePath, targetDest);
        }
        if (targetDest === resolvedPath) {return cb();
        }
        return rmFile(target, function () {makeLink(resolvedPath, target);
        });
      });
    });
  }

  function makeLink(linkPath, target) {fs.symlink(linkPath, target, function (err) {if (err) {return onError(err);
      }
      return cb();});
  }

  function isWritable(path, done) {fs.lstat(path, function (err) {if (err) {if (err.code === 'ENOENT') return done(true);
        return done(false);
      }
      return done(false);
    });
  }

  function onError(err) {if (options.stopOnError) {return cback(err);
    }
    else if (!errs && options.errs) {errs = fs.createWriteStream(options.errs);
    }
    else if (!errs) {errs = [];
    }
    if (typeof errs.write === 'undefined') {errs.push(err);
    }
    else {errs.write(err.stack + '\n\n');
    }
    return cb();}

  function cb(skipped) {if (!skipped) running--;
    finished++;
    if ((started === finished) && (running === 0)) {if (cback !== undefined) {return errs ? cback(errs) : cback(null);
      }
    }
  }
}

path 和 fs 模块的核心应用,对于读写文件的应用的同学可以参考一下写法

总结

本篇主要描述的是模板拉取及拷贝,对于复杂的需要编译的模板如何编写,且听下回分解

未完待续 …

参考

  • ora.js 源码
  • inquirer.js 源码
  • inquirer.js —— 一个用户与命令行交互的工具
  • download-git-repo.js 源码
  • ncp.js 源码
退出移动版