一、引言
随着前端工程化的理念不断深入,脚手架的出现就是为减少重复性工作而引入的命令行工具,
众所周知, 新建项目是很繁琐的一项工作, 要考虑项目目录结构,基础库的配置, 各种规范等等. 在此过程中如何摆脱ctrl + c, ctrl + v,而通过脚手架从零到一搭建项目的方式变得更加有必要.
另外,对于很多系统,他们的页面相似度非常高,所以就可以基于一套模板来搭建,虽然是不同的人开发,但用脚手架来搭建,相同的项目结构与代码书写规范,是很利于项目的后期维护的;
以上就是为什么脚手架存在的意义, 让项目从"搭建-开发-部署"更加快速以及规范.
目前前端脚手架市场中, 大家最熟悉的就是create-react-app和vue-cli,它们可以帮助我们初始化配置生成项目结构、自动安装依赖,最后我们通过一行指令就可以运行项目开始开发,或者进行项目构建(build)。
这些脚手架提供的都是普遍意义上的最佳实践,但是实际开发中发现,随着业务的不断发展,必然会出现需要针对业务开发的实际情况来进行调整。例如:
● 通过调整插件与配置实现Webpack打包性能优化
● 项目架构调整
● 编码风格
● 用户权限控制
● 融合公司的基建
● 快速创建带有公共/基础业务的模块
● ......
总而言之,随着业务发展,我们往往会沉淀出一套更“个性化”的业务方案。
这时候我们最直接的做法就是开发出一个该方案的脚手架来,以便今后能复用这些最佳实践与方案。
二、思路
● 解耦:脚手架与模板分离
● 脚手架负责构建流程,通过命令行与用户交互,获取项目信息
● 模板负责统一项目结构、工作流程、依赖项管理
● 脚手架需要检测模板的版本是否有更新,支持模板的删除与新建
● 参考vue-cli
三、架构设计
前端工程化一个复杂的概念, 上图中很多功能都可以集成到脚手架中, 根据实际需要选取.
四、基本流程
五、用到的三方库
序号 | 库名 | 描述 |
---|---|---|
1 | commander | 处理控制台命令 |
2 | chalk | 命令行输出美化 |
3 | latest-version | 获取最新的npm包 |
4 | inquirer | 控制台询问 |
5 | download-git-repo | git远程仓库拉取(github) |
6 | figlet | 粉笔字 |
7 | glob | 匹配指定路径文件 |
8 | ora | 命令行环境的loading效果 |
9 | clear | 清除控制台输出的信息 |
10 | og-symbols | 各种日记级别的彩色符号 |
11 | metalsmith | 处理模板内容 |
12 | axios | ajax请求处理 |
13 | ejs | 模板引擎 |
14 | ncp | 递归文件拷贝 |
15 | consolidate | 模板类 |
16 | rollup | JavaScript 模块打包器 |
17 | download | 下载文件库 |
18 | extract-zip | 压缩包 |
19 | validate-npm-package-name | 校验项目名称 |
六、功能概要
七、基本规范
version 方法输出版本信息
command 注册命令,参数是命令名称和回传给 action 方法的参数
description 输出该命令的描述
action 订阅了该命令触发时的回调函数
parse 对传入的参数进行解析并执行相应的回调
八、开发与调试
开发
先设想下要实现的功能: 根据模板初始化项目(类似vue create )
- 用到的核心库commander用于解析用户命令
获取用户操作命令, 例: gfe-cli create appDemo, 即获取create命令
import { Command } from 'commander';
import { actionMap } from './contsant/actions';
import { pkg } from './contsant/pkg';
const program = new Command(pkg.name);
Reflect.ownKeys(actionMap).forEach((actKey: string) => {
const action = actionMap[actKey];
program
.command(actKey) // 配置命令的名字
.alias(action.alias) // 配置命令的别名
.description(action.description) // 命令对应的描述
.action(() => {
// 当输入 gfe create appDemo时, 输出create
console.log(actKey);
})
});
当用户输入的命令错误时,需要给与用户提示 比如用户拼错create为craete时,需要告知用户命令不可用
// command not found
if (actKey == "*") {
const errorCmd: string = process.argv.pop() as string;
console.log(chalk.red(errorCmd) + ' ' + chalk.yellow(action.description))
} else {
// TODO:这里实现create功能
}
- 执行用户命令
通常一个脚手架会对应很多种操作,拿vue为例: create add build ....很多常用命令
这样可以规划不同的文件实现对应的功能
创建create.ts用来实现新建项目的功能
export const create = async (argv: string[]) => {
// TODO:业务实现
}
在index.ts中动态调用
import * as ActionFn from './actions';
if (actKey == "*") {
const errorCmd: string = process.argv.pop() as string;
console.log(chalk.red(errorCmd) + ' ' + chalk.yellow(action.description))
} else {
// 当actKey为create时,调用上面create函数
ActionFn[actKey](process.argv);
}
cretae.ts的实现-拉取模板项目
先说下实现该功能的整体思路:
1.获取用户输入命令 gfe create appName, appName即用户创建项目的项目名
2.拉取远程模板列表,供用户选择
3.选择好模板后,选择该模板对应的Tag
4.根据模板与Tag拉取对应模板项目
5.简单模板可以直接复制到项目根目录下, 复杂模板需要进行解析渲染相关变量
在实际场景中我们通常会把模板源码放到公司私有npm上,这里我们以github为例, 实现方法都一样, 调用对应api即可
github api文档: docs.github.com/en/rest
- 提供 --help 帮助提示 首先我们定义好提示的内容
export const actionMap = {
create: {
alias: 'c',
description: 'create a project',
examples: [
'gfe-cli create <project-name>'
]
},
add: {
alias: 'a',
description: 'add a component',
examples: [
'gfe-cli add component <component-name>'
]
},
config: {
alias: 'conf',
description: 'config project',
examples: [
'gfe-cli config set <k> <v>',
'gfe-cli config get <k>'
]
},
list: {
alias: 'ls',
description: 'list all projects',
examples: [
'gfe-cli list',
'gfe-cli ls'
]
},
'*': {
alias: '',
description: 'command not found',
expmples: []
}
};
定义提示命令 通常为: --help
// listen help command
program.on('--help', () => {
Reflect.ownKeys(actionMap).forEach((actKey: string) => {
const action = actionMap[actKey];
(action.examples || []).forEach(example => {
console.log(chalk.cyan(` ${example}`));
});
});
});
初始化项目配置
- 解析模板
这里我们metalsmith库进行渲染, metalsmith可以将<%=name=>解析为对应的name值
在创建项目时我们通常需要将package.json中的name解析为用户自定义的项目名称
在模板中我们可以这样绑定变量
{
"name": "<%=projectName%>"
...
...
...
}
解析完成后对应的package.json如下
{
"name": "app-name"
...
...
...
}
本地测试
package.json 中增加 bin 字段,它是一个可执行命令和本地文件名的映射
在项目根目录下执行 npm link,这样会在全局的 node_modules 下生成一个符号链接,此时就可以在全局使用 package.json 中 bin 字段的命令名了
项目中测试
通过nrm注册私有npm源, 发布新版本脚手架
在涉及项目中投入使用当前cli各项功能
九、源码
/**
* 新建项目
*/
import inquirer from 'inquirer';
import Metalsmith from 'metalsmith';
import ncp from 'ncp';
import path from 'path';
import util from 'util';
import { askFileName, downloadDir, loadingFn } from '../contsant';
import { downloadFile, fetchaTags, fetchRepos } from '../service';
const promisify = util.promisify;
const ncpPromise = promisify(ncp);
const { ejs } = require('consolidate');
const render = promisify(ejs.render);
const fs = require('fs-extra');
import chalk from 'chalk';
import extract from 'extract-zip';
const validateProjectName = require('validate-npm-package-name');
export const create = async (argv: string[]) => {
// 用户创建的项目名
const projectName: string = argv.slice(3)[0];
const result = validateProjectName(projectName)
if (!result.validForNewPackages) {
console.error(chalk.red(`Invalid project name: "${projectName}"`))
result.errors && result.errors.forEach((err: any) => {
console.error(chalk.red.dim('Error: ' + err))
})
result.warnings && result.warnings.forEach((warn: any) => {
console.error(chalk.red.dim('Warning: ' + warn))
});
return false;
}
// 选择模板
const repos = await loadingFn(fetchRepos, 'fetching...')(true);
// console.log(repos);
const names = repos.map((t: any) => {
return { name: t.name }
});
// 获取选择结果
const { repo } = await inquirer.prompt({
name: 'repo',
type: 'list',
message: chalk.cyan('please choose a template to create project'),
choices: names
});
const targetRepo = repos.find((r: any) => r.name == repo);
const projectId: string = targetRepo.id;
// 选择tag
const tags = await loadingFn(fetchaTags, 'fetching tags...')(projectId);
const tagNames = tags.map((t: any) => t.name)
const { tag } = await inquirer.prompt({
name: 'tag',
type: 'list',
message: chalk.cyan('please choose a tag'),
choices: tagNames
});
// 下载模板文件
const repositoryName: string = targetRepo.name;// 用户选择选择模板名称
const source = `${downloadDir}/${repositoryName}.zip`;
if (fs.existsSync(source)) fs.removeSync(source);
const data = await downloadFile(projectId, tag);
await fs.promises.writeFile(source, data);
// 解压当前版本库- 解压到同级同名文件夹内
const dest = `${downloadDir}/${repositoryName}`;
if (fs.existsSync(dest)) fs.removeSync(dest);
if (!fs.existsSync(dest)) fs.mkdirSync(dest);
await extract(source, { dir: dest });
// 是否需要渲染
const isNeedRender: boolean = fs.existsSync(path.join(dest, askFileName));
if (!isNeedRender) {
// 简单模板 直接复制模板文件到当前目录
await ncpPromise(dest, path.resolve(projectName));
} else {
// 渲染模板
await new Promise((resolve, reject) => {
Metalsmith(__dirname)
.source(dest)
.destination(path.resolve(projectName))
.use(async (files, metal, done) => {
const args = require(path.join(dest, askFileName));
const result = await inquirer.prompt(args);
const meta = metal.metadata();
Object.assign(meta, result);
delete files[askFileName];
done();
})
.use((files, metal, done) => {
const meta = metal.metadata();
const mergeMeta = Object.assign(meta, {
projectName: projectName
});
Reflect.ownKeys(files).forEach(async (fileKey: any) => {
// 需要处理内容的文件类型: ts,js,json
if (fileKey.includes('.js') || fileKey.includes('.ts') || fileKey.includes('.tsx') || fileKey.includes('.json') || fileKey.includes('.yml')) {
// 处理文件内容
let content = files[fileKey].contents.toString();
if (content.includes('<%')) {
content = await render(content, mergeMeta);
// 更新文件内容
files[fileKey].contents = Buffer.from(content);
}
}
});
done();
})
.build(err => {
err ? reject() : resolve();
})
});
}
}
十、部署
**gfe-cli本地直接发布到内网npm上 **
参考 www.pudn.com/news/62a606…