为什么要弄个脚手架
对于我个人,经常写些 demo,或者写一个新项目的时候,要么就是把以前的项目模板复制一份,要么就是重新搭建一份,显得比较麻烦,浪费时间,所以就有了搭建一个能满足自己需要的脚手架。
脚手架的效果
这是一个基本的脚手架,init 一个项目,输入项目名称,版本号等信息,然后从 git 仓库拷贝一份自己需要的项目模板。类似 vue 的 vue-cli 或者 react 的 create-react-app,只是这个比较简单.
基本思路参考下图
这部分参考了掘金 @张国钰大佬的思路.
项目结构
主要 3 个,一个 bin 文件夹,放执行命令的入口文件
lib 文件夹,放项目的主要文件,package.json 不多说
这项目主要用到的几个包
- commander: 命令行工具
- download-git-repo: 用来下载远程模板
- ora: 显示 loading 动画
- chalk: 修改控制台输出内容样式
- log-symbols: 显示出 √ 或 × 等的图标
- inquirer.js: 命令交互
- metalsmith:处理项目模板
- handlebars:模板引擎
使用 commander.js 命令行工具
修改 package.json 的 bin 执行入口,
"bin": {"lz": "./bin/www"},
“lz” 这个命令可以自己选择,然后在 bin 文件加创建名为 www 的文件,
#! /usr/bin/env node
require('../lib/index.js');
其中
#! /usr/bin/env node
不能少,这个主要指定当前脚本由 node.js 进行解析
在 lib 创建一个 index.js 文件,
const program = require('commander')
program.version('1.0.0')
.usage('<command> [项目名称]')
.command('init', '创建新项目')
.parse(process.argv);
为方便测试,先链接到全局环境
npm link
执行下命令感受下
lz init hello
正常来说,应该就报错了,错误堆栈大概就是确实 www-init 文件,
这是因为
commander 支持 git 风格的子命令处理,可以根据子命令自动引导到以特定格式命名的命令执行文件,文件名的格式是[command]-[subcommand],例如:
macaw hello => macaw-hello
macaw init => macaw-init
所以我们 执行 www 文件的 init,所以要在 bin 创建一个 www-init 文件,在 lib 创建个 init.js 文件
www-init
#! /usr/bin/env node
require('../lib/init.js');
init.js 完整代码
const program = require('commander')
const path = require('path')
const fs = require('fs')
const glob = require('glob') // npm i glob -D
const download = require('../lib/download.js')
const inquirer = require('inquirer')
const chalk = require('chalk')
const generator = require('../lib/generator')
const logSymbols = require("log-symbols");
program.usage('<project-name>')
// 根据输入,获取项目名称
let projectName = process.argv[2];
if (!projectName) { // project-name 必填
// 相当于执行命令的 --help 选项,显示 help 信息,这是 commander 内置的一个命令选项
program.help()
return
}
const list = glob.sync('*') // 遍历当前目录
let next = undefined;
let rootName = path.basename(process.cwd());
if (list.length) { // 如果当前目录不为空
if (list.some(n => {const fileName = path.resolve(process.cwd(), n);
const isDir = fs.statSync(fileName).isDirectory();
return projectName === n && isDir
})) {console.log(` 项目 ${projectName}已经存在 `);
return;
}
rootName = projectName;
next = Promise.resolve(projectName);
} else if (rootName === projectName) {
rootName = '.';
next = inquirer.prompt([
{
name: 'buildInCurrent',
message: '当前目录为空,且目录名称和项目名称相同,是否直接在当前目录下创建新项目?',
type: 'confirm',
default: true
}
]).then(answer => {return Promise.resolve(answer.buildInCurrent ? '.' : projectName)
})
} else {
rootName = projectName;
next = Promise.resolve(projectName)
}
next && go()
function go() {
next
.then(projectRoot => {if (projectRoot !== '.') {fs.mkdirSync(projectRoot)
}
return download(projectRoot).then(target => {
return {
name: projectRoot,
root: projectRoot,
downloadTemp: target
}
})
})
.then(context => {
return inquirer.prompt([
{
name: 'projectName',
message: '项目的名称',
default: context.name
}, {
name: 'projectVersion',
message: '项目的版本号',
default: '1.0.0'
}, {
name: 'projectDescription',
message: '项目的简介',
default: `A project named ${context.name}`
}
]).then(answers => {
return {
...context,
metadata: {...answers}
}
})
})
.then(context => {
// 删除临时文件夹,将文件移动到目标目录下
return generator(context);
})
.then(context => {
// 成功用绿色显示,给出积极的反馈
console.log(logSymbols.success, chalk.green('创建成功:)'))
console.log(chalk.green('cd' + context.root + '\nnpm install\nnpm run dev'))
})
.catch(err => {
// 失败了用红色,增强提示
console.log(err);
console.error(logSymbols.error, chalk.red(` 创建失败:${err.message}`))
})
}
init.js 都做了什么呢?
首先,获得 init 后面输入的参数,作为项目名称,当然判断这个项目名称是否存在,然后进行对应的逻辑操作, 通过 download-git-repo 工具,下载仓库的模板,然后通过 inquirer.js 处理命令行交互,获得输入的名称,版本号能信息,最后在根据这些信息,处理模板文件。
用 download-git-repo 下载模板文件
在 lib 下创建 download.js 文件
const download = require('download-git-repo')
const path = require("path")
const ora = require('ora')
module.exports = function (target) {target = path.join(target || '.', '.download-temp');
return new Promise(function (res, rej) {
// 这里可以根据具体的模板地址设置下载的 url,注意,如果是 git,url 后面的 branch 不能忽略
let url='github:ZoeLeee/BaseLearnCli#bash';
const spinner = ora(` 正在下载项目模板,源地址:${url}`)
spinner.start();
download(url, target, { clone: true}, function (err)
{if (err) {download(url, target, { clone: false}, function (err)
{if (err) {spinner.fail();
rej(err)
}
else {
// 下载的模板存放在一个临时路径中,下载完成后,可以向下通知这个临时路径,以便后续处理
spinner.succeed()
res(target)
}
})
}
else {
// 下载的模板存放在一个临时路径中,下载完成后,可以向下通知这个临时路径,以便后续处理
spinner.succeed()
res(target)
}
})
})
}
这里注意下下载地址的 url,注意 url 的格式,不是 git clone 的那个地址。其中有个 clone:false 这个参数,如果只是个人用,可以为 true,这样就相当于执行的 git clone 的操作,如果给其他人,可能会出错,用 false 的话,那个就是直接用 http 协议去下载这个模板,具体可以去看官网的文档.
inquirer.js 处理命令交互
比较简单,可以看 init.js
这里把获取到的输入信息在往下传递去处理。
metalsmith
接着,要根据获取到的信息,渲染模板。
首先, 未不影响原来的模板运行,我们在 git 仓库上创建一个 package_temp.json, 对应上我们要交互的变量名
{"name": "{{projectName}}",
"version": "{{projectVersion}}",
"description": "{{projectDescription}}",
"main": "./src/index.js",
"scripts": {
"dev": "webpack-dev-server --config ./config/webpack.config.js",
"build": "webpack --config ./config/webpack.config.js --mode production"
},
"author": "{{author}}",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.3.3",
"@babel/preset-env": "^7.3.1",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.5",
"clean-webpack-plugin": "^1.0.1",
"css-loader": "^2.1.0",
"html-webpack-plugin": "^3.2.0",
"style-loader": "^0.23.1",
"webpack": "^4.28.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.2.0"
},
"dependencies": {
"react": "^16.8.2",
"react-dom": "^16.8.2"
}
}
在 lib 下创建 generator.js 文件,用来处理模板
const Metalsmith = require('metalsmith')
const Handlebars = require('handlebars')
const remove = require("../lib/remove")
const fs = require("fs")
const path = require("path")
module.exports = function (context) {
let metadata = context.metadata;
let src = context.downloadTemp;
let dest = './' + context.root;
if (!src) {return Promise.reject(new Error(` 无效的 source:${src}`))
}
return new Promise((resolve, reject) => {const metalsmith = Metalsmith(process.cwd())
.metadata(metadata)
.clean(false)
.source(src)
.destination(dest);
// 判断下载的项目模板中是否有 templates.ignore
const ignoreFile = path.resolve(process.cwd(), path.join(src, 'templates.ignore'));
const packjsonTemp = path.resolve(process.cwd(), path.join(src, 'package_temp.json'));
let package_temp_content;
if (fs.existsSync(ignoreFile)) {
// 定义一个用于移除模板中被忽略文件的 metalsmith 插件
metalsmith.use((files, metalsmith, done) => {const meta = metalsmith.metadata()
// 先对 ignore 文件进行渲染,然后按行切割 ignore 文件的内容,拿到被忽略清单
const ignores = Handlebars
.compile(fs.readFileSync(ignoreFile).toString())(meta)
.split('\n').map(s => s.trim().replace(/\//g, "\\")).filter(item => item.length);
// 删除被忽略的文件
for (let ignorePattern of ignores) {if (files.hasOwnProperty(ignorePattern)) {delete files[ignorePattern];
}
}
done()})
}
metalsmith.use((files, metalsmith, done) => {const meta = metalsmith.metadata();
package_temp_content = Handlebars.compile(fs.readFileSync(packjsonTemp).toString())(meta);
done();})
metalsmith.use((files, metalsmith, done) => {const meta = metalsmith.metadata()
Object.keys(files).forEach(fileName => {const t = files[fileName].contents.toString()
if (fileName === "package.json")
files[fileName].contents = new Buffer(package_temp_content);
else
files[fileName].contents = new Buffer(Handlebars.compile(t)(meta));
})
done()}).build(err => {remove(src);
err ? reject(err) : resolve(context);
})
})
}
通过 Handlebars 给我们的 package_temp.json 进行插值渲染,然后把渲染好的文件内容替换掉原先的 package.json 的内容
其中有时候我们也需要输入选择某些文件不下载,所以,我们在模板仓库加入一个文件,取名 templates.ignore,
然后,跟处理 package_temp.json 类似,优先渲染这个文件内容,找出需要忽略的文件删掉。最后,删除临时文件夹,把文件移动到项目的文件。这样项目就差不多了。
加入删除文件夹得功能,在 lib 创建 remove.js
const fs =require("fs");
const path=require("path");
function removeDir(dir) {let files = fs.readdirSync(dir)
for(var i=0;i<files.length;i++){let newPath = path.join(dir,files[i]);
let stat = fs.statSync(newPath)
if(stat.isDirectory()){
// 如果是文件夹就递归下去
removeDir(newPath);
}else {
// 删除文件
fs.unlinkSync(newPath);
}
}
fs.rmdirSync(dir)// 如果文件夹是空的,就将自己删除掉
}
module.exports=removeDir;
结尾
关于美化得就不说了,大概的脚手架就以上这些内容,当然这些功能太过于简单,我们还需要根据自己的需要,添加功能,比如说,是否要启用 Typescript,要 less 还是 sass 等,大概原来差不多,根据输入,选择加载哪些文件,大家自由扩展,谢谢阅读.