前言
公司项目多了,且后续会增加更多项目,为了避免每次创建项目都是重复的 copy,这里可以自己写一个适合公司的脚手架,就跟 vue-cli
, create-react-app
类似。
简单描述下原理:首先你需要准备一个模板,这个模板可以存储在公司的 git 上,然后根据用户选择决定采用哪个分支。比如我们就有 h5模板
和 web模板
两个分支。
然后这些模板会有一些我们自定义的特殊字符,让用户可以根据输入的内容替换。比如我在模板那边里有定义了 $$PROJECT_NAME$$
这个特殊字符,通过命令行交互让用户输入创建的项目名: test-project
,最后我就通过 node
去遍历模板里的文件,找到这个字符,将 $$PROJECT_NAME$$
替换成 test-project
即可。根据公司需求自己事先定义好一些特殊变量即可,主要用到的就是下面几个库。
package.json 里的 bin 字段
用于执行 可执行文件 ,当使用 npm 或 yarn
命令安装时,如果发现包里有该字段,那么会在 node_modules
目录下的 .bin
目录中复制 bin
字段链接的可执行文件,我们在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。
bin 文件里的 #!
含义
#!
符号的名称叫 Shebang,用于指定脚本的解释程序。
/usr/bin/env node
表示 系统可以在 PATH
目录中查找 node
程序
如果报错,说明没有在 PATH
中找到 node
npm link
npm link
(组件库里用来在本地调试用的)是将整个目录链接到全局node_modules
中,如果有 bin
那么则会生成全局的可执行命令
npm link xxx
(本地测试项目里使用), xxx
为 那个库的 package.json
的 name
。 是让你在本地测试项目中可以使用 xxx
-
库在开发迭代,不适合发布到线上进行调试。
-
可以帮助我们模拟包安装后的状态,它会在系统中做一个快捷方式映射,让本地的包就好像 install 过一样,可以直接使用。
-
用
npm unlink
解除链接
commander —— 命令行指令配置
实现脚手架命令的配置, commander 中文文档
// 引入 program
const { program } = require('commander')
// 设置 program 可以输入的选项
// 每个选项可以定义一个短选项名称(-后面接单个字符)和一个长选项名称(--后面接一个或多个单词),使用逗号、空格或|分隔。
// 长选项名称可以作为 .opts() 的对象key
program.option('-p, --port <count>') // 必选参数使用 <> 表示,可选参数使用 [] 表示
// 解析后的选项可以通过Command对象上的.opts()方法获取,同时会被传递给命令处理函数。
const options = program.opts()
program.command('create <name>').action((fileName) => {
console.log({ fileName, options })
})
program.parse(process.argv) // 执行命令
chalk —— 命令行美化工具
可以美化我们在命令行中输出内容的样式,例如实现多种颜色,花里胡哨的命令行提示等。chalk 文档
安装 chalk 时一定要注意安装 4.x 版本(小包使用的是 4.0.0),否则会因为版本过高,爆出错误。
const chalk = require('chalk')
console.log(`hello ${chalk.blue('world')}`)
console.log(chalk.blue.bgRed.bold('Hello world!'))
inquirer —— 命令行交互工具
支持 input, number, confirm, list, rawlist, expand, checkbox, password,editor 等多种交互方式。 inquirer 文档
const inquirer = require('inquirer')
inquirer
.prompt([
/* 输入问题 */
{
name: 'question1',
type: 'checkbox',
message: '爸爸的爸爸叫什么?',
choices: [
{
name: '爸爸',
checked: true,
},
{
name: '爷爷',
},
],
},
{
name: 'question2',
type: 'list',
message: `确定要创建${fileName}的文件夹吗`,
choices: [
{
name: '确定',
checked: true,
},
{
name: '否',
},
],
},
])
.then((answers) => {
// Use user feedback for... whatever!!
console.log({ answers })
})
.catch((error) => {
if (error.isTtyError) {
// Prompt couldn't be rendered in the current environment
} else {
// Something else went wrong
}
})
ora —— 命令行 loading 效果
现在的最新版本为 es6 模块
,需要用以前的版本,例如: V5.4.1
才是 cjs 模块
: ora 文档
const ora = require('ora')
const spinner = ora('Loading unicorns').start()
setTimeout(() => {
spinner.color = 'yellow'
spinner.text = 'Loading rainbows'
}, 1000)
spinner.succeed()
fs-extra —— 更友好的文件操作
是系统 fs 模块的扩展,提供了更多便利的 API,并继承了 fs 模块的 API。比 fs 使用起来更加友好。 fs-extra 文档
download-git-repo —— 命令行下载工具
从 git 中拉取仓库,提供了 download 方法,该方法接收 4 个参数。 download-git-repo 文档
/**
* download-git-repo 源码
* Download `repo` to `dest` and callback `fn(err)`.
*
* @param {String} repo 仓库地址
* @param {String} dest 仓库下载后存放路径
* @param {Object} opts 配置参数
* @param {Function} fn 回调函数
*/
function download(repo, dest, opts, fn) {}
【注】
download-git-repo
不支持 Promise
项目源码举例
最近一直在忙公司业务,基本是有朋友私我要源码,我会发给他,现在想了想,还是将源码整理下发出来。
// package.json
{
"name": "mother-cli",
"version": "1.0.2",
"description": "",
"scripts": {
"cli": "node ./bin/index.js"
},
// 这里的字段在上面有说到,这里再说下,当你弄到私库上了之后
// 你再拉下来,那么 mother-cli 就会变成你的可执行命令
"bin": {
"mother-cli": "./bin/index.js"
},
"keywords": [],
"author": "PNM",
"license": "ISC",
// 这些包上面一一都有介绍,直接安装
"dependencies": {
"axios": "^1.3.4",
"commander": "^9.3.0",
"download-git-repo": "^3.0.2",
"fs-extra": "^10.1.0",
"inquirer": "^8.2.4",
"ora": "^5.4.1"
}
}
我项目用的是 node 写的,纯 js 代码,功能简单,大家应该看一下就懂。
【注】各个公司项目不一样,这里只以本人公司项目为视角出发。
// 此文件为 ./bin/index.js 文件,为 mother-cli 的路径,可以看到第一行为 #! 开头,在前面有提到这个的作用
#! /usr/bin/env node
// 模板路径,# 后面跟着 分支名,由用户选择
const REPO_PATH = 'http://xxx/pengnima/empty-web.git#'
const { program } = require('commander')
const { checkName } = require('./check') // 检测目录名的方法
const { onPrompt } = require('./inquirer') // 封装了 命令行交互工具 的一个方法
const { pull } = require('./pull') // 拉取git项目的方法
const { init, readDirs } = require('./replace') // 因为模板有些内容需要替换,这里就是做替换功能的
const shell = require('./shell') // 内部封装了,执行一些 shell 脚本的功能
const axios = require('axios') // 老搭档了
const fse = require('fs-extra') // 文件操作
/**
* todo: 主要做的就是 4 点
*
* 1. 检测当前目录是否有同名文件夹
*
* 2. 检测用户选项,例如:是否输入了 -p 选项 (记录 port)
*
* 3. 拉取 git 项目
*
* 4. 根据不同参数改写文件(根据公司项目情况而定)
*/
// program 的功能前面介绍过了,这里不做过多介绍
program.name('mother-cli').usage(`<command>`)
program.version('0.0.1')
program
.command('init')
.description('创建项目')
.action(async () => {
// 与用户交互,看用户是要选择哪种模板,默认为 web模板,这里的就对应了 git 上的分支
onPrompt('list', {
message: '请选择项目类型:',
choices: [
{
name: 'web',
checked: true,
},
{
name: 'h5',
},
],
callback: async (type) => {
//...省略一些交互代码
let name, dirName
while (!name) {
;({ name, dirName } = await onPrompt('input', {
message: '请输入系统名:',
// 我这里的系统名及项目的目录名,所以这里需要轮询检测是否有重名的目录
callback: (_name) => checkName(_name, type),
}))
}
// 通过上面的代码,dirName 和 name 就有值了,一个为目录地址,一个为目录名
console.log(`即将创建 ${name}-${type} 项目`)
// 拉取项目代码 type 为分支名,即选择的项目类型 web 或 h5
await pull('direct:' + REPO_PATH + type, dirName, { clone: true })
// 拉取代码之后,通过 fs 改写一些模板中的信息
init({ name })
readDirs(dirName)
console.log('完成...')
// 执行 git init 命令
await shell('git init', dirName)
},
})
})
// 执行命令
program.parse(process.argv)
上面为主要内容,然后就是一些封装的文件:
// ./inquirer 文件中,封装了 inquirer 交互工具的 prompt 方法
const inquirer = require('inquirer')
const onPrompt = (type, options) => {
const { callback, ..._options } = options
return inquirer
.prompt([
{
name: 'question',
type,
..._options,
},
])
.then((answers) => {
return callback ? callback(answers.question) : answers.question
})
.catch((err) => {
return undefined
})
}
module.exports = {
onPrompt,
}
// ./check 文件
const fse = require('fs-extra')
const path = require('path')
const { onPrompt } = require('./inquirer')
// 校验目录名
async function checkName(name, type) {
const dirName = path.join(process.cwd(), name + '-' + type)
if (fse.existsSync(dirName)) {
return onPrompt('input', {
message: '该目录已存在,请输入新的目录名:',
callback: (_name) => checkName(_name, type),
})
} else {
return {
name,
dirName,
}
}
}
module.exports = { checkName }
// ./pull 从git中拉取模板
const downGitRepo = require('download-git-repo')
const util = require('util')
const ora = require('ora')
// 使用 promisify 将 downGitRepo 转换成 Promise 格式
const _downloadPromise = util.promisify(downGitRepo)
function _sleep(n) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, n)
})
}
const pull = async (repo, dest, opts) => {
const spinner = ora('正在创建...').start()
try {
await _downloadPromise(repo, dest, opts)
spinner.succeed('创建成功')
} catch (error) {
spinner.fail('拉取失败,1s 后重试')
await _sleep(1000)
return pull(repo, dest, opts)
}
}
module.exports = {
pull,
}
// ./replace 文件, 替换模板中的一些特殊标识符
// 简单介绍下,就是我在模板中写了一些 $__CLI_PATH__$ 之类的标识符,
// 然后我将模板拉取下来之后,遍历文件,再用前面与用户交互所获得的 变量 ,把那些标识符挨个替换成对应的变量。
const fse = require('fs-extra')
const path = require('path')
let REPLACE_REG = ''
let REPLACE_MAP = {}
let WHITE_FILE_PATH = ['yarn.lock', 'node_modules']
const init = ({ name, ptName, preFixTitle }) => {
// 生成替换的 keyValue
REPLACE_MAP = {
$__CLI_PATH__$: name,
$__CLI_PTNAME_PATH__$: ptName,
$__CLI_TITLE_PATH__$: preFixTitle,
}
// 替换 $ 为 \\$ 便于生成正则
const keyList = Object.keys(REPLACE_MAP)
REPLACE_REG = new RegExp(keyList.map((v) => `(${v.replace(/\$/gi, '\\$')})`).join('|'), 'ig')
}
const readDirs = async (pPath) => {
const fileList = fse.readdirSync(pPath)
fileList.forEach((file) => {
// 不存在与白名单的文件,才去替换
if (WHITE_FILE_PATH.every((v) => v != file)) {
const tempPath = path.join(pPath, file)
const state = fse.statSync(tempPath)
// 检测是否为目录,如果是则递归
if (state.isDirectory()) {
readDirs(tempPath)
} else {
const res = fse.readFileSync(tempPath).toString()
let newContent = res
if (file == '.gitignore') {
newContent = res.replace('yarn.lock', '')
} else {
newContent = res.replace(REPLACE_REG, (key) => {
return REPLACE_MAP[key]
})
}
fse.writeFileSync(tempPath, newContent) // 写入
}
} else {
// console.log(`${file} 文件为白名单`)
}
})
}
module.exports = { readDirs, init }
// child_process 可以帮我们执行shell命令,封装起来
const exec = require('child_process').exec
const util = require('util')
const promiseExec = util.promisify(exec)
const shell = async (command, cwd) => {
return new Promise(async (resolve, reject) => {
try {
const { stdout } = await promiseExec(command, { cwd })
resolve(stdout)
} catch (error) {
console.log({ error })
reject()
}
})
}
module.exports = shell