1 简介
1.1 什么是脚手架
针对脚手架,前端并没有一个很明确的定义。当我们百度搜索脚手架时,会看到这么一句话: 脚手架是为了保证各施工过程顺利进行而搭设的工作平台。
那么是否可以理解为:在日常开发积累的一些模板,一些组件,搭建的一个相对成熟的项目,方便复用,提高效率。
简单的来说:可复用,可提高开发效率的一个工具。(例如我们常用的vue-cli)
1.2、为什么去了解脚手架
- 提高工作效率
- 学习,提高个人竞争力
1.3、市面上,已经有成熟的模板,为什么还要手写呢?
市面上的 vue-cli、create-react-app、angular-cli,已经可以很好的满足我们的日常需求了,为什么还要,花时间去学习,或者开发一个新的脚手架呢?
- 业务日渐增多,有了项目自己配置,vue-cli 等项目无法满足
- 可集成多个业务模块(多个项目),根据业务需求,选择不同的模板
- 利于团队内部多人协作,即使后端人员也可以很容易的上手,启动项目
- 根据动态交互生成项目结构和配置文件,具备更灵活的定制的能力
- 更好的了解学习
2 开发流程
那么一个最简单脚手架的最基本流程是什么呢?
- 通过命令行交互询问用户问题
- 根据用户回答的结果生成文件
那么就定个小目标,来完成一个简单的脚手架工具!
2.1 新建项目
2.1.1 创建文件夹
mkdir my-cli
cd my-cli
npm init // 初始化项目
2.1.2 创建入口文件
├─ bin
│ └─ index.js # 启动文件
└─ package.json
#! /usr/bin/env node
console.log('bin: index.js')
2.1.3 npm link 链接到全局
npm link的功能: 在本地开发npm模块的时候,我们可以使用npm link命令,将npm 模块链接到对应的运行项目中去,方便地对模块进行调试和测试
修改package.json
"bin": {
"nb-cli": "./bin/index.js" // bin 在 package.json 中提供一个字段,该字段是命令名称到本地文件名的映射
},
执行代码
npm link
执行命令
nb-cli
打印出: bin: index.js
至此:我们的小目标,距离完成更近了一步!
2.2 依赖工具
库 | 作用 |
---|---|
chalk-pipe | 使用更简单的样式字符串创建粉笔样式方案 |
chalk | 正确处理终端字符串样式 |
Commander.js | 完整的 node.js 命令行解决方案 |
Inquirer.js | 一组通用的交互式命令行用户界面。 |
slash | 系统路径符处理 |
minimist | 解析参数选项 |
dotenv | 将环境变量从 .env文件加载到process.env中 |
dotenv-expand | 扩展计算机上已经存在的环境变量 |
hash-sum | 非常快的唯一哈希生成器 |
deepmerge | 深度合并两个或多个对象的可枚举属性。 |
yaml-front-matter | 解析yaml或json |
resolve |
package.json
"dependencies": {
"axios": "^0.26.0",
"chalk": "^4.0.0",
"execa": "^5.1.0",
"chalk-pipe": "^5.1.1",
"Commander": "npm:commander@^9.0.0",
"deepmerge": "^4.2.2",
"dotenv": "^16.0.0",
"dotenv-expand": "^8.0.1",
"fs-extra": "^10.0.1",
"hash-sum": "^2.0.0",
"Inquirer": "npm:inquirer@^8.2.0",
"minimist": "^1.2.5",
"resolve": "^1.22.0",
"slash": "^4.0.0",
"yaml-front-matter": "^4.1.1"
}
2.3 创建命令
2.3.1 注册命令
- 利用 commander 注册多个命令
- 每个命令 也可以自定义多个选项(根据具体需求, 这里只定义最简单的 create )
#! /usr/bin/env node
const program = require('commander')
program.command('create <app-name>')
.description('create a new project by your select')
.option('-f --force', 'overwrite your project if it exist')
.action((name, options)=> {
console.log(name, options)
})
program.parse(process.argv); // 解析命令
执行命令
nb-cli create app
打印: app {}
nb-cli create app -f
打印: app { force: true }
由此,我们可以看到, create 命令已经生效,并打出了相应的参数。当然这仅仅只是开始,我们要针对,用户输入的命令进行逻辑处理!
2.3.2 完善创建逻辑
创建 lib 文件夹并在文件夹下创建 create.js;
├─ lib
│ └─ create.js # 创建文件
create.js 的职责:
- 根据用户选择进行相关处理(是否覆盖等)
- 询问用户,并给出具体选择(模板选择,版本选择等等)
- 根据用户的相关选择,进行模板下载等
2.3.3 通过例子了解 inquirer
我们需要借助 通过 inquirer 来询问用户相关问题。
先通过一个例子来了解一下 inquirer。
const inquirer = require('inquirer')
// 问题 类型 confirm input list expand
// 包括 default 默认值 validate 校验 filter 过滤 when 条件判断
const questions = [
{
type: 'confirm',
name: 'human',
message: 'Are you human?',
default: true,
},
{
type: 'input',
name: 'phone',
message: "What's your phone number?",
validate(value) {
const pass = value.match(
/^([01]{1})\d{10}$/i
);
if (pass) {
return true;
}
return 'Please enter a valid phone number';
},
},
{
type: 'list',
name: 'gender',
message: 'What is your gender?',
choices: ['Men', 'Women', 'Unknown'],
filter(val) {
return val.toLowerCase();
},
},
{
type: 'input',
name: 'age',
message: 'What is your age?',
validate(value) {
const valid = !isNaN(parseFloat(value));
return valid || 'Please enter a number';
}
},
{
type: 'expand',
name: 'sport',
message: 'What is your favorite sport?',
choices: [
{
key: 'f',
name: 'Football',
value: 'Football',
},
{
key: 'b',
name: 'Basketball',
value: 'Basketball',
},
{
key: 'o',
name: 'Other',
value: 'Other',
},
],
},
{
type: 'input',
name: 'address',
message: 'Where do you live?',
default: 'HeFei',
},
{
type: 'list',
name: 'region',
message: 'Which area do you live in',
choices: ['shushan', 'luyang', 'yaohai', 'baohe'],
when(answers) {
return answers.address == 'HeFei';
},
},
];
module.exports = async function (name, options) {
console.log(name, options);
inquirer.prompt(questions).then((answers) => {
console.log(answers)
} )
}
上述例子可以让你更好的了解 inquirer 的 用法。 包含了 输入框,选中框,校验,when 等使用方式。也可通过官网了解更多细节!
2.3.4 询问用户是否覆盖已存在目录
接下来来完善询问逻辑,以及删除逻辑。通过判断本地是否已经存在文件夹,来进行 覆盖等操作!
const inquirer = require('inquirer');
const fs = require('fs-extra'); // fs模块的扩展
const path = require('path')
const questions = [
{
name: 'action',
type: 'list',
message: 'Overwrite current directory?',
choices: [
{
name: 'Overwrite',
value: 'overwrite'
},{
name: 'Cancel',
value: 'Cancel'
}
]
}
]
module.exports = async function (name, options) {
const cwd = process.cwd(); // 当前目录
const target = path.join(cwd, name);
if (fs.existsSync(target)) { // 如果目录存在
if (options.force) { // 强制覆盖
console.log(`\r\nRemoving...`)
await fs.remove(target);
console.log(`\r\nRemove success...`)
} else {
let { action } = await inquirer.prompt(questions)
if ( action == 'overwrite') {
console.log(`\r\nRemoving...`)
await fs.remove(target);
console.log(`\r\nRemove success...`)
} else {
return
}
}
}
}
通过创建一个空文件夹 app,来验证是否达到预期!
mkdir app
nb-cli create app -f
Removing...
Remove success...
由此可见,删除逻辑已经满足!
2.3.5 安装模板
本次脚手架,主要通过拉取远程仓库模板,来实现。因此,我们需要通过获取远程 模板,版本。进行模板安装!
在 lib 文件夹并在文件夹下创建 http.js;
├─ lib
│ └─ http.js # 创建文件
const axios = require('axios')
axios.interceptors.response.use(res => {
return res.data;
})
/**
* 获取模板列表
* @returns Promise
*/
async function getRepoList() {
return axios.get('xxxxxxxxxxxxx')
}
/**
* 获取分支信息
* @param {string} key
* @returns Promise
*/
async function getTagList(key) {
return axios.get(`xxxxxxxxxx${key}`)
}
module.exports = {
getRepoList,
getTagList
}
正如我们期望的一样,我们可以通过 获取远程模板,分支,提供给用户,用于选择! 目前 由于是内部部署的gitlab,暂时无法通过接口请求的方式去获取远程的仓库,分支等,因此本地写好固定的模板,分支。作为用户的可选择配置。来完成模板的下载 因此通过模拟数据,来实现当前效果
2.3.6 模拟数据
在 lib 文件夹并在文件夹下创建 util.js;
├─ lib
│ └─ util.js # 创建文件
function normalizeData(arr = []) {
const target = [];
arr.forEach(item => {
target.push({
name: item,
value: item
})
});
return target;
}
// 内部模板用 xxx 代替,这里可以用自己的或者公用的
function getRepoList() {
return ['xxx', 'xxx', 'xxx']
}
function getTagList() {
return ['test', 'pre', 'master']
}
module.exports = {
getRepoList,
getTagList,
normalizeData
}
2.3.7 模板拉取
那么问题来了?我们该如何从远程仓库拉取代码呢?
此时,我们可以回想一下,平时拉取代码的方式。 git clone xxxx。 因此我们可以借助 execa 来执行代码。
在 lib 文件夹并在文件夹下创建 generator.js;
├─ lib
│ └─ generator.js # 创建文件
const inquirer = require('inquirer')
const {getRepoList, getTagList, normalizeData} = require('./util')
const execa = require('execa');
const path = require('path');
class Generator {
constructor (name, currentDir) {
// 项目名称
this.name = name;
// 创建位置
this.currentDir = currentDir;
}
// 创建
async create(){
// 由于内部gitlab 无权限拉取 因此先将 模板,分支写死 作为示例
const template = await this.getTemplate()
const branch = await this.getBranch()
this.clone(template, branch)
}
// 安装模板
clone(template, branch) {
const cwd = this.currentDir;
const child = execa('git', ['clone', `xxxxxx/${template}.git`], {
stdio: ['inherit', 'pipe', 'inherit']
})
child.stdout.on('data', buffer => {
console.log("写数据")
process.stdout.write(buffer)
})
child.on('close', code => {
console.log(code)
if (code !== 0) {
return
}
// resolve()
})
}
async getTemplate() {
const { template } = await inquirer.prompt({
name: 'template',
type: 'list',
choices: normalizeData(getRepoList()),
message: 'Place choose a templates to create project'
})
return template
}
async getBranch() {
const { branch } = await inquirer.prompt({
name: 'branch',
type: 'list',
choices: normalizeData(getTagList()),
message: 'Place choose a branch to create project'
})
return branch
}
}
module.exports = Generator
本地执行 命令
nb-cli create app
此时我们会发现, app 文件夹并没有按照预期,生成。因为此时我们相当于 只执行了 git clone...
2.3.8 缺少项目名称文件夹
新增 createDir 方法
async createDir(name) {
await execa('mkdir', [name], {
stdio: ['inherit', 'pipe', 'inherit']
})
}
更新 clone 函数, 通过提前创建 文件夹,然后 git clone 到当前目录
async clone(template, branch) {
await this.createDir(this.name);
return new Promise((resolve, reject) => {
const child = execa('git', ['clone', '-b', branch, `xxx/${template}.git`, this.currentDir], {
stdio: ['inherit', 'pipe', 'inherit']
})
child.stdout.on('data', buffer => {
console.log("写数据")
process.stdout.write(buffer)
})
child.on('close', code => {
console.log(code)
if (code !== 0) {
reject()
return
}
resolve()
})
})
}
2.3.9 主动的npm install
当项目(模板)下载完毕时,此时我不想手动的安装依赖,是否可以主动替用户 进行npm install (前端项目)。
新增 install 函数
async install() {
await execa('npm', ['--registry','https://registry.npm.taobao.org','install'], {
cwd: this.currentDir,
stdio: ['inherit', 'pipe', 'inherit']
})
}
这里用了直接使用淘宝镜像,主要是为了更快的安装依赖。当然,我们可以继续扩展,如(根据用户的选择 来使用 npm yarn等)
更新 create
async create() {
// 由于内部gitlab 无权限拉取 因此先将 模板,分支写死 作为示例
const template = await this.getTemplate()
const branch = await this.getBranch()
console.log("正在下载模板")
await this.clone(template, branch)
console.log("模板下载完成,进行依赖安装")
await this.install();
console.log(`cd ${this.name}`)
console.log('npm run dev')
}
执行命令看看效果:
2.3.10 打印文件多样化
我们在日常使用 vue-cli时,会发现 控制台打印 用颜色的 区分,那么是如何实现的呢?
我们可以借助 chalk 来实现该功能。下面对我们的工具来进行简单优化
console.log(`模板下载完成,进行${chalk.yellow('依赖')}安装`)
3. 总结
到此,我们结合内部的一个简易的脚手架已经完成。当然,这是一个最简单的版本
提供
- create 命令
- 支持删除
- 选择模板,分支
- npm install
我们也可以在此基础上,进行功能完善,如:
- 支持更多的命令
- 模板,分支,从远程获取
- 用户可选的 包管理器等
- 以及像 vue-cli 一样,支持 plugin 功能等
那么后续可以,借鉴vue-cli 完成一个更加完善的脚手架工具
4. 完整代码
/bin/index.js
#! /usr/bin/env node
const program = require('commander')
program.command('create <app-name>')
.description('create a new project by your select')
.option('-f --force', 'overwrite your project if it exist')
.action((name, options)=> {
require('../lib/create.js')(name, options)
})
program.parse(process.argv); // 解析命令
/lib/create.js
const inquirer = require('inquirer');
const fs = require('fs-extra'); // fs模块的扩展
const path = require('path')
const Generator = require('./generator')
const execa = require('execa');
const questions = [
{
name: 'action',
type: 'list',
message: '是否覆盖?',
choices: [
{
name: '覆盖',
value: 'overwrite'
}, {
name: '取消',
value: 'Cancel'
}
]
}
]
module.exports = async function (name, options) {
const cwd = process.cwd(); // 当前目录
console.log(options)
const currentDir = path.join(cwd, name);
if (fs.existsSync(currentDir)) { // 如果目录存在
if (options.force) { // 强制覆盖
console.log(`\r\nRemoving...`)
await fs.remove(currentDir);
console.log(`\r\nRemove success...`)
} else {
let { action } = await inquirer.prompt(questions)
console.log(action);
if (action == 'overwrite') {
console.log(`\r\nRemoving...`)
await fs.remove(currentDir);
console.log(`\r\nRemove success...`)
} else {
return
}
}
}
const generator = new Generator(name, currentDir);
generator.create()
}
/lib/util
function normalizeData(arr = []) {
const target = [];
arr.forEach(item => {
target.push({
name: item,
value: item
})
});
return target;
}
function getRepoList() {
return ['xxx', 'xxx', 'xxx']
}
function getTagList() {
return ['test', 'pre', 'master']
}
module.exports = {
getRepoList,
getTagList,
normalizeData
}
/lib/http.js 暂时无用
const axios = require('axios')
axios.interceptors.response.use(res => {
return res.data;
})
/**
* 获取模板列表
* @returns Promise
*/
async function getRepoList() {
return axios.get('xxxxxxxxxxxxx')
}
/**
* 获取分支信息
* @param {string} key
* @returns Promise
*/
async function getTagList(key) {
return axios.get(`xxxxxxxxxx${key}`)
}
module.exports = {
getRepoList,
getTagList
}
/lib/generator.js
const inquirer = require('inquirer')
const { getRepoList, getTagList, normalizeData } = require('./util')
const execa = require('execa');
const path = require('path');
const chalk = require('chalk');
const fs = require('fs-extra'); // fs模块的扩展
class Generator {
constructor(name, currentDir) {
// 项目名称
this.name = name;
// 创建位置
this.currentDir = currentDir;
}
// 创建
async create() {
// 由于内部gitlab 无权限拉取 因此先将 模板,分支写死 作为示例
const template = await this.getTemplate()
const branch = await this.getBranch()
console.log(`正在下载模板${chalk.red(template)}`)
await this.clone(template, branch)
console.log(`模板下载完成,进行${chalk.yellow('依赖')}安装`)
await this.install();
console.log(`cd ${chalk.cyan(this.name)}`)
console.log('npm run dev')
}
// 依赖安装
async install() {
await execa('npm', ['--registry','https://registry.npm.taobao.org','install'], {
cwd: this.currentDir,
stdio: ['inherit', 'pipe', 'inherit']
})
}
// 创建文件夹
async createDir(name) {
await execa('mkdir', [name], {
stdio: ['inherit', 'pipe', 'inherit']
})
}
// cd目录
async toPath(path) {
await execa.command(`cd ${path}`)
}
// 安装模板
async clone(template, branch) {
await this.createDir(this.name);
return new Promise((resolve, reject) => {
const child = execa('git', ['clone', '-b', branch, `xxx/${template}.git`, this.currentDir], {
stdio: ['inherit', 'pipe', 'inherit']
})
child.stdout.on('data', buffer => {
console.log("写数据")
process.stdout.write(buffer)
})
child.on('close', code => {
if (code !== 0) {
reject()
return
}
resolve()
})
})
}
async getTemplate() {
const { template } = await inquirer.prompt({
name: 'template',
type: 'list',
choices: normalizeData(getRepoList()),
message: 'Place choose a templates to create project'
})
return template
}
async getBranch() {
const { branch } = await inquirer.prompt({
name: 'branch',
type: 'list',
choices: normalizeData(getTagList()),
message: 'Place choose a branch to create project'
})
return branch
}
}
module.exports = Generator