动手打造一款脚手架工具

在公司使用微前端进行开发的时候,总要创建子应用。
之前都是cv部分代码结构,然后再手动改配置,这样不仅浪费时间,而且很有可能因为一个小地方没修改导致线下/线上不能运行。
那么如何解决这个痛点呢?——我想到了脚手架。

作为一名前端开发,大家肯定都使用过vue-cli或者其他的脚手架,回忆一下我们创建vue项目的流程:

// 1. 先全局安装脚手架
npm -g install vue-cli
// 2. 使用脚手架创建一个项目
vue create test-project
// 3. 进行个性化配置:比如是否安装vue-router、vuex 是否使用ts等等
Please pick a preset:
...
// 4. 一顿配置安装完成后进入项目 利用devServer把项目run起来
cd test-project
npm run serve
复制代码

假如我们从0开始自己搭建项目会是什么样呢?
新建文件夹、初始化npm、初始化git、手撸webpack、配置各种loader和plugin、研究tsconfig如何实践 等等…
毫无疑问,通过脚手架创建项目,解放了我们的双手和大脑,不需要考虑如何配置webpack和安装哪些依赖,键盘啪啪啪几下,一个可以运行的项目就出来了。
由此可知:

  1. 脚手架帮助我们快速高效地初始化了项目;
  2. 帮我们制定了一套较为标准的规范;
  3. 为开发中各种场景(测试、打包)提供了解决方案。

1、分析脚手架执行流程

废话不多说,我们直接开始。
先看看效果:

2021-05-16 20.28.51.gif
那么我们就要分析一下,脚手架的执行流程是什么呢:

image.png
可见,只要把这四个模块的功能实现,那我们的脚手架就做好了。下面我以ev-cli为例,一步步打造一款符合需求的脚手架。
我们最终的项目结构如下:

├── README.md
├── bin
│   └── evcli.js
├── package.json
├── src
│   ├── create.ts
│   ├── main.ts
│   └── util
│       ├── commands.ts
│       ├── constants.ts
│       ├── files.ts
│       ├── git.ts
│       └── prompt.ts
├── tsconfig.json
└── yarn.lock
复制代码

2、创建可执行环境

2.1 创建全局执行文件

在项目文件夹bin/evcli.js中写下:

第一句用来声明执行脚本的node环境
#!/usr/bin/env node
require('../lib/main.js');
复制代码

同时在package.json中加上:

  "bin": {
    "ev-cli": "./bin/evcli.js"
  },
复制代码

2.2 npm link

现在我们的指令还不是全局指令,需要执行npm link,将 ev-cli 这个 npm 包添加到全局。
假如我们在 main.ts 中写下了console.log('hello'),那么现在在 cmd 中执行 ev-cli ,就会打印hello

3、处理指令

我们常用的比如 -v、-h 或 –version 这些指令是如何被解析呢?
这里就用到了一个库——commander
可以通过链式指定指令、别名、描述、动作等等:

program.command(XXX)
    .alias(XXX)
    .description(XXX)
    .action(() => {
        XXX();
    });
复制代码

我们就可以使用一个数组,来列举所有的指令:
util/commands.ts

const mapActions = {
    create: {
        alias: 'c',
        description: '创建一个子应用', 
        examples: [
            'ev-cli create <submodule-name>'
        ]
    },
    '*': {
        alias: '',
        description: 'command not found',
        examples: []
    }
};

export default mapActions;
复制代码

package.json中引入版本信息:

const { version } = require('../../package.json');

export default version;
复制代码

main.ts中处理逻辑:

import program from 'commander';
import version from './util/constants';
import mapActions from './util/commands';
import create from './create';

// 遍历注册指令
Reflect.ownKeys(mapActions).forEach((action: string | number | symbol) => {
    program.command(action as string)
        .alias(mapActions[action].alias)
        .description(mapActions[action].description)
        .action(() => {
            if(action === '*') console.log(mapActions[action].description);
            else if(action === 'create') create(process.argv[3]);
        });
});

// program自带的help指令不会打印Examples
// 所以我们手动加上打印例子
program.on('--help', () => {
    console.log('\nExamples:');
    Reflect.ownKeys(mapActions).forEach((action) => {
        mapActions[action].examples.forEach((example: string) => {
            console.log(`${example}`);
        })
})})

// 通过--version查看当前版本
program.version(version).parse(process.argv);
复制代码

4、进行配置

4.1 引入inquirer

在脚手架中经常有对话的场景:

  • 输入一个命名
  • 选择是否使用XX
  • 选择一个合适的版本

inquirer就是一个用来做这件事情的库。
基本使用方法也是配置一个数组:

const inquirer = require('inquirer') 
inquirer.prompt([ 
    { 
        type: 'confirm', 
        name: 'isLatest', 
        message: '是否使用最新版本?', 
        default: true 
    }
]).then((answers) => { console.log(answers) })    // { isLatest: true }
复制代码

在create.ts中就可以实现create的逻辑:

import inquirer from 'inquirer';
import { promptList } from './util/prompt';

const create = async (projectName: string) => {
    console.log(`子应用文件夹名称是${projectName}`);
    const answers = await inquirer.prompt(promptList);
    console.log('结果为:');
    const { namespace, port, framework } = answers;
};

export default create;
复制代码

4.2 配置promptList

而引用的promptList我们单独拿出来,在util/prompts.ts维护:

interface PromptListItem {
    type: string;
    name: string;
    message?: string;
    validate?: (v: string) => Promise<string | boolean>;
    choices?: string[]; 
    default?: string;
};

export const promptList: PromptListItem[] = [
    {
        type: 'list',
        name: 'framework',
        message: '请选择一个该子应用的框架',
        choices: ['react', 'vue'],
        default: 'react'
    },
    {
        type: 'input',
        name: 'namespace',
        message: '请输入子应用路由标识:',
        validate: (input) => {
            return new Promise((done) => {
                setTimeout(() => {
                    if(!input) {
                        done('Invalid input.');
                        return; 
                    }
                    done(true);
                }, 0);
            })
        }
    },
    {
        type: 'input',
        name: 'port',
        message: '请输入端口号:',
        validate: (input) => {
            return new Promise((done) => {
                setTimeout(() => {
                    if(!input || typeof Number(input) !== 'number') {
                        done('Invalid input.');
                        return; 
                    }
                    done(true);
                }, 0);
            })
        }
    }
];
复制代码

5、拉取代码

5.1 封装git clone

这里就比较好理解了,我们拿到一些基本信息后就可以执行git clone拉取对应的代码,值得注意的一点是:比如vue或react不同的模板我选择放在远程仓库的不同分支,这样刚才交互选择的框架类型framework就可以作为分支名传入cloneGitRepo方法。

import { execSync } from 'child_process';
import fs from 'fs';

const cloneGitRepo = (repo: string, name: string, branch: string) => {
    const path = `./packages/${name}`;
    if(fs.existsSync(path)) {
        console.log('已存在同名模块');
        return false;
    }
    try {
        execSync(
            `git clone ${repo} ${path} --branch ${branch}`
        );
        return true;
    } catch (error) {
        console.log(`clone ${repo} 失败\n错误原因:${error}`);
    }
};

export default cloneGitRepo;
复制代码

5.2 使用ora显示图标

比如当我们拉取代码的时候通常需要等待一会儿,会有一个loading的状态,这里最好是可以给用户看到loading的标识,以及拉取代码成功后的成功标识,交互会更加友好。
通过ora这个包就可以非常简单的实现,在util/prompts.ts中增加下面代码:

export const fnLoadingByOra = async (fn: () => boolean|void|undefined, message: string) => {
    // 逻辑非常好理解
    // 即创建一个对象后 根据回调的结果来看是成功还是失败,显示对应的图标和文案
    const spinner = ora(message);
    spinner.start();
    const res = await fn();
    if(res !== false) spinner.succeed(message + 'Success');
    else spinner.fail(message + 'Fail');
    return res;
};
复制代码

有了封装的这个fnLoadingByOra函数后,我们还可以再封装一层哈。
create.ts下加上:

const fetchLoading = async (name: string, branch: string) => {
    const res = await fnLoadingByOra(() => { 
        const res = cloneGitRepo('XXXX:XXX/ev-submodule-template.git', name, branch); 
        return res;
    }, '拉取模板 ');
    return res;
};
复制代码

有了这个方法后,我们继续补充create内部的逻辑:

const create = async (projectName: string) => {
    console.log(`子应用文件夹名称是${projectName}`);
    const answers = await inquirer.prompt(promptList);
    console.log('结果为:');
    const { namespace, port, framework } = answers;
    const res = await fetchLoading(projectName, framework);
    if(!res) return;
    new FileService(projectName, namespace, port);
    fnLoadingByOra(() => {}, '模板配置 ');
};
复制代码

诶,这时你发现:FileService这是啥啊?
是的,这就是我们的最后一步——模板替换。

6、模板替换

对于模板,学过vue的同学一定很熟悉:

<div>{{name}}<div>
复制代码

从早期的SSR服务端渲染开始,模板替换已经屡见不鲜了,我们要做的就是把之前配置过的信息,渲染到对应的位置。
在本项目中,我使用的模板是<%XXX%>,比如对于模板webpack中的publicPath就可以写成:

publicPath: '/<%namespace%>'
复制代码

经过替换后就会得到:

publicPath: '/test'
复制代码

用正则匹配也很简单:

const reg = /\<%(.+?)%\>/g;
复制代码

替换的流程也非常好理解:读取文件 -> 正则匹配 -> 替换 -> 将替换后的副本写入原文件。
具体的实现我则是把文件处理封装成一个类:

import fs from 'fs';

const reg = /\<%(.+?)%\>/g;
const DIR = ['build', 'src', 'templates'];

class FileService {
    constructor(projectName: string, namespace: string, port: string) {
        const path =  `./packages/${projectName}`;
        this.writeFile(`${path}/package.json`, projectName);
        DIR.forEach((dir) => {
            this.readDocuments(`${path}/${dir}`, projectName, namespace, port);
        });
    }

    // 字符串替换的方法
    replaceVar(text: string, projectName='', namespace='', port='') {
        const newText = text.replaceAll(reg, (match, value) => {
            if(value === 'projectName') return projectName;
            else if(value === 'namespace') return namespace;
            else if (value === 'port') return port;
            return '';
        });
        return newText;
    }

    // 写入文件
    writeFile(path: string, ...args: string[]) {
        const res = fs.readFileSync(path, 'utf8');
        const newText = this.replaceVar(res, ...args);
        fs.writeFileSync(`${path}`, newText);
    }

    // 读取文件夹下的文件名
    readDocuments(path: string, ...args: string[]) {
        const res = fs.readdirSync(path, 'utf8');
        res.forEach((file) => {
            this.writeFile(`${path}/${file}`, ...args);
        });
    };
}

export default FileService;
复制代码

好啦,到这里我们就已经实现这个脚手架咯,下一步tsc编译ts后,在命令行中执行就可以看到效果啦。
当然如果你想把脚手架投入生产,可以npm publish,发布成一个包,后面就可以下载使用。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享