仿vue-cli从零搭建一个前端脚手架

前言

工作中,我们可能经常碰到下面这些问题:

  • 公司需要开发一个新项目,项目的权限,菜单,登录等功能在别的项目已经存在。
  • 项目需要配置例如eslint等代码规范,需要和原先的一些项目保持一致(可能所有的项目都需要保持一致)。
  • 公司的所有项目需要遵循一套风格,比如皮肤,字体规范,按钮形状等等...

这个时候,我们可能会复制菜单等逻辑的代码,复制eslint规则的json,复制样式的css或者font文件等等。。。

但是显然这并不是一个优雅的解决方案,因此,我们需要去开发一个脚手架,这个脚手架自动生成项目,项目中有已经存在的基础功能(权限,菜单等),有统一的一套技术栈(比如vue3+vite),有统一的代码规范,甚至有统一的单元测试等等...

所以,话不多说,走起~~~

搭建脚手架

初始化脚手架名称

首先,给你的脚手架定一个响亮的名称(我的是ywill-cli),需要注意的是,不要和npm源上的包名重复,否则在你想发布到npm的时候会有重名问题(定好名字后可以去npm上搜一下是否存在了这个包)。

现在,根据你定的包名,创建一个和你包名同名的文件夹。然后初始化package.json:

npm init -y
复制代码

创建入口

你已经拥有了一个属于你的包的文件夹了(虽然空空如也...),现在该去创建一个入口了。

入口就是在最后例如使用 ywill-cli 命令的时候,执行的入口文件。

在你的包文件夹下,建立一个bin目录,里面增加一个main文件(注意,纯main文件,没有.js这种后缀)。main中代码如下:

#! /usr/bin/env node

console.log('我是入口文件....')
复制代码

这个时候,在命令行运行node ./bin/main发现控制台就执行了main这个文件,并打印出来了结果:

screenshot-20220727-171219.png

#! /usr/bin/env是base脚本需要在第一行指定脚本的解释语言,我们使用的语言是node。

当然,你可能更希望运行例如ywill-cli而不是node ./bin/mian,所以我们在package.json中增加以下代码:

"bin": {
    "ywill-cli": "bin/enter"
  }
复制代码

接着,为了暂时能够升级ywill-cli为全局命令,我们运行npm link

就这么简单,好了,这个时候在控制台使用ywill-cli就可以打印出来了。

配置脚手架的选项(options)

首先,什么是options?看看vue脚手架我们就一目了然了

screenshot-20220727-172500.png

没错,红框框中的就是选项。

我们现在也为我们的脚手架增加选项。

增加版本

我们需要用到一个插件commander,安装一下:

npm install commander@9
复制代码

新版本好像不支持commonjs语法,具体我也没仔细看,保险起见,还是安装9.x.x的把

commander是用来实现脚手架命令配置的插件,大家可以自己去commander中文文档查看。

为main中增加如下代码:

const program = require('commander');

// 获取当前版本号
const version = require('../package.json').version;

program
    // 配置脚手架名称
    .name('ywill-cli')
    // 配置命令格式
    .usage(`<command> [option]`)
    // 配置版本号
    .version(version);
    

program.parse(process.argv);
复制代码

代码非常简单,注释也写清楚了。这个时候,我们运行ywill-cli --help看一下:

screenshot-20220727-174030.png

很棒,已经有--version的提示了。我们执行一下命令:ywill-cli --version会发现可以输出版本号了。

增加提示

我们再看看vue的提示:

screenshot-20220727-174519.png

为了增加提示(其实是为了美化效果),我们需要引入插件chalk

npm install chalk@4
复制代码

chalk是用来美化字体的插件,也就是改变字体, 背景颜色等等,大家可以自己去chalk地址查看。

现在,我们继续为main增加下面代码:

const chalk = require('chalk');

// 给提示增加
program.on('--help', () => {
    console.log();
    console.log(
        `Run ${chalk.cyan(
            'ywill-cli <command> --help'
        )} for detailed usage of given command.
    `)
});
复制代码

继续运行ywill-cli --help看一下:

screenshot-20220727-175034.png

成功,那么配置脚手架的选项(options)我们就实现了,so easy!!!

配置脚手架命令(command)

脚手架的核心是命令,例如vue create xxx,所以,我们也需要实现自己的脚手架命令(毕竟开发脚手架就是为了这个)。

我们现在明确这个脚手架的需求是:使用这个脚手架,可以选择拉取vue2或者vue3的模版代码(当然,以后你们的脚手架可能是拉取公司的基础框架代码或者某些模块)。

添加命令模块

现在,我们需要有一个模块(比如create模块),来完成创建指令。

所以,我们新建一个文件夹lib,并增加一个文件create.js,代码如下:

module.exports = function(projectName, options) {
    console.log(projectName, options);
}
复制代码

这个模块很简单,就是导出一个函数,这个函数接收两个参数,然后将接收的参数打印出来。

现在,我们继续改造main文件,在main文件增加以下代码:

// 获取create模块
const createModel = require('../lib/create')

program
    .command('create <project-name>')
    .description('create a new project')
    .option('-f, --force', 'overwrite target directory if it exists')
    .action((projectName, options) => {
        // 引入create模块,并传入参数
        createModel(projectName, options);
    })
复制代码

解释一下:

  • program.command是定义一个命令,命令的格式是ywill-cli create xxx
  • description就是这个命令的描述,不多介绍了
  • option是命令后面可以携带的参数以及参数的相关描述
  • action后面是一个回调,回调的第一个参数就是上面的xxx,第二个参数就是--后面的

OK,现在我们运行命令ywill-cli create test --force,发现果然打印了如下:

screenshot-20220727-211810.png

编写create模块

现在,我们就可以对create模块的逻辑进行编写了。

创建Creator类

由于create模块可能有很多功能,比如校验目录重复,获取版本信息,拉取远程代码或模块等等功能。所以我们先创建一个类,于是,create.js代码变为:

class Creator {
    constructor(projectName, options) {
        this.projectName = projectName;
        this.options = options;
    }

    // 创建
    async create() {
        ...
    }
}

module.exports = async function (projectName, options) {
    const creator = new Creator(projectName, options);
    await creator.create();
}
复制代码

处理目录重复

假设我们可以通过ywill-cli create xxx来创建一个基础项目了,但是我们所在的目录本身可能存在xxx目录,所以,我们需要做一个目录是否存在的校验,校验如下:

  • 如果使用了--force参数,那么直接删除原先的目录,然后直接创建

  • 如果没有使用--force参数,那么询问用户,是否覆盖,选择覆盖则执行上面逻辑,不覆盖则终止创建

    在第二步的时候,询问用户需要和用户进行交互,所以我们需要使用inquirer插件,还需要使用fs-extra模块来判断目录是否存在。

inquirer插件是一个和命令行交互的工具插件,vue-cli的交互就是使用这个插件完成的,大家可以自己去inquirer文档自行查看。

安装一下:

npm install inquirer@8 fs-extra
复制代码

现在,我们为create.js增加这个逻辑:

const path = require('path');
const fs = require('fs-extra');
const chalk= require('chalk');
const Inquirer = require('inquirer');

const cwd = process.cwd();

class Creator {
    // ...
    // 创建
    async create() {
        const isOverwrite = await this.handleDirectory();
        if(!isOverwrite) return;
        console.log('todo....');
    }
    // 处理是否有相同目录
    async handleDirectory() {
        const targetDirectory = path.join(cwd, this.projectName);
        // 如果目录中存在了需要创建的目录
        if (fs.existsSync(targetDirectory)) {
            if (this.options.force) {
                await fs.remove(targetDirectory);
            } else {
                let { isOverwrite } = await new Inquirer.prompt([
                    {
                        name: 'isOverwrite',
                        type: 'list',
                        message: '是否强制覆盖已存在的同名目录?',
                        choices: [
                            {
                                name: '覆盖',
                                value: true
                            },
                            {
                                name: '不覆盖',
                                value: false
                            }
                        ]
                    }
                ]);
                if (isOverwrite) {
                    await fs.remove(targetDirectory);
                } else {
                    console.log(chalk.red.bold('不覆盖文件夹,创建终止'));
                    return false;
                }
            }
        };
        return true;
    }
}
复制代码

逻辑非常简单,就不一一解释了,上面都已经注释了。

这个时候,我们再次运行ywill-cli create test(先在目录下创建一个test文件夹):

screenshot-20220727-213951.png

果然,我们如约的和命令行交互了。

增加调取模版API

接下来,我们需要从远程去获取需要拉取的模版列表,然后选择一个需要拉取的模版,然后拉取到本地。

这里准备好了需要拉取的模版列表的API: https://api.github.com/users/shenyWill/repos

里面的topics包含了template的就是可以拉取的模版。

那么,我们先封装一下API(也可以不封装,直接使用axios也行),下载axios:

npm install axios
复制代码

接下来,创建一个api目录(在lib里面或者不在都可以,我放在了lib里面,注意自己的引用路径即可),所有与api操作相关的都在里面,这个不一一讲解了,毕竟不是重点,直接贴代码:

// api/request.js
const axios = require('axios');

class HttpRequest {
    constructor(baseUrl, options = {}) {
        this.baseUrl = baseUrl;
        this.commonOptions = options;
    }
    getInsideConfig() {
        const configs = {
            baseUrl: this.baseUrl,
            ...this.commonOptions
        };
        return configs;
    }
    interceptors(instance, options) {
        // todo...
    }
    request(options) {
        const instance = axios.create({});
        options = Object.assign(this.getInsideConfig(), options);
        this.interceptors(instance, options);
        return instance(options);
    }
};

module.exports = HttpRequest;
复制代码
// api/api.request.js
const HttpRequest = require('./request');
module.exports = new HttpRequest('');
复制代码
// api/interface/index.js
const axios = require('../api.request');

const getRepoList = params => {
    return axios.request({
        url: 'https://api.github.com/users/shenyWill/repos',
        params,
        method: 'get'
    })
}

module.exports = {
    getRepoList
}
复制代码

上面无非就是很简单的封装了一下api,就不介绍了。

获取模版列表

那么,现在就需要去调用上面的getRepoList获取模版列表,然后让用户选择,需要拉取哪个模版。

由于我们拉取远程数据需要时间,所以,为了优化体验感,我们增加一个loading的效果,这需要用到ora库。

ora就是增加命令行loading效果的库,大家可以自行去ora文档查看。

安装一下:

npm install ora@5
复制代码

话不多说,我们为create.js增加这个逻辑:

const ora = require('ora');
const api = require('./api/interface/index');
// ...
    async create() {
        // ...
        await this.getCollectRepo();
    }
    // ...
    // 获取可拉取的仓库列表
    async getCollectRepo() {
        const loading = ora('正在获取模版信息...');
        loading.start();
        const {data: list} = await api.getRepoList({per_page: 100});
        loading.succeed();
        const collectTemplateNameList = list.filter(item => item.topics.includes('template')).map(item => item.name);
        let { choiceTemplateName } = await new Inquirer.prompt([
            {
                name: 'choiceTemplateName',
                type: 'list',
                message: '请选择模版',
                choices: collectTemplateNameList
            }
        ]);
        console.log('选择了模版:' + choiceTemplateName);
    }
复制代码

这个时候,运行一下试试,是不是可以拉取到模版了,如下图:

screenshot-20220728-183646.png

下载对应模版

接下来,就需要根据用户选择的模版来定向拉取对应的模版到本地了,拉取的模版地址已经准备好了shenyWill/xxx(xxx为对应的模版名称),那么我们需要使用download-git-repo插件来把git上面的项目拉取到本地,由于这个插件不支持promise,所以又需要使用node自带的util工具来支持。下载一下插件:

npm install download-git-repo
复制代码

create.js中加入如下代码:

const util = require('util');
const downloadGitRepo = require('download-git-repo');

// ...

class Creator {
    // ...
    // 获取可拉取的仓库列表
    async getCollectRepo() {
        // ...
        this.downloadTemplate(choiceTemplateName);
    }
    
    // 下载仓库
    async downloadTemplate(choiceTemplateName) {
        this.downloadGitRepo = util.promisify(downloadGitRepo);
        const templateUrl = `shenyWill/${choiceTemplateName}`;
        const loading = ora('正在拉取模版...');
        loading.start();
        await this.downloadGitRepo(templateUrl, path.join(cwd, this.projectName));
        loading.succeed();
    }
}
复制代码

再次运行,应该可以拉取代码到本地了:

screenshot-20220729-110000.png

同时,在本地目录应该可以查看到相关代码了。

模版提示

最后,我们增加一个优化的功能,在拉取成功后,告诉用户该怎么操作,并且增加艺术字体。

增加艺术字体需要使用figlet插件,大家可以自行查看figlet文档

下载一下:

npm install figlet
复制代码

然后,我们在create.js中增加如下代码:

const figlet = require('figlet');
class Creator {
    // ...
    // 下载仓库
    async downloadTemplate(choiceTemplateName) {
        // ...
        this.showTemplateHelp();
    }
    // 模版使用提示
    showTemplateHelp() {
        console.log(`\r\nSuccessfully created project ${chalk.cyan(this.projectName)}`);
        console.log(`\r\n  cd ${chalk.cyan(this.projectName)}\r\n`);
        console.log("  npm install");
        console.log("  npm run dev\r\n");
        console.log(`
            \r\n
            ${chalk.green.bold(
                figlet.textSync("SUCCESS", {
                    font: "isometric4",
                    horizontalLayout: "default",
                    verticalLayout: "default",
                    width: 80,
                    whitespaceBreak: true,
                  })
            )}
        `)
    }
}
复制代码

在尝试运行一次命令,会发现成功:

screenshot-20220729-112114.png

发布脚手架

一个最简单的脚手架,就这样创建完成了,接下来就是发布到npm(或者你公司内部的源,比如字节的bnpm)上了,发布就和发布插件没有区别。

  1. 去npm上注册一个自己的账号
  2. 本地登录npm,也就是npm login
  3. 在本地增加一个.npmignore文件,写上需要忽略的文件,比如.vscode
  4. 执行npm publish即可(每次更新的时候,修改你的package.json中的version,再次npm publish即可)。
  5. 接着,就可以在npm中找到我们的包了,比如ywill-cli

最后

到此为止,整个最基础的脚手架就搭建完成了,附上比较核心的maincreate.js代码:

// main
#! /usr/bin/env node

const program = require('commander');
const chalk = require('chalk');


// 获取当前版本号
const version = require('../package.json').version;

// 获取create模块
const createModel = require('../lib/create')

program
    // 配置脚手架名称
    .name('ywill-cli')
    // 配置命令格式
    .usage(`<command> [option]`)
    // 配置版本号
    .version(version);

// 给提示增加
program.on('--help', () => {
    console.log();
    console.log(
        `Run ${chalk.cyan(
            'ywill-cli <command> --help'
        )} for detailed usage of given command.
    `)
});

program
    .command('create <project-name>')
    .description('create a new project')
    .option('-f, --force', 'overwrite target directory if it exists')
    .action((projectName, options) => {
        // 引入create模块,并传入参数
        createModel(projectName, options);
    })

program.parse(process.argv);
复制代码
// create.js
const path = require('path');
const fs = require('fs-extra');
const chalk= require('chalk');
const Inquirer = require('inquirer');
const ora = require('ora');
const util = require('util');
const downloadGitRepo = require('download-git-repo');
const figlet = require('figlet');

const api = require('./api/interface/index');
const cwd = process.cwd();

class Creator {
    constructor(projectName, options) {
        this.projectName = projectName;
        this.options = options;
    }

    // 创建
    async create() {
        const isOverwrite = await this.handleDirectory();
        if(!isOverwrite) return;
        await this.getCollectRepo();
    }
    // 处理是否有相同目录
    async handleDirectory() {
        const targetDirectory = path.join(cwd, this.projectName);
        // 如果目录中存在了需要创建的目录
        if (fs.existsSync(targetDirectory)) {
            if (this.options.force) {
                await fs.remove(targetDirectory);
            } else {
                let { isOverwrite } = await new Inquirer.prompt([
                    {
                        name: 'isOverwrite',
                        type: 'list',
                        message: '是否强制覆盖已存在的同名目录?',
                        choices: [
                            {
                                name: '覆盖',
                                value: true
                            },
                            {
                                name: '不覆盖',
                                value: false
                            }
                        ]
                    }
                ]);
                if (isOverwrite) {
                    await fs.remove(targetDirectory);
                } else {
                    console.log(chalk.red.bold('不覆盖文件夹,创建终止'));
                    return false;
                }
            }
        };
        return true;
    }

    // 获取可拉取的仓库列表
    async getCollectRepo() {
        const loading = ora('正在获取模版信息...');
        loading.start();
        const {data: list} = await api.getRepoList({per_page: 100});
        loading.succeed();
        const collectTemplateNameList = list.filter(item => item.topics.includes('template')).map(item => item.name);
        let { choiceTemplateName } = await new Inquirer.prompt([
            {
                name: 'choiceTemplateName',
                type: 'list',
                message: '请选择模版',
                choices: collectTemplateNameList
            }
        ]);
        this.downloadTemplate(choiceTemplateName);
    }

    // 下载仓库
    async downloadTemplate(choiceTemplateName) {
        this.downloadGitRepo = util.promisify(downloadGitRepo);
        const templateUrl = `shenyWill/${choiceTemplateName}`;
        const loading = ora('正在拉取模版...');
        loading.start();
        await this.downloadGitRepo(templateUrl, path.join(cwd, this.projectName));
        loading.succeed();
        this.showTemplateHelp();
    }

    // 模版使用提示
    showTemplateHelp() {
        console.log(`\r\nSuccessfully created project ${chalk.cyan(this.projectName)}`);
        console.log(`\r\n  cd ${chalk.cyan(this.projectName)}\r\n`);
        console.log("  npm install");
        console.log("  npm run dev\r\n");
        console.log(`
            \r\n
            ${chalk.green.bold(
                figlet.textSync("SUCCESS", {
                    font: "isometric4",
                    horizontalLayout: "default",
                    verticalLayout: "default",
                    width: 80,
                    whitespaceBreak: true,
                  })
            )}
        `)
    }
}

module.exports = async function (projectName, options) {
    const creator = new Creator(projectName, options);
    await creator.create();
}
复制代码

当然,脚手架的内容远远不止于此,大家一起努力,加油加油!!!

猜你喜欢

转载自juejin.im/post/7125631921375150110