UzumakiItachi
首页
  • JavaSript
  • Vue

    • Vue2
    • Vue3
  • React

    • React_18
  • WebPack
  • 浏览器相关
  • 工程化相关
  • 工作中遇到的问题以及解决方案
  • Git
  • 面试
  • 学习
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
  • 个人产出
  • 实用工具
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

UzumakiItachi

起风了,唯有努力生存。
首页
  • JavaSript
  • Vue

    • Vue2
    • Vue3
  • React

    • React_18
  • WebPack
  • 浏览器相关
  • 工程化相关
  • 工作中遇到的问题以及解决方案
  • Git
  • 面试
  • 学习
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
  • 个人产出
  • 实用工具
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • WebPack

  • 浏览器相关

  • 工程化相关

    • 执行npm run dev的时候发生了什么
    • 仓库版本管理-standard-version
    • git版本管理以及生成changelog
    • 发布一个npm包
    • gitlab runner 免密码登录服务器
    • 实现一个小程序持续集成工具
    • 使用changeset管理monorepo项目
    • pnpm+trubo打造一个极致丝滑的monorepo工程
    • 编写一个生成git提交信息的vite插件
    • 关于构建SPA项目的一些优化
    • 实现一个简易的脚手架
    • vite搭建多页面项目
    • 实现一个可自定义模板内容的脚手架
      • 前言
      • 新增功能
      • 如何动态写入配置
      • 写入基础代码
      • 安装依赖
      • 结语
  • 工作中遇到的问题以及解决方案

  • Git

  • Vite

  • 一些小工具

  • 算法

  • 服务器

  • HTTP

  • 技术
  • 工程化相关
hanhanbuku
2024-05-29
目录

实现一个可自定义模板内容的脚手架

# 前言

此前写了一个简易的脚手架,根据用户选项去拉取指定的远端模板。后面在使用过程中发现实在不太灵活,如果能做到像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);

1
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()
            }
        }
    }
}
1
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})
1
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)
        }
    }
1
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');
    })
}

1
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}`)
        }
    });
}
1
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异步导致的问题以及开启子进程所遇到的一些问题。脚手架目前还欠缺一个通过进度条的方式输出依赖安装过程的功能,后续会继续完善

编辑 (opens new window)
上次更新: 2024/05/29, 17:24:40
vite搭建多页面项目
解决浏览器返回页面不刷新的问题

← vite搭建多页面项目 解决浏览器返回页面不刷新的问题→

最近更新
01
前端检测更新,自动刷新网页
06-09
02
swiper渲染大量数据的优化方案
06-06
03
仿抖音短视频组件实现方案
02-28
更多文章>
Theme by Vdoing | Copyright © 2023-2025 UzumakiItachi | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式