实现一个可自定义模板内容的脚手架
# 前言
此前写了一个简易的脚手架,根据用户选项去拉取指定的远端模板。后面在使用过程中发现实在不太灵活,如果能做到像
vueCli
、createVite
这些成熟的脚手架一样可以自定义模板的内容就好了。本文就来基于上次那个简易的脚手架来改造一下。
# 新增功能
- 支持拉取远端模板
- 支持自定义模板内容
- 支持安装依赖
- 支持覆盖重名目录
*脚手架的基础核心可以看之前那篇文章,本次开发完全基于上一次的脚手架做二开 实现一个简易的脚手架 (opens new window)
现在的脚手架入口长这样
#! /usr/bin/env node
import {Command} from "commander";
import chalk from "chalk";
import Creator from "../src/lib/creator.js";
import path from 'path'
import inquirer from 'inquirer'
import Questions from "../src/lib/questions.js";
import fs from 'fs-extra'
import {filterFile} from "../src/lib/utils.js";
const program = new Command();
program
.command('create <app-name>') // 创建脚手架的命令
.description('创建一个新项目') // 命令的描述
.option('-d, --default', '跳过选项,使用默认配置')
.option('-f, --force', '覆盖当前已存在的目录')
.action(async (name,options,cmd)=>{
// 获取工作目录
const cwd = process.cwd();
// 目标目录也就是要创建的目录
const targetDir = path.join(cwd, name);
// 先检测文件是否存在
await filterFile(targetDir,options)
// 询问是否自定义选项
const anwser = await inquirer.prompt([
{
name: 'customerOrTemplate',
type: 'list',
// 提示信息
message: '请选择使用模板还是自定义配置',
// 选项
choices: [
{name: '使用模板', value: 'template'},
{name: '自定义配置项', value: 'customer'},
],
},
{
name: 'nodeModules',
type: 'list',
// 提示信息
message: '请选择包管理器',
// 选项
choices: [
{name: 'npm', value: 'npm'},
{name: 'yarn', value: 'yarn'},
{name: 'pnpm', value: 'pnpm'},
],
},
])
// 如果选择直接使用模板
if(anwser.customerOrTemplate==='template'){
// 初始化下载器
const creator = new Creator(name, targetDir);
creator.createTemplate(anwser.nodeModules)
return
}
// 添加一些自定义选项
const customerAnwser = await inquirer.prompt(Questions)
// 初始化下载器
const creator = new Creator(name, targetDir);
creator.create({...customerAnwser,nodeModules:anwser.nodeModules})
})
program.on('--help',()=>{
console.log();
console.log(`Run ${chalk.cyan('rippi <command> --help')} to show detail of this command`);
console.log();
})
program.version('1.0.5', '-v, --version')
// 解析用户执行命令传入的参数
program.parse(process.argv);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
可以看到 和之前相比 我们新增了一个filterFile
函数用于检测文件是否存在,以及一个新的提问 询问是拉取远端模板还是自定义内容。不同的选项将触发不同的问答分支。
先来解析一下filterFile
函数
/**
* 检测文件是否存在
*/
export const filterFile = async (targetDir, options) => {
// 先检测文件是否存在
if (fs.existsSync(targetDir)) {
// 用户是否选择了强制创建
if (!options.force) {
// 询问是否强制创建
let {action} = await inquirer.prompt([{
name: 'action',
type: 'list',
message: '当前目录已存在:',
choices: [
{
name: '覆盖',
value: 'overwrite'
}, {
name: '取消',
value: false
}
]
}])
if (!action) {
process.exit(1);
} else {
fs.remove(targetDir)
return Promise.resolve()
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
函数比较简单 就是通过fs
模块的existsSync
函数检测目标路劲是否存在,存在的话根据用户选择是否覆盖,如果选择了覆盖就调用fs
的remove
函数删除掉当前的文件就行了。
// 如果选择直接使用模板
if(anwser.customerOrTemplate==='template'){
// 初始化下载器
const creator = new Creator(name, targetDir);
creator.createTemplate(anwser.nodeModules)
return
}
// 添加一些自定义选项
const customerAnwser = await inquirer.prompt(Questions)
// 初始化下载器
const creator = new Creator(name, targetDir);
creator.create({...customerAnwser,nodeModules:anwser.nodeModules})
2
3
4
5
6
7
8
9
10
11
12
createTemplate
函数就是之前旧的拉取模板的函数,本文就不过多赘述了。重点看create
函数。
// 入口函数
create = async (anwser)=>{
const spinner = ora('正在获取模板...').start();
loading.color = 'yellow'
try{
// 将cli中的模板文件拷贝到本地
// 获取 CLI 工具模块的路径
const sourcePath = path.resolve(
fileURLToPath(import.meta.url), // import.meta.url es模块中当前文件所处的位置
'../../template',
`vue3-${anwser.lang}`,
)
const targetPath = path.join(process.cwd(), this.name);
// 将模板拷贝到本地
await fs.copy(sourcePath, targetPath);
// 动态添加依赖库
for (let key in anwser){
if(key!=='lang'&&key!=='nodeModules'&& anwser[key]!==false){
await modulesMap[key](targetPath,modulesMap[key])
}
}
// // 动态新增package.json里的内容
await handleAddPackage(targetPath,anwser)
spinner.info('正在添加依赖...');
// 安装依赖
await installDependencies(this.dir,anwser.nodeModules)
}catch (e){
spinner.fail('模板生成失败:' + e)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
这里的代码基础模板是放置在了cli
中,所以我们要做的就是将cli中
的模板拷贝到本地,然后根据用户的选择往本地文件中写入对应的配置,这样就完成了一个支持自定义内容的脚手架
这里需要注意:因为我们的cli
是安装在本地的node_modules
中的所以查找他的路径需要借助一下es6
的import.meta.url
。这个变量就是当前执行文件所处的位置,根据这个位置我们就可以拿到cli
中template
的位置了。
靠北到本地后我们就要开始往本地文件写入配置了。
# 如何动态写入配置
这里有两种方法,第一种是采用ejs
动态模板填入,第二种是cli
中内置依赖文件的基础代码然后写入到本地。
设计之初考虑到我们的脚手架会动态的增删一些文件,例如用户选择了vuex
,那我们就需要新增vuex
的基础代码,如果没有选则不需要。所以ejs
动态模板的方式不太适合我们,动态模板主要用于处理一个永远存在的文件里的内容动态设置。
关于ejs
的使用可以看一下官方文档EJS (opens new window)
综上所述 我们这里采用写入cli
内置代码的方式。由于方法都是一样的,所以本文只拿vuex
作为例子。
# 写入基础代码
首先我们需要准备一个写入基础代码的工具函数
// addVuex.js
import path from 'path'
import fs from 'fs-extra'
const __dirname = path.resolve();
/**
* 往模板中添加vuex库相关代码
*/
export const handleAddVuex = (templatePath) => {
const storeContent =
`import { createStore } from 'vuex';
export default function() {
return createStore({
state: {
count: 0
},
getters: {
count: state => state.count
},
mutations: {
increment(state) {
state.count++;
}
},
actions: {
increment(context) {
context.commit('increment');
}
}
});
}`
const storePath = path.join(templatePath, 'src/store');
const storeFilePath = path.join(storePath, 'index.js');
// 确保 store 目录存在
fs.ensureDirSync(storePath);
// 写入
fs.outputFileSync(storeFilePath, storeContent);
// 定义 main.js 的路径
const mainJsPath = path.join(templatePath, 'src', 'main.js');
return fs.readFile(mainJsPath,'utf-8').then(content=>{
console.log(content)
// 定义 Vuex 相关的代码片段
const vuexImportStatement = "import store from './store'";
// 修改 main.js 的内容,添加 Vuex 相关的代码
let updatedContent = content;
// 确保不在文件中重复添加相同的代码
if (!updatedContent.includes('import store from')) {
updatedContent = updatedContent.replace("import App from './App.vue'",
`import App from './App.vue'\n${vuexImportStatement}`
);
updatedContent = updatedContent.replace(
'createApp(App).',
`createApp(App).use(store).`
);
}
return fs.writeFile(mainJsPath, updatedContent, 'utf-8');
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
代码其实也很简单,就是准备一个vuex
的基础代码,然后通过fs
的一些函数去进行写入,这里主要用到了以下几个
fs.ensureDirSync
- 接受文件目录作为参数。查询目录是否存在,不存在则创建目录
fs.outputFileSync
- 将文本内容写入到目标路径,接受两个参数:一个目标路径,一个需要写入的内容,这是一个同步方法
fs.readFile
- 读取本地文件信息,可以指定编码
fs.writeFile
- 异步写入文件内容,功能和
outputFileSync
类似,writeFile
使用回调函数来处理错误;outputFileSync
会抛出一个异常,需要使用try...catch
语句来捕获。
- 异步写入文件内容,功能和
这里其实都可以使用writeFile
来写入文件,这样异步执行不会阻塞主进程,性能更高。但是代码的执行顺序会变得不可控需要自己设计promise
来控制写入顺序。一开始在写的时候我也没太分清这两个写入函数的区别,所以混用了。这里其实应该都采用writeFile
去写入
代码执行到这里就已经完成文件的写入了,剩下的就是根据自己的需求去拓展写入的函数,注意一下编码格式以及代码的写入顺序就可以了。
注意
由于writeFile
是异步执行的原因,导致在实际开发过程中发现所有的写入只有最后一次生效了,也就是上文中遍历用户选项的函数中await
没起作用,所以这里将fs.readFile
return
出去,也就是返回了一个promise
对象出去以达到等待的效果
# 安装依赖
当所有文件都写入完之后,接下来就进行最后一步,自动安装依赖。
/**
* 安装依赖
*/
export const installDependencies = async (targetPath, manager) => {
const managerMap = {
npm: 'install',
yarn: '',
pnpm: 'install'
};
const command = managerMap[manager] || 'install';
const spinner = ora(`正在使用 ${manager} 安装依赖...`).start();
const child = spawn(manager, [command], {
cwd: targetPath,
env: process.env,
});
if (!child) {
console.error('子进程创建失败');
return;
}
// 监听子进程的标准输出(stdout)
child.stdout.on('data', (data) => {
// spinner.text(data.toString()) // 将npm的输出
});
// 监听子进程的标准错误输出(stderr)
child.stderr.on('data', (data) => {
spinner.fail(data.toString());// 将npm的错误输出
});
// 监听子进程错误事件
child.on('error', (error) => {
console.error('启动安装进程出错:', error);
spinner.fail('启动安装进程出错');
});
// 监听子进程的结束事件
child.on('close', (code) => {
if (code === 0) {
spinner.succeed('依赖安装成功!')
console.log(chalk.green(`执行${manager} run dev 运行项目`));
} else {
spinner.fail(`安装过程中出错,退出码:${code}`)
}
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
这里主要通过开启一个子进程的方式去执行安装依赖的命令,耐心等待依赖安装完毕整个脚手架的工作就结束啦!
注意
nodejs
提供了多种开启子进程的方式,但是这里我们想实现一个读取依赖安装过程的功能,也就是需要实时输出子进程的打印,所以采用了spawn
函数来启动子进程。同时nodejs
自带的spawn
并不是很好用,会有兼容性问题,我们可以安装cross-spawn
这个库来解决
# 结语
脚手架整体的视线难度并不,但是这过程中会涉及到很多
node
相关的操作。比如读写文件,开启子进程等。实现过程中遇到的问题主要是如何获取cli中
的模板,writeFile
异步导致的问题以及开启子进程所遇到的一些问题。脚手架目前还欠缺一个通过进度条的方式输出依赖安装过程的功能,后续会继续完善