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