前言

书接上回 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源码