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