Hi~,我是 一碗周,一个在舒适区垂死挣扎的前端,如果写的文章有幸可以得到你的青睐,万分有幸~
写在前面
现在市面上已经有这么多成熟的脚手架,我们还有必要开发一个脚手架呢?如果我们处在应用的角度,像vue-cli、create-react-app等这些脚手架已经够用了;但是我们在开发的过程中,需要对很多项目模板进行二开,但是往往这样的二开并不是一次;作为一个成熟的程序猿,如果进行大量的重复工作肯定是拒绝的,这个时候就需要自己开发一个脚手架自己用,也可以上传的Github开源给大家一起用。
还有就是如果站在学习的角度,我们创建项目如果只是使用脚手架,我们永远不知道如何搭建一个项目。
这篇文章将手把手教你如何开发一个脚手架。
准备工作
首先创建一个项目,使用npm init -y
命令初始化一个Node项目,然后创建项目目录结构,如下所示:
├── node_modules # 项目依赖资源
├── bin # 脚手架入口。
│ └── ywz.js # 入口文件。
├── lib # 项目的主要逻辑代码
│ └── index.js # 逻辑处理的js文件
├── .gitignore # Git推送忽略列表配置文件
├── .prettierrc # Prettier格式化配置文件
└── package.json # 项目所需要的各种模块,以及项目的配置信息
现在来介绍一下脚手架中使用到的一些插件:
- commander:完整的 node.js 命令行解决方案,中文文档
- axios :拉取数据
- ora :实现loading效果
- inquirer:通用交互式命令行用户界面的集合
- chalk :实现彩色终端字体
- download-git-repo :基于Node下载并提取Git仓库
- metalsmith : 一个非常简单,可以插入的static站点生成器
- ncp :用于copy文件
- consolidate :模板引擎的集合
- handlebars :模板引擎
安装命令如下:
npm i commander axios ora inquirer chalk download-git-repo metalsmith ncp consolidate handlebars -D
现在来修改一下我们的package.json
文件中的内容,内容如下:
{
"name": "ywz",
"version": "1.0.0",
"description": "",
"main": "lib/index.js",
"directories": {
"lib": "lib"
},
"bin": {
"ywz": "bin/ywz.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "ywanzhou",
"license": "ISC",
"devDependencies": {
"axios": "^0.24.0",
"chalk": "^5.0.0",
"commander": "^8.3.0",
"consolidate": "^0.16.0",
"download-git-repo": "^3.0.2",
"handlebars": "^4.7.7",
"inquirer": "^8.2.0",
"metalsmith": "^2.3.0",
"ncp": "^2.0.0",
"ora": "^5.4.1"
}
}
这里主要修改了如下内容:
main
:项目的入口文件bin
:可执行命令文件
然后在bin/ywz.js
文件中写入如下代码:
#! /usr/bin/env node
console.log('脚手架')
#! /usr/bin/env node
就是告诉系统可以在PATH目录中查找,然后使用Node运行
然后通过link
命令将ywz
链接到全局,方便我们测试,示例代码如下:
npm link
这个链接就相当于Linux中的软链接,如果测试完毕可以通过
npm unlink
取消链接。
值得注意的的就是使用npm link
命令的时候,一定是在项目的根目录中。
现在我们可以在命令行中键入ywz
即可看到命令行提示的脚手架
。
目前为止,我们的准备工作就完成了,现在开始进入正题。
配置命令
process.argv属性
手写脚手架的第一步,就是配置我们的命令,只有配置完毕命令,才才可以进行开发。
首先介绍process.argv
属性,该属性返回一个数组,其中包含当启动Node.js进程时传入的命令行参数。 第一个元素是process.execPath
,即Node.js的安装路径。 第二个元素将是正在执行的JavaScript文件的路径。 其余元素将是任何其他命令行参数。
假如我们的bin/ywz.js
中是这样的
#! /usr/bin/env node
console.log(process.argv)
然后在命令行中输入下面这段命令
ywz create node
即可看到下面这段内容
[
'C:\\Program Files\\nodejs\\node.exe',
'E:\\nodejs\\npm-global\\node_modules\\ywz\\bin\\ywz.js',
'create',
'node'
]
如果这里通过原生Node提供的这个属性去操作命令行中的参数,那肯定是非常的麻烦的,所以我们使用第三方提供包,也就是commander。
commander的使用
首先我们先写入如下代码:
#! /usr/bin/env node
const { program } = require('commander')
const { version } = require('../package.json')
// .version() 方法用于设置版本号,当在命令行中执行 --version 或者 -V 时,显示的版本
// .parse() 用于解析命名行参数,默认值为 process.argv * 重要
program.version(version).parse()
然后在命令行中输入ywz -V
即可看到版本号。
现在我们就可以通过.command()
方法来定义命令了,该方法的第一个参数为命令名称,后面可以跟命令参数,命令参数也可以使用.argument()
方法单独指定。
该方法接受的参数有三种,如下所示:
- 必选参数:尖括号表示
- 可选参数:方括号表示
- 可变参数:在参数名后加上
...
,例如<dirs...>
,如果有可变参数,必须在最后。
还可以通过.alias()
方法设置别名,使用.action()
方法对命令进行处理。
示例代码如下:
#! /usr/bin/env node
const { program } = require('commander')
const { version } = require('../package.json')
program
// 定义命令
.command('create')
// 定义别名
.alias('crt')
// 定义参数
.argument('<projectName>')
// 定义命令处理方法
.action(projectName => {
// 该方法接受一个回调函数,回调函数的参数名称就是我们前面定义的参数
console.log(projectName)
})
program.version(version).parse()
而且我们使用commander
会自动帮助我们生成help
选项,测试如下:
ywz --help
结果如下:
Usage: ywz [options] [command]
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
create|crt <projectName>
help [command] display help for command
优化命令
现在,对应我们这个项目来说,目前这个程度的使用commander
就够了,然后改造一下代码
#! /usr/bin/env node
const { program } = require('commander')
const { version } = require('../package.json')
const creatProject = require('..')
program
.command('create')
.alias('crt')
.argument('<projectName>')
.action(projectName => {
// 处理函数,定义在外部
creatProject(projectName)
})
program.version(version).parse()
require(..)
表示的上级目录中的index.js
,由于我们的package.json
中的main
字段中的入口在lib/index.js
中,所以require(..)
中引入的就是lib/index.js
文件,在该文件中写入如下代码
module.exports = function (name) {
console.log(name)
}
然后在命令行中输入ywz create node
,即可看到命令中输出的node
,即项目的名称。
获取远端模板
概述
这里我们将远端仓库存储在Github,介绍两个Github提供的两个API,分别如下:
- 获取指定用户的仓库列表
`https://api.github.com/users/${username}/repos`
- 获取指定仓库的分支列表
`https://api.github.com/repos/${username}/${repositoriesName}/branches`
我们使用测试的模板参考地址是:pacpc/node-template: node仓库模板 (github.com)
基础库的使用
首先我们介绍一下ora
和inquirer
库的使用。
inquirer
库用于在命令行交互,它的语法结构如下所示:
const inquirer = require('inquirer')
module.exports = async name => {
let { projectName } = await inquirer.prompt({
/* Pass your questions in here */
})
console.log(projectName)
}
inquirer.prompt()
方法的返回值是一个Promise,这里我们使用async/await
语法糖。
方法接受两个参数,两个都是对象,通常我们使用第一个就够了,具体语法内容可以参考这里。如下代码展示了inquirer
库的基本用法
const inquirer = require('inquirer')
module.exports = async name => {
let { projectName } = await inquirer.prompt({
// 问题的类型,input 表示输入
type: 'input',
// 答案的 key
name: 'projectName',
// 问题是什么
message: 'The project name is it?',
// 默认值
default: name,
})
let { license } = await inquirer.prompt({
// 问题的类型,list 表示可以选择
type: 'list',
// 答案的 key
name: 'license',
// 问题是什么
message: 'Choose a license',
// 支持选择的选项
choices: ['LGPL', 'Mozilla', 'GPL', 'BSD', 'MIT', 'Apache'],
// 默认值
default: 'MIT',
})
console.log(projectName, license)
}
现在我们在命令行中输入ywz create node-test
,运行结果如下所示:
ora
库用于实现loading效果,该库的使用比较简单,直接调用ora()
方法,可以传入一个字符串作为显示的内容,该方法返回一个实例对象,可以调用start()
方法开始旋转、stop()
停止旋转、succeed()
成功并停止旋转、fail()
失败停止旋转。还有很多实例方法,具体可以参考文档,点击这里
示例代码如下:
const inquirer = require('inquirer')
const ora = require('ora')
module.exports = async name => {
let { projectName } = await inquirer.prompt({
type: 'input',
name: 'projectName',
message: 'The project name is it?',
default: name,
})
const spinner = ora('开始加载...').start()
setTimeout(() => {
console.log('\n项目名称是:' + projectName)
spinner.succeed('加载完毕')
}, 3000)
}
测试结果如下:
获取远端模板
现在就可以通过axios
库获取我们的具体仓库名称,然后根据仓库名称获取对应的仓库分支,选择分支直接下载即可。
首先我们封装一个loading
方法,该方法可以为axios
的请求增加一个loading效果,具体实现代码如下:
/**
* @description: 为一个Promise函数添加一个loading效果
* @param {Function} callback 返回Promise且需要被loading修饰的函数
* @returns {Function} 被修饰后的方法
*/
const loading = callback => {
return async (...args) => {
// 开始
let spinner = ora('start...').start()
try {
// 没有异常即成功
let res = await callback(...args)
spinner.succeed('success')
return res
} catch (error) {
spinner.fail('fail')
return error
}
}
}
然将我们前面提到的两个API封装为方法,代码如下:
/**
* @description: 获取仓库列表
* @param {string} username 被获取的用户名
* @returns {Array} 仓库列表
*/
const fetchRepoList = async username => {
let { data } = await axios.get(
`https://api.github.com/users/${username}/repos`,
)
return data.map(item => item.name)
}
/**
* @description: 获取 branches 列表
* @param {string} username 需要获取的用户名
* @param {string} repoName 需要获取的仓库名称
* @returns {Array} branches 列表
*/
const fetchTagList = async (username, repoName) => {
let { data } = await axios.get(
`https://api.github.com/repos/${username}/${repoName}/branches`,
)
return data.map(item => item.name)
}
实现获取远端模板代码如下:
module.exports = async name => {
let { projectName } = await inquirer.prompt({
// 问题的类型,input 表示输入
type: 'input',
// 答案的 key
name: 'projectName',
// 问题是什么
message: 'The project name is it?',
// 默认值
default: name,
})
// 获取仓库列表
let repos = await loading(fetchRepoList)('pacpc')
// 选择仓库列表
let { repoName } = await inquirer.prompt({
type: 'list',
name: 'repoName',
message: 'Choose a template',
choices: repos,
})
// 获取所有 branches
let branches = await loading(fetchTagList)('pacpc', repoName)
// 如果有多个分支,用户选择多个分支,没有多个分支可以直接下载
if (branches.length > 1) {
// 存在
let { checkout } = await inquirer.prompt({
type: 'list',
name: 'checkout',
message: 'Choose the target version',
choices: branches,
})
repoName += `#${checkout}`
} else {
repoName += `#${branches[0]}`
}
}
现在我们可以通过命令行来测试这个代码的可行性了。
下载模板
download-git-repo库
download-git-repo
库可以下载Github中的存储库,使用方式也比较简单,直接将用户名/仓库名
作为参数传递即可;这里我们通过Node.js提供promisify()
方法将download-git-repo
库提供的方法转换为Promise,示例代码如下:
const { promisify } = require('util')
const download = promisify(require('download-git-repo'))
缓存处理
如果我们每次创建一个项目都要进行模板的下载的话,其实是不必要的,我们可以在第一次下载的时候进行一下缓存,以后如果有需要的话,我们可以直接使用,不需要下载。
一般我们将缓存存储在用户目录下的.tmp
目录下,在Node.js中获取用户目录通过process.env.USERPROFILE
来获取Windows下的用户目录,通过process.env.HOME
来获取macOS下的用户目录。还可以通过process.platform
属性来获取当前是不是Windows系统。示例代码如下:
// win32 表示 Windows 系统
console.log(process.platform) // win32
const user = process.env[process.platform == 'win32' ? 'USERPROFILE' : 'HOME']
console.log(user) // C:\Users\Administrator
定义下载函数
现在我们知道了如何下载一个Github上的模板,以及获取存储模板的目录,现在我们就来定义一个下载函数,实现代码如下:
/**
* @description: 下载具体仓库中的内容
* @param {string} username 仓库拥有者的名称
* @param {string} repoName 仓库名称 + 分支名称, # 号拼接
* @returns {string} 下载的临时目录
*/
const downloadGithub = async (username, repoName) => {
const cacheDir = `${
process.env[process.platform == 'win32' ? 'USERPROFILE' : 'HOME']
}/.tmp`
// 拼接一个下载后的目录
let dest = path.join(cacheDir, repoName)
// fs 模块提供的 existsSync 方法用于判断目录是否存在,如果存在,说明无需下载
let flag = existsSync(dest)
let url = `${username}/${repoName}`
if (!flag) {
// 需要下载 则执行下载
await loading(download)(url, dest)
}
return dest
}
该函数的使用如下:
let dest = await downloadGithub('pacpc', repoName)
渲染模板数据
现在我们就将模板已经下载到本地了,现在我们就开始对模板中的数据进行处理。
确定模板数据
我们的一个脚手架可能用来使用很多个模板,但是每个模板可能都有一些个性化的内容,我们我们在每个模板中增加一个question.js
,用来存储每个模板的问题,从而生成对应的内容。
这里测试的question.js
的内容如下:
module.exports = [
{
type: 'input',
name: 'version',
message: 'version?',
default: '0.1.0',
},
{
type: 'input',
name: 'description',
message: 'description',
},
{
type: 'input',
name: 'author',
message: 'author?',
},
{
type: 'input',
name: 'email',
message: 'email?',
},
{
type: 'input',
name: 'github',
message: 'github?',
},
{
type: 'list',
name: 'license',
message: 'Choose a license',
choices: ['LGPL', 'Mozilla', 'GPL', 'BSD', 'MIT', 'Apache'],
default: 'MIT',
},
]
模板引擎
这里我们使用的模板引擎是Handlebars (handlebarsjs.com),我们通过consolidate来统一管理模板引擎,使用方式也比较简单,示例代码如下:
const { render } = require('consolidate').handlebars
content = await render(content, data)
上面代码中content
表示原始数据,返回值是将原始数据中的模板语法替换为data
中的数据内容。
metalsmith库的应用
这里介绍一下metalsmith库的应用,该库是一个静态站点生成器,用法比较简单,如下代码所示:
Metalsmith(__dirname)
// 源目录 默认值 src
.source()
// 目标目录 默认值 build
.destination()
// 中间处理方法
.use(async (files, metal, done) => {
// files 就是需要渲染的模板目录下的所有类型的文件
// metal.metadata() 可以来保存所有的数据,交给下一个use 使用
// done() 执行完毕调用
done()
})
// 处理方法可以有多个
.use((files, metal, done) => {
// 获取上一个 use 中拿到的用户填写的数据
done()
})
// 处理完毕
.build((err) => {
if (err) {
// 失败了
} else {
// 成功了
}
})
上面就是metalsmith库的一个基本应用。
渲染数据
我们我们已经知道了渲染数据前的一些工具库的应用,以及每个模板中的提问内容,开始编写我们的这个主要代码,代码如下:
// 下载模板到临时目录
let dest = await downloadGithub('pacpc', repoName)
// 判断下载的模板中是否包含 question.js 如果包含则进行模板的替换,否则直接复制到目标仓库
if (existsSync(path.join(dest, 'question.js'))) {
await new Promise((resolve, reject) => {
Metalsmith(__dirname)
.source(dest)
.destination(path.resolve(projectName))
.use(async (files, metal, done) => {
// files 就是需要渲染的模板目录下的所有类型的文件
// 加载 question 文件
const quesList = require(path.join(dest, 'question.js'))
// 依据问题数据,定义交互问题
let answers = await inquirer.prompt(quesList)
// 当前 answers 保存的是用户传递的数据,我们通过 metal.metadata() 将其保存给下一个 use 中使用
let meta = metal.metadata()
Object.assign(meta, answers, { projectName })
// 删除 question.js 文件,避免拷贝的用户模板
// 可以通过 delete 关键字删除的原因是因为 files 中存在的全部都是 buffer,我们直接删除这个 key,对应的 value 也就被删除了
delete files['question.js']
done()
})
.use((files, metal, done) => {
// 获取上一个 use 中存储的数据
let data = metal.metadata()
// 将 files 中的所有自有属性制作为一个数据
let arr = Reflect.ownKeys(files)
// 通过遍历数组,将所有的 buffer 转换为字符串,然后通过模板引擎进行替换,最后转换为 buffer 存储即可
arr.forEach(async file => {
// 只对 js 或者 json 文件进行替换
if (file.includes('js') || file.includes('json')) {
let content = files[file].contents.toString()
// 如果包含模板引擎语法就进行替换
if (content.includes('{{')) {
content = await render(content, data)
files[file].contents = Buffer.from(content)
}
}
})
done()
})
// 如果有异常 Promise 调用 reject
.build(err => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
console.log('\nsuccess~')
} else {
// 如果不需要模板进行处理的直接拷贝至项目目录
ncp(dest, projectName)
}
到这里我们的基本代码就全部完成了,现在就可以测试带个代码了,命令行输入ywz create node-test
,测试结果如下:
写在最后
这篇文章这样就结束了,大概用了一下午的时间写了这篇文章,希望可以对你有所帮助。
这是 《轮子是怎么跑起来的》专栏的第一篇文章,该专栏持续输出一些轮子原理以及怎么造轮子的文章,如果和你的胃口,可以三连支持一下。