在公司使用微前端进行开发的时候,总要创建子应用。
之前都是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、分析脚手架执行流程
废话不多说,我们直接开始。
先看看效果:
那么我们就要分析一下,脚手架的执行流程是什么呢:
可见,只要把这四个模块的功能实现,那我们的脚手架就做好了。下面我以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
,发布成一个包,后面就可以下载使用。