为什么要实现脚手架
在项目开发过程中,我们常常有不同的项目,但是基础模板却是相同的,如果用官方提供的脚手架,里面有很多的配置是没有为我们配置的,再从零配置浪费时间,为了提升开发效率,所以就可以自己写一套常用的模板,编写一个脚wenj手架,在运行我们自己的脚手架命令后,直接生成常用的配置好的模板
如何实现
首先我们先梳理一下,会用到的模块包
1. commander 用来配置可执行命令
2. inquirer 用来实现命令行交互
3. ejs 用来渲染模版
4. ora 用来实现loading
5. download-git-repo 用来下载模版文件
6. chalk 用来实现打印不同颜色的字体
7. figlet 打印logo
创建可执行脚本,配置可执行命令,实现命令行交互然后下载模版,编译ejs模版,根据用户选择动态生成内容
实现
首先创建初始化一个包,npm init -y
添加bin字段,value值是我们执行的入口文件,bin/index.js, name默认就是我们使用脚手架的名称
// 默认以name 可以配置别名
// "bin": "./bin/index",
"bin": {
"cli": "./bin/index",
"mycli": "./bin/index"
},
创建一个bin文件夹,然后 新建index.js文件,将这个包在开发模式下链接到全局下面,
先链接到本地全局下面,因为发布了这个包之后可以下载下来使用,开发先链接到本地 npm link npm link --force npm unlink cli(npm link 后删除)
#! /usr/bin/env node
const program = require('commander');
const figlet = require('figlet');
const createCommand = require('../lib/command')
// 配置命令
program.version(`cli@${require('../package.json').version}`)
program.usage('<command> [option] ✅例如: cli create name -f', )
// chalk 配置颜色
program.on('--help', () => {
console.log("")
// 使用 figlet 绘制 Logo
console.log('\r\n' + figlet.textSync('cli-hello', {
font: 'Ghost',
horizontalLayout: 'default',
verticalLayout: 'default',
width: 100,
whitespaceBreak: true
}));
console.log('🌞运行cli <command> --help 查看具体参数')
console.log('✅例如: cli create --help')
console.log("")
})
// 自定义--help中的选项
program.option('-u --user', 'คิดถึง')
// 自定义创建命令
createCommand()
// 解析用户传入的命令以及参数
program.parse(process.argv);
将创建命令的文件抽离,新建一个lib文件夹,创建command.js文件
const program = require('commander');
const { createAction, createPage } = require('./action')
const createCommand = () => {
// 创建项目
program.command('create <value>')
.description('创建一个新项目')
.option('-f, --fource', '是否覆盖创建')
.option('-t, --type <value>', '选择项目的类型 React/Vue')
.action((value, options) => {
createAction(value, options)
})
// 添加页面
program.command('addPage <name> [page]')
.description('创建一个新组件')
.action((name, path) => {
createPage(name, path)
})
}
module.exports = createCommand
为了方便接收到命令后进行交互,我们创建一个action.js文件,将其所有的动作抽离
const path = require('path')
const inquirer = require('inquirer')
const { loadingWrap } = require('../utils/loading')
// fs-extra 相当于fs模块
const fs = require('fs-extra');
const Creator = require('./Creator')
const { ejsCompile } = require('../utils/ejsCompile')
const { writeFile, mkdirSync } = require('../utils/fileUtils')
// create
const createAction = async (projectName, cmd) => {
// 获取当前执行的目录路径
const cwd = process.cwd();
// 获取到要创建的地址
const targetDir = path.join(cwd, projectName);
// 判断该目录存不存在
if (fs.existsSync(targetDir)) {
if (cmd.force) {
// 如果是强制创建, 删除目录后面直接创建
await fs.remove(targetDir)
} else {
// 提示用户是否确认覆盖
const {chose} = await inquirer.prompt([ // 配置询问方式
{
name: 'chose',
type: 'list', // 类型
message: '文件夹已存在是否覆盖?',
choices: [
{name: '覆盖', value: 'overwrite'},
{name: '取消', value: false}
]
}
]);
// 覆盖就是删除再创建
if (chose === 'overwrite') {
await loadingWrap(fs.remove, '文件覆盖中', targetDir)
} else {
return;
}
}
}
// 创建目录
const creator = new Creator(projectName, targetDir)
// 开始创建
creator.createProject()
}
// createPage
const createPage = async (name, address) => {
const templatePath = await path.resolve(__dirname, '../template/component.react.ejs')
// 需要一个ejs模版进行渲染
const result = await ejsCompile(templatePath, {name, lowerName: name.toLowerCase()});
// 判断文件不存在,那么就创建文件
mkdirSync(address);
const targetPath = path.resolve(address, `${name}.tsx`);
// 写入文件
writeFile(targetPath, result);
}
module.exports = {
createAction,
createPage,
}
创建一个Creator.js文件,用来执行创建文件操作
const fs = require('fs-extra')
const inquirer = require('inquirer')
const { fetchRepoList, fetchTagList } = require('./request')
const util = require('util'); // promisify 将回调函数的形式包裹promise
const terminal = require('../utils/terminal')
const open = require('open');
const { loadingWrap } = require('../utils/loading')
// 需要将这个方法转换为promise
const downloadGit = require('download-git-repo');
const { args } = require('commander');
class Creator {
constructor(projectName,targetDir ) {
this.name = projectName
this.targetDir = targetDir
this.downloadGitRepo = util.promisify(downloadGit)
}
async fetchRepo() {
// 失败重新拉取
let repos = await loadingWrap(fetchRepoList, '模版文件正在拉取中');
if (!repos) return false;
repos = repos.map(item => item.name)
let { repo } = await inquirer.prompt([{
name : 'repo',
type: 'list',
choices: repos,
message: '请选择项目'
}])
return repo
}
async fetchTag(args) {
if (!args) return;
let tags = await loadingWrap(fetchTagList, '正在拉取版本号', args)
if (!tags) return false;
tags = tags.map(item => item.name)
let { tag } = await inquirer.prompt({
name: 'tag',
type: 'list',
choices: tags,
message: "请选择版本"
})
return tag
}
async download(repo, tag) {
if (!repo) return false;
// 拼接下载路径
let requestUrl = `zhu-cli/${repo}${tag ? '#' + tag : '' }`
// 把资源下载到某个路径(后续可以增加缓存,下载到系统目录,渲染模版然后再写入)
// console.log(process.cwd(), this.targetDir, this.name)
// 当前目录下创建文件夹,下载到这个目录下
await fs.mkdirs(this.targetDir)
await loadingWrap(this.downloadGitRepo, '项目生成中', requestUrl, this.targetDir)
return true
}
async runNpm(isSuccess) {
if (!isSuccess) return;
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
// 下载依赖
await terminal.spawn(npm, ['install'], { cwd: `${this.targetDir}` });
open('http://localhost:8080/')
// 运行项目
await terminal.spawn(npm, ['run', 'serve'], { cwd: `${this.targetDir}` });
}
async createProject() { // 真实创建
// 获取仓库模版
const repo = await this.fetchRepo()
// 获取模版版本
const tag = await this.fetchTag(repo)
// 下载
const isSuccess = await this.download(repo, tag)
// 执行npm install 打开浏览器
await this.runNpm(isSuccess)
}
}
module.exports = Creator
创建一个request.js文件,用来请求我们github上面的模版地址,这里我们请求了所有的模版以及他们的版本,如果只有一个模版,只需要改一下地址,直接下载即可
可以用download-git-repo 传入地址直接下载
const axios = require('axios')
axios.interceptors.response.use(res => res.data)
async function fetchRepoList() {
return axios.get('https://api.github.com/orgs/my-cli/repos')
}
async function fetchTagList(repo) {
return axios.get(`https://api.github.com/repos/my-cli/${repo}/tags`)
}
module.exports = {
fetchRepoList,
fetchTagList
}
在终端交互的时候,下载模版添加loading状态
新建utils
// loading.js
const ora = require('ora')
const chalk = require('chalk');
async function sleep(n) {
return new Promise((resolve, reject) => {
setTimeout(resolve, n)
})
}
let counter = 0; // 用来记录重试的错误
async function loadingWrap(fn, message, ...args) {
const spinner = ora(`${chalk.green(message)}`)
spinner.start()
try {
const value = await fn(...args)
spinner.succeed('成功');
console.log('')
return value
} catch(e) {
spinner.fail('执行失败,重试中...⌛️')
// 自动重试5次
if (counter === 4) {
console.log(chalk.red('网络错误或未知错误,请重试'))
counter = 0
return false;
} else {
counter = counter + 1;
await sleep(1000)
return loadingWrap(fn, message, ...args)
}
}
}
module.exports = {
loadingWrap
}
添加创建文件夹写入文件的工具 fileUtiles.js
const fs = require('fs');
const path = require('path');
const inquirer = require('inquirer');
const writeFile = async (path, content) => {
if (fs.existsSync(path)) {
// 提示用户是否确认覆盖
const {chose} = await inquirer.prompt([ // 配置询问方式
{
name: 'chose',
type: 'list', // 类型
message: '此文件已存在是否覆盖?',
choices: [
{name: '覆盖', value: 'overwrite'},
{name: '取消', value: false}
]
}
]);
if (!chose) {
return;
} else if (chose === 'overwrite'){
return fs.promises.writeFile(path, content);
}
} else {
return fs.promises.writeFile(path, content);
}
}
const mkdirSync = (dirname) => {
if (fs.existsSync(dirname)) {
return true
} else {
// 不存在,判断父亲文件夹是否存在?
if (mkdirSync(path.dirname(dirname))) {
// 存在父亲文件,就直接新建该文件
fs.mkdirSync(dirname)
return true
}
}
}
module.exports = {
writeFile,
mkdirSync
}
添加开启终端子进程的工具,用来实现自动下载依赖,自动运行项目
// terminal.js
// 开启子进程
const { spawn, exec } = require('child_process');
const spawnCommand = (...args) => {
return new Promise((resole, reject) => {
const childProcess = spawn(...args);
childProcess.stdout.pipe(process.stdout);
childProcess.stderr.pipe(process.stderr);
childProcess.on('close', () => {
resole();
})
})
}
module.exports = {
spawn: spawnCommand
}
添加ejsCompile.js 编译ejs模版,用来实现可以快速创建一个页面
const ejs = require('ejs')
const ejsCompile = (templatepath, data={}, options = {}) => {
return new Promise((resolve, reject) => {
ejs.renderFile(templatepath, {data}, options, (err, str) => {
if (err) {
reject(err);
return;
}
resolve(str)
})
})
}
module.exports = {
ejsCompile
}
在使用终端创建一个页面的时候,要首先创建好ejs模版,到时候,直接将命令行的参数传入到模版里面,渲染出来,然后写入到文件中就可以了
新建一个template文件夹,创建一个react的模版 component.react.ejs
import React from 'react'
const <%= data.lowerName %> :React.FC<Iprops> = (props) => {
return (
<div>
<%= data.name %>
</div>
)
}
interface Iprops {
}
export default <%= data.lowerName %>
✅这个时候我们的大概脚手架基本功能就已经完成啦,
后面想要新增功能只要在command里添加就好了,然后添加ejs模版渲染
搁置了好久的脚手架终于搞完了,奖励自己一瓶茶π