引子
在前端项目中,脚手架可以理解为自动为我们创建项目基础文件的一种工具。除了创建文件外,更重要的是提供给开发者一些约定和规范。通常在去开发相同类型的项目时都会有一些相同的约定,例如:
- 相同的组织规范
- 相同的开发范式
- 相同的模块依赖
- 相同的工具配置
- 相同的基础代码
有时创建新项目时,会有大量的重复工作要做,脚手架工具就是用来解决这样一类问题的。我们可以通过脚手架工具去快速的搭建特定类型的项目骨架,然后基于这个骨架进行后续开发工作。
在前端项目创建过程中,由于前端技术选型的多样性,加上没有一个统一的标准,所以前端项目的脚手架工具不会集成在同一个当中。在前端项目中有很多成熟的脚手架工具,但是大多数都是为了特定项目类型而服务的。例如使用create-react-app创建react项目、使用vue-cli创建vue项目、使用angular-cli创建angular项目。它们的实现方式大同小异,通过开发者提供的一些信息去主动生成一些项目文件以及一些配置。还有一些脚手架工具可以生成通用型项目,比如yeoman,它可以根据一套模板生成对应的项目结构。这类型的工具一般都很灵活而且易于扩展。除了上面这些在创建项目时才会用到的脚手架工具,还有一些脚手架工具也特别好用,比如plop,他们可以在项目创建过程中用来创建特定类型的文件,例如可以在项目中创建一个组件或者模块所需要的文件,这些文件一般都是需要几个特定的文件组成的,而且每个文件都有一些特定的代码。相比于开发者手动创建这些文件,脚手架会以更为稳定的一种方式创建文件。
对比以上的脚手架工具,我们也可以自己自定义一个满足自己需求的脚手架工具。
初始化脚手架文件结构
创建一个空文件夹并通过npm init
或者yarn init
创建一个package.json
文件,这里使用yarn
:
yarn init --yes
在package.json
文件中再加一项bin
属性:
{
...,
"bin": "cli.js",
...
}
现在package.json
文件中大概是这样:
name属性为脚手架的名称,以上图为例,在后续可以使用
my-cli
的命令来启动脚手架
再创建一个cli.js
文件,此文件为该脚手架的入口文件,在文件的开头应加上:
#!/usr/bin/env node
Node CLI应用入口文件必须要这样的开头
再创建一个template
文件夹,用于存放模板文件。
TIPS:模板文件中存放着要生成的项目基本结构以及文件,以下以vue项目大致的结构为例的
template
└───template/.........................模板目录
├───public/.........................public目录
├───src/............................src目录
| └───api/..........................api目录
│ └───assets/.......................资源目录
│ └───fonts/......................字体资源目录
│ └───icon/.......................图标目录
│ └───img/........................图片目录
│ └───styles/.....................公共样式目录
│ └───components/.................组件目录
│ └───config/.....................配置文件目录
│ └───derectives/.................指令文件目录
│ └───filters/....................过滤器文件目录
│ └───layouts/....................页面布局文件目录
│ └───mixins/.....................混合文件目录
│ └───mock/.......................mock文件目录
│ └───plugins/....................插件目录
│ └───router/.....................路由文件目录
│ └───store/......................vuex文件目录
│ └───themes/.....................主题文件目录
│ └───utils/......................其他文件目录
│ └───views/......................vue文件目录
│ └───App.vue.....................入口vue文件
│ └───main.js.....................入口js文件
└───.env.development/.............开发环境配置文件
└───.env.production/..............生产环境配置文件
└───.eslintrc.js..................eslint配置文件
└───.gitignore....................git忽略文件
└───babel.config.js...............babel配置文件
└───jsconfig.json.................文件指定根目录和JavaScript服务提供的功能选项文件
└───package.json..................模块包配置文件
在模板文件中,可以通过ejs
的语法为模板渲染预留位置,例如给template
中的package.json
中name
属性值替换成"<%= name %>"
,这样在后续可以通过询问用户项目名称然后将用户输入的用户名称渲染在此处。
vue项目中index.html文件中也会自带的有这样的语法,如:
![]()
而我们只想让它自己的ejs语法原样输出,所以可以在
<%
后再加上一个%,让其能够原样输出![]()
然后在命令行内输入npm link
或者yarn link
命令,将其存于npm
或者yarn
的内存之中,以便后续使用脚手架。
在cli.js
内输入
#!/usr/bin/env node
console.log('cli working~')
然后在命令行之中输入my-cli
运行脚手架,会有打印出cli working
,这样就说明脚手架可以成功运行起来了。
至此,脚手架的文件结构基本上就完成了,接下来就开始实现脚手架的工作流程。
脚手架工作流程
一般来说脚手架的工作流程分为这两步:
- 通过命令行交互询问用户问题
- 根据用户回答的结果生成文件
通过命令行交互询问用户问题
使用npm i inquirer
或yarn add inquirer
命令安装inquirer
模块,此模块可以帮助我们实现与用户的命令行交互、获取用户输入的内容。
在cli.js
内引入并使用该模块:
#!/usr/bin/env node
// console.log('cli working~')
const inquirer = require('inquirer')
inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Project Name?'
}
]).then(answer => {
console.log(answer)
})
inquirer
的prompt
方法可以实现在命令行中与用户交互,prompt
的then
方法传入一个回调函数,回调函数中的参数为用户输入的内容。
命令行中使用my-cli
运行脚手架,将会询问项目名称。输入名称按下回车后将会打印刚刚所输入的内容:
{ name: 项目名称 }
接下来将要考虑如何根据用户的输入来生成对应的项目文件
根据用户回答的结果生成文件
根据生成文件这样的需求,我们大致可以想到这个需求需要读取模板文件、渲染模板文件和生成目标文件这样的操作,所以需要引入node
的fs
和path
这两个核心模块并且安装引入ejs
模块来渲染模板文件。
// 安装ejs
yarn add ejs
or
npm i ejs
#!/usr/bin/env node
// console.log('cli working~')
// 引入
const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer')
const ejs = require('ejs')
inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Project Name?'
}
]).then(answer => {
console.log(answer)
})
这样基本工作就做完了,现在开始完善prompt
的then
方法中的逻辑。
在then
方法中首先定义一个变量来保存模板文件的路径:
// 模板路径
const tempDir = path.join(__dirname, 'templates') // __dirname为当前文件(cli.js)的路径
再定义一个变量来保存目标文件的路径:
// 目标路径
const destDir = path.join(process.cwd(), answer.name)
// process.cwd()为脚手架工作时的路径,将其与用户输入的项目名称拼接起来作为目标路径
判断当前文件夹下是否有目标路径的目录,若有则抛出一个错误,否则就创建目标文件夹:
// 判断当前文件夹下是否有目标路径的目录
if (fs.existsSync(destDir)) {
throw Error(`Folder named '${answer.name}' is already existed`)
}
// 创建文件夹
fs.mkdir(destDir, {recursive: true}, (err) => {
if (err) throw err
})
接下来开始开始编写读取、渲染以及生成的代码。
为了方便以及代码美观,我们将封装成一个函数,接下来这些操作的逻辑将在rw
函数中书写:
/**
* @description 模板文件的读取、渲染以及生成
* @param tempDir 模板路径
* @param destDir 目标路径
* @param answer 用户输入的内容
*/
const rw = (tempDir, destDir, answer) => { }
整理一下cli.js
中的代码:
#!/usr/bin/env node
// 引入
const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer')
const ejs = require('ejs')
inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Project Name?'
}
]).then(answer => {
// 模板路径
const tempDir = path.join(__dirname, 'templates')
// 目标路径
const destDir = path.join(process.cwd(), answer.name)
// 判断当前文件夹下是否有目标路径的目录
if (fs.existsSync(destDir)) {
throw Error(`Folder named '${answer.name}' is already existed`)
}
// 创建文件夹
fs.mkdir(destDir, {recursive: true}, (err) => {
if (err) throw err
})
// 将模板下的文件全部转换到目标目录
rw(tempDir, destDir, answer)
// 成功后的打印
console.log(`Congratulations! Project '${answer.name}' has been created successfully~`)
})
/**
* @description 模板文件的读取、渲染以及生成
* @param tempDir 模板路径
* @param destDir 目标路径
* @param answer 用户输入的内容
*/
const rw = (tempDir, destDir, answer) => { }
读取模板文件
const rw = (tempDir, destDir, answer) => {
// 使用fs.readdir方法读取文件夹下所以文件
fs.readdir(tempDir, (err, files) => {
if (err) throw err
// 遍历文件数组
files.forEach(file => {
// 得到当前文件的路径
const filePath = path.join(tempDir, file)
// 读取文件的状态
fs.stat(filePath, (err1, stats) => {
if (err1) throw err1
// 通过stats中的isFile方法判断当前文件是文件还是文件夹
if (stats.isFile()) {
// 判断当前文件夹是否为图片、字体文件,若是则直接读写
if (destDir.includes('img') || destDir.includes('fonts') || destDir.includes('icon') || destDir.includes('.ico')) {
const readStream = fs.createReadStream(filePath)
const writeStream = fs.createWriteStream(path.join(destDir, file))
readStream.pipe(writeStream)
} else {
...渲染
}
} else {
// 如果当前的'file'是文件夹,就创建该文件夹再递归调用rw方法
const targetDir = path.join(destDir, file)
fs.mkdir(targetDir, { recursive: true }, err2 => {
if (err2) throw err2
rw(filePath, targetDir, answer)
})
}
})
})
})
}
渲染模板文件
// 通过模板引擎渲染文件,回调内的第二个参数是渲染完成后的结果
ejs.renderFile(filePath, answer, (err2, res) => {
if (err2) throw err2
...写入
})
生成目标文件
// 将渲染完成后的结果写入目标路径
fs.writeFileSync(path.join(destDir, file), res)
有点乱-_-|||整理一下rw
函数的代码:
/**
* @description 模板文件的读取、渲染以及生成
* @param tempDir 模板路径
* @param destDir 目标路径
* @param answer 用户输入的内容
*/
const rw = (tempDir, destDir, answer) => {
fs.readdir(tempDir, (err, files) => {
if (err) throw err
files.forEach(file => {
// 得到当前文件的路径
const filePath = path.join(tempDir, file)
// 读取文件的状态
fs.stat(filePath, (err1, stats) => {
if (err1) throw err1
// 通过stats中的isFile方法判断当前文件是文件还是文件夹
if (stats.isFile()) {
// 判断当前文件夹是否为图片文件,若是则直接读写
if (destDir.includes('imgs')) {
const readStream = fs.createReadStream(filePath)
const writeStream = fs.createWriteStream(path.join(destDir, file))
readStream.pipe(writeStream)
} else {
// 通过模板引擎渲染文件,回调内的第二个参数是渲染完成后的结果
ejs.renderFile(filePath, answer, (err2, res) => {
if (err2) throw err2
// 将渲染完成后的结果写入目标路径
fs.writeFileSync(path.join(destDir, file), res)
})
}
} else {
// 如果当前的'file'是文件夹,就创建该文件夹再递归调用rw方法
const targetDir = path.join(destDir, file)
fs.mkdir(targetDir, { recursive: true }, err2 => {
if (err2) throw err2
rw(filePath, targetDir, answer)
})
}
})
})
})
}
看这一层接一层的回调函数实在头疼,再将带有回调的异步操作改成它同步的API叭:
// optimization--rw方法中过多回调函数,改成同步的api
/**
* @description 模板文件的读取、渲染以及生成
* @param tempDir 模板路径
* @param destDir 目标路径
* @param answer 用户输入的内容
*/
const rw = (tempDir, destDir, answer) => {
try {
const files = fs.readdirSync(tempDir)
files.forEach(file => {
const filePath = path.join(tempDir, file)
const targetDir = path.join(destDir, file)
const stats = fs.statSync(filePath)
if (stats.isFile()) {
if (destDir.includes('img') || destDir.includes('fonts') || destDir.includes('icon') || destDir.includes('.ico')) {
const readStream = fs.createReadStream(filePath)
const writeStream = fs.createWriteStream(targetDir)
readStream.pipe(writeStream)
} else {
ejs.renderFile(filePath, answer, (err2, res) => {
if (err2) throw err2
// 将渲染完成后的结果写入目标路径
fs.writeFileSync(path.join(destDir, file), res)
})
}
} else {
fs.mkdirSync(targetDir)
rw(filePath, targetDir, answer)
}
})
} catch (e) {
throw e
}
}
ejs.renderFile
方法本来想用ejs.render
替换的,但是一直有点问题,还望先生救我还望大佬指点。
代码总结及测试
来整理一下整个cli.js
的代码叭:
#!/usr/bin/env node
// node cli应用入口文件必须要这样的开头👆
const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer')
const ejs = require('ejs')
inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Project Name?',
}
]).then(answer => {
// console.log(answer)
// 模板路径
const tempDir = path.join(__dirname, 'templates')
// 目标路径
const destDir = path.join(process.cwd(), answer.name)
// 判断当前文件夹下是否有目标路径的目录
if (fs.existsSync(destDir)) {
throw Error(`Folder named '${answer.name}' is already existed`)
}
// 创建文件夹
fs.mkdir(destDir, {recursive: true}, (err) => {
if (err) throw err
})
// 将模板下的文件全部转换到目标目录
rw(tempDir, destDir, answer)
console.log(`Congratulations! Project '${answer.name}' has been created successfully~`)
})
/**
* @description 模板文件的读取、渲染以及生成
* @param tempDir 模板路径
* @param destDir 目标路径
* @param answer 用户输入的内容
*/
const rw = (tempDir, destDir, answer) => {
try {
const files = fs.readdirSync(tempDir)
files.forEach(file => {
const filePath = path.join(tempDir, file)
const targetDir = path.join(destDir, file)
const stats = fs.statSync(filePath)
if (stats.isFile()) {
if (destDir.includes('img') || destDir.includes('fonts') || destDir.includes('icon') || destDir.includes('.ico')) {
const readStream = fs.createReadStream(filePath)
const writeStream = fs.createWriteStream(targetDir)
readStream.pipe(writeStream)
} else {
ejs.renderFile(filePath, answer, (err2, res) => {
if (err2) throw err2
// 将渲染完成后的结果写入目标路径
fs.writeFileSync(path.join(destDir, file), res)
})
}
} else {
fs.mkdirSync(targetDir)
rw(filePath, targetDir, answer)
}
})
} catch (e) {
throw e
}
}
找一个自己喜欢的文件夹,找个空文件夹,打开命令行,输入my-cli
,提示输入项目名称,按下回车:
这样就成功创建了一个基础项目,打开之前给ejs
预留的位置看看有没有把输入的项目名称成功渲染上:
看到这样子大致就完成了一个简易的脚手架。当然也可以通过更多的交互来形成更好的效果,这里就不再演示了~
自动为基础项目安装node_modules
引入child-process
(子进程)下的spawn
方法,它来执行cmd
命令:
const spawn = require('child_process').spawn
在inquirer.prompt
方法中再加一项问题:
{
type: 'rawlist',
name: 'method',
choices: [
{ name: 'I\'ll do it by myself', value: 'either' },
{ name: 'install by yarn', value: 'yarn' },
{ name: 'install by npm', value: 'npm' },
],
message: 'Do you want Node_Modules installed automatically?',
}
在打印项目创建成功之后来处理用户的这条输入的结果,处理方法可以封装为一个函数:
// 整理命令并返回需要的结构结果
const formatCMD = (method, destDir) => {
switch (method) {
case 'npm':
return [process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install'], destDir]
case 'yarn':
return [process.platform === 'win32' ? 'yarn.cmd' : 'yarn', [], destDir]
case 'either':
return
}
}
// 运行命令
const runCmd = (command, args, destDir) => {
spawn(command, args, { stdio: 'inherit', cwd: destDir }, err => {
if (err) {
throw err
} else {
console.log('All done')
}
})
}
整理一下代码:
#!/usr/bin/env node
const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer')
const ejs = require('ejs')
const spawn = require('child_process').spawn
inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Project Name?',
},
{
type: 'rawlist',
name: 'method',
choices: [
{ name: 'I\'ll do it by myself', value: 'either' },
{ name: 'install by yarn', value: 'yarn' },
{ name: 'install by npm', value: 'npm' },
],
message: 'Do you want Node_Modules installed automatically?',
}
]).then(answer => {
// console.log(answer)
// 模板路径
const tempDir = path.join(__dirname, 'templates')
// 目标路径
const destDir = path.join(process.cwd(), answer.name)
// 判断当前文件夹下是否有目标路径的目录
if (fs.existsSync(destDir)) {
throw Error(`Folder named '${answer.name}' is already existed`)
}
// 创建文件夹
fs.mkdir(destDir, {recursive: true}, (err) => {
if (err) throw err
})
// 将模板下的文件全部转换到目标目录
rw(tempDir, destDir, answer)
const cmd = formatCMD(answer.method, destDir)
if (!cmd) {
console.log(`Congratulations! Project '${answer.name}' has been created successfully~`)
} else {
runCmd(...cmd)
}
})
/**
* @description 模板文件的读取、渲染以及生成
* @param tempDir 模板路径
* @param destDir 目标路径
* @param answer 用户输入的内容
*/
const rw = (tempDir, destDir, answer) => {
try {
const files = fs.readdirSync(tempDir)
files.forEach(file => {
const filePath = path.join(tempDir, file)
const targetDir = path.join(destDir, file)
const stats = fs.statSync(filePath)
if (stats.isFile()) {
if (destDir.includes('img') || destDir.includes('fonts') || destDir.includes('icon') || destDir.includes('.ico')) {
const readStream = fs.createReadStream(filePath)
const writeStream = fs.createWriteStream(targetDir)
readStream.pipe(writeStream)
} else {
ejs.renderFile(filePath, answer, (err2, res) => {
if (err2) throw err2
// 将渲染完成后的结果写入目标路径
fs.writeFileSync(path.join(destDir, file), res)
})
}
} else {
fs.mkdirSync(targetDir)
rw(filePath, targetDir, answer)
}
})
} catch (e) {
throw e
}
}
/**
* @description 运行命令
* @param command 指令
* @param args 指令参数
* @param destDir 目标路径
*/
const runCmd = (command, args, destDir) => {
spawn(command, args, { stdio: 'inherit', cwd: destDir }, err => {
if (err) {
throw err
} else {
console.log('All done')
}
})
}
/**
* @description 整理命令
* @param method 用户输入的方法
* @param destDir 目标路径
* @returns {[(string), *[], undefined]|[(string), [string], undefined]}
*/
const formatCMD = (method, destDir) => {
switch (method) {
case 'npm':
return [process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install'], destDir]
case 'yarn':
return [process.platform === 'win32' ? 'yarn.cmd' : 'yarn', [], destDir]
case 'either':
return
}
}
运行来试验一下:
成功运行没啥大问题了