书接上回 vee-cli脚手架实践(上)
上回主要介绍了脚本命令的一些分发配置,本篇主要在于介绍创建文件的模板拉取、拷贝,主要是create.js下的具体逻辑
[包目录结构]
[目录描述] 通过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: 函数柯里化和反柯里化也常考编程题-方法库部分)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Oy5sIXgg-1593680247874)(https://vleedesigntheory.github.io/tech/front/cli20200702/cli03.jpeg)]
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));
// 模板渲染后再拷贝
}
这里将拉取的模板直接下载到了当前目录下,对于需要编译的部分将在下篇中介绍
本篇设计的包较多,由于篇幅有限,从中选取几个核心包的核心代码亮点进行分析
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大类
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类,对于选取的内容进行获取和映射
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下来的仓库来进行流的读写操作
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模块的核心应用,对于读写文件的应用的同学可以参考一下写法
本篇主要描述的是模板拉取及拷贝,对于复杂的需要编译的模板如何编写,且听下回分解
未完待续…