一个前端cli脚手架工具是如何诞生的

501 阅读4分钟

前言

笔者最近在倒腾项目上一些工程化的业务,之前基于create-react-app搭好了两个工程架子,一个面向的是pc web,一个是已经做了移动端适配面向mobile h5,所以存在两个项目挂在gitlab上面,如果新来了个业务需求,需要做pc或者mobile的业务,这个时候会打开终端,复制相应的gitlab地址 git clone xxxx一下,一个已经打造好的工程就拉取下来了,可以美滋滋的开发了。

这样的做法还是比较原始,面对一些场景难免clone好了项目还得修修改改,比如拉了个mobile的工程,有的业务需要引入wx sdk.js,有的又不需要,以此类推,如果把这类的引入需求在clone之前就做好了,那么我们才算真正可以美滋滋的开发。

那么如何能在clone之前就做好呢,这便是cli工具诞生的由来。先来看看笔者的cli工具是怎么工作的👇

QQ20210414-225227.gif

实现

看着好像很高大上的样子,其实一个cli无非就是做到以下几件事。

image.png 咱们一步一步来。

获取命令参数+新建

// 根据输入,获取项目名称
let projectName = program.args[0];

// 返回 Node.js 进程的当前工作目录
let rootName = path.basename(process.cwd());

fs.mkdirSync(projectName); // 根据输入创造一个文件夹

配置模版

先新建一个文件保存模版的数据

// template.json
{
  "cra": {
    "name": "create react app模版1",
    "value": "tmp1",
    "git": "gitlab:xxxxxxxx",
    "options": []
  },
  "mini": {
    "name": "Taro小程序",
    "value": "mini",
    "git": "gitlab:xxxxxxxx",
    "options": []
  }
}

选择模版类型

这里用的是inquirer这个库实现的常见的交互式命令行用户接口

image.png

/**
 * 模板选择
 */
function selectTemplate() {
    return new Promise((resolve, reject) => {
        let choices = Object.values(templateConfig).map(item => {
            return {
                name: item.name,
                value: item.value
            };
        });
        let config = {
            // type: 'checkbox',
            type: "list",
            message: "请选择创建项目类型",
            name: "select",
            choices: [new inquirer.Separator("模板类型"), ...choices]
        };
        inquirer.prompt(config).then(data => {
            let { select } = data;
            let { value, git } = templateConfig[select];
            resolve({
                git,
                // templateValue: value
            });
        });
    });
}

// 选择模板 拿到了git地址
let { git } = await selectTemplate(); 

下载模版

这里的核心功能用的是download-git-repo这个库

function download (target, url) {
  // 这里先把模版下载到download-temp
  // 以备后续ejs合成使用
  target = path.join('./download-temp'); 

  return new Promise((resolve,reject) => {
    download(`direct:${url}`,
    target, { clone: true }, (err) => {
    
      if (err) {
        console.log(chalk.red("模板下载失败:("));
        reject(err)
      } else {
        console.log(chalk.green("模板下载完毕:)"));
        resolve(target)
      }
    })
  })
}

templateName = await download(rootName, git);

获取本地配置

function getCustomizePrompt(target, fileName) {
    return new Promise((resolve) => {
        const filePath = path.join(process.cwd(), target, fileName)

        if (fs.existsSync(filePath)) {
            console.log('读取模板配置文件')
            let file = require(filePath)
            resolve(file)
        } else {
            console.log('该文件没有配置文件')
            resolve([])
        }
    })
}

// 获取模版中自定义的配置项目
// cli提供基础的配置项目:项目名称/作者/描述
// 而业务自己需要的配置项则保存在模版项目中
let customizePrompt = await getCustomizePrompt(templateName, 'customize_prompt.js')

配置项合成

function render(projectRoot, templateName, customizePrompt) {

    return new Promise(async (resolve, reject) => {
        try {
            let context = {
                name: projectRoot, // 项目文件名
                root: projectRoot, // 项目文件路径
                downloadTemp: templateName // 模板位置
            };

            // 获取默认配置
            const promptArr = configDefault.getDefaultPrompt(context);

            // 添加模板自定义配置
            promptArr.push(...customizePrompt);
            let answer = await inquirer.prompt(promptArr); // 获取自定义配置

            let generatorParam = {
                metadata: {
                    ...answer
                },
                src: context.downloadTemp,
                dest: context.root
            };

            // 获取完配置后传入合成方法
            await generator(generatorParam);
            resolve();
        } catch (err) {
            reject(err);
        }
    });
}

ejs合成

这里的核心功能使用的metalsmith这里,负责遍历文件模块,并且把文件模块复制到我们的目标目录下,同时通过use我们可以操作转移的文件内容

const rm = require("rimraf").sync;
const Metalsmith = require("metalsmith");
const ejs = require("ejs");
const path = require("path");
const fs = require("fs");

function generator(config) {
    
    // ejs配置
    let { metadata, src, dest } = config;

    if (!src) {
        return Promise.reject(new Error(`无效的source:${src}`));
    }
    
    // 官方模板
    return new Promise((resolve, reject) => {
        // 声明metalsmith实例
        const metalsmith = Metalsmith(process.cwd())
            .metadata(metadata)
            .clean(false)
            .source(src)
            .destination(dest);
            
        // 提供需要忽略的文件模块名单
        // 可以根据自定义的配置忽略不想被复制的文件
        // 比如不需要用于微信环境的web,则可以忽略到模版中的wx.d.ts声明文件
        const ignoreFile = path.resolve(process.cwd(), src, '.fileignore');

        if (fs.existsSync(ignoreFile)) {

            // 定义一个用于移除模板中被忽略文件的metalsmith插件
            metalsmith.use((files, metalsmith, done) => {
                const meta = metalsmith.metadata();

                // 先对ignore文件进行渲染,然后按行切割ignore文件的内容,拿到被忽略清单
                const ignores = ejs
                    .render(fs.readFileSync(ignoreFile).toString(), meta)
                    .split("\n")
                    .filter(item => !!item.length);

                Object.keys(files).forEach(fileName => {
                    
                    // 移除被忽略的文件
                    ignores.forEach(ignorePattern => {

                        if (fileName.includes(ignorePattern)) {
                            delete files[fileName];
                        }
                    });
                });
                done();
            });
        }

        metalsmith
            .use((files, metalsmith, done) => {
                const meta = metalsmith.metadata();

                // 编译模板
                Object.keys(files).forEach(fileName => {
                    try {
                        const t = files[fileName].contents.toString();

                        if (/(<%.*%>)/g.test(t)) {
                            // 对文件的内容进行ejs合成
                            files[fileName].contents = new Buffer.from(ejs.render(t, meta));
                        }

                    } catch (err) {
                        console.log("fileName------------", fileName);
                        console.log("er -------------", err);
                    }
                });
                done();
            })
            .build(err => {
                rm(src); // 都完成了之后可以把临时存放模版的download-temp删掉
                err ? reject(err) : resolve();
            });
    });
};

await render(projectRoot, templateName, customizePrompt);

依赖安装

/**
 * 模板渲染后执行
 */
function afterBuild(name) {
    inquirer.prompt({
        type: "confirm",
        name: "install",
        message: "是否需要安装依赖",
    }).then(data => {
        
        if (!data.install) {
            return
        }

        const ls = spawn('yarn', [], {
            cwd: path.resolve(process.cwd(), path.join(".", name))
        });
    
        ls.stdout.on('data', (data) => {
            console.log(`${data}`);
        });
    
        ls.stderr.on('data', (data) => {
            console.error(`${data}`);
        });
    
        ls.on('close', (code) => {
            console.log(`安装完毕`);
        });
    });
}

 // 构建结束
 afterBuild(projectRoot);

配置命令

把以上所有的代码都放在index.js文件中,通过

node index.js

应该是跑的起来的(我们先假设可以跑起来吧^_^。这种做法不够方便 也不好维护。于是我们可以把整个项目当作一个npm包,可以随时随地更新项目发布版本。

npm init

package json中配置

"bin": {
    "lemon": "bin/lemon",
    "lemon-init": "bin/lemon-init"
},

这里的核心功能借助commander实现命令,此时我们把以上的index.js内容放到lemon-init中,至于lemon内容则借助commander帮我们触发init命令执行index脚本

// lemon
#!/usr/bin/env node
const program = require('commander')

console.log('version', require('../package').version)

program
  .version(require('../package').version)
	.usage('<command> [项目名称]')
	.command('init', '创建新项目') // lemon init wxApp
	.parse(process.argv)

此时工程目录如下

.
├── bin
│   ├── lemon
│   └── lemon-init
├── .gitignore
├── template.json
├── package.json
├── README.md

等我们需要使用的时候可以直接命令行初始化

lemon init wxApp

总结

cli工具其实做的内容相对还比较简单些,至于后续的工作就是根据不同的配置去编写不同的ejs模版代码。笔者一开始也摸不着头脑,但是翻看了allen-cli的源码,了解了大体的流程,进行了bug修复和功能删减改进。一个适合自己项目的脚手架工具就呼之欲出了。