你是否在烦恼每次新项目来了都需要重复配置(eslint,router,ui库等)?
你是否在烦恼每次新项目来了都需要copy旧项目然后进行删减?
你是否想搭建一个属于自己的react-create-app或者create-vite?
搭建一个cli,可以分为2部分(coding模板 + cli工具)。下面从0到1带你搞一个属于自己的cli。隔壁小孩子馋哭了!
coding模板
我们这里先去github创建一个自己的coding模板库,注意一定要选择public可以访问,要不然用cli的时候代码就下载不下来了,可以每个分支对应我们不同的模板,然后主文件的readme文件添加一些模板说明之类的。
技术栈选择
现在技术栈常用的基本上都是react或者vue,我这里以vite为例,搭建一个react+vite的项目。
pnpm create vite
我这里选择的是react+ts,按需执行完成后创建如下空模板:
代码检测相关配置
如果用vite创建项目的话,会默认安装eslint。如果是自己搭建webpack或者其他模板的话,就那需要手动安装下eslint,记得vscode也安装eslint插件哈。大家也可以使用Oxlint,据说是比eslnt快而且不需要额外配置。
pnpm init @eslint/config
安装完成后项目中多出一个eslint.config.js文件,大家可以根据自己的实际情况,定制化自己的eslint-config,后续我也会单独推荐下我目前在用的eslint-config。
// 修改package.json添加脚本命令 lint ,然后测试eslint是否正常工作
"scripts": {
"start": "vite",
"instal": "pnpm install",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
eslint安装完成后,需要配置下lint-staged 和 husky 来实现代码提交前自动检测
lint-staged
这是一个对暂存区的内容进行检测工具,先安装下依赖
pnpm install -D lint-staged
依赖安装完成后,配置下lint-staged的执行
"scripts": {
"start": "vite",
"instal": "pnpm install",
"tsc": "tsc",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"lint-staged": "lint-staged",
"lint:staged": "eslint --ext .js,.jsx,.ts,.tsx "
},
"lint-staged": {
"**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue,astro,svelte}": "npm run lint:staged"
},
我们来测试下,随便改一下代码看下是否好使
husky
对暂存区的内容进行检测已经完成了,那么我们需要在代码提交commit的时候自动进行检测,那么就需要一个git hooks工具 - husky。老规矩,先安装下依赖
pnpm install -D husky
安装依赖后,配置下husky初始化
// package.json添加以下命令,每次安装依赖都会初始化husky
"scripts": {
"prepare": "husky",
},
然后重现pnpm install一下,发现根目录下多了一个.husky目录
在.husky目录下新增个pre-commit文件,然后就可以在每次提交代码自动执行这个文件里的脚本
// 这里添加我们lint-staged的命令
// 大家也可以在这里添加一些其他的,比如tsc检测
npm run lint-staged
添加完成后,提交代码就会自动走全部流程了
git commit -> husky的pre-commit -> (script) npm run lint-staged -> package.json中的 lint-staged -> (script) lint:staged -> 无错通过 or 有错停止
配置我们需要的其他功能
我这里以react-router为例,其他大家可以根据自己项目定制化
pnpm install react-router-dom
import { lazy } from "react";
import { HOME_PATH, LOGIN_PATH, TEST_PATH } from "./constant";
const Layout = lazy(() => import('@/layout'))
const Login = lazy(() => import('@/pages/login'))
const Home = lazy(() => import('@/pages/home'))
const Test = lazy(() => import('@/pages/test'))
const getRoutes = () => {
return [
{
path: "/",
element: <Layout />,
children: [
{
path: HOME_PATH,
element: <Home />,
},
{
path: TEST_PATH,
element: <Test />,
},
],
},
{ path: LOGIN_PATH, element: <Login /> },
]
}
export default getRoutes
import { useRoutes } from 'react-router-dom'
import getRoutes from './routes'
import { routerGuide } from './routes/constant';
function App() {
const element = useRoutes(getRoutes());
return routerGuide(element);
}
export default App
其实这部分可以做很多,比如把我们自己的http请求啊,公共方法,菜单都提前配置后,那么我们新开发的项目就只需要关注业务就可以了。
我的模板只配置了一些常用的,大家可以参考下,后续会补充其他技术栈的相关模板。github.com/waltiu/code…
贴下一下目录结果和相关依赖版本:
cli搭建
模板创建完成了, 终于可以搭cli了
准备工作
初始化项目,name就可以设置成你的脚手架名称,我的是npc-create-app
// 根目录创建index.js文件
npm init
// package.json中需要通过bin指定入口文件
"name": "npc-create-app",
"version": "0.0.1",
"description": "",
"main": "index.js",
"type": "module",
"bin": {
"npc-create-app": "./index.js"
},
随便写个log测试下,第一行的env node必须要加,指定node环境去执行,要不然就会直接打开文件
// index.js
#! /usr/bin/env node
console.log(1)
可以通过根目录执行npm link 将当前包安装到全局。
主流程
入口
创建一个init函数然后,然后主动去执行。我们想下用户会通过命令行输入哪些信息或者配置哪些内容来生成模板呢,我这里简单弄几个,大家可以根据需求自己补充。
- projectName - 项目名称
- version - 版本
- help - 帮助提示信息
- template - 模板名称
- packageManager - 包管理工具
我们可以通过minimist
来获取通过命令带过来的参数。我们先初始下result:
const argv = minimist(process.argv.slice(2), { string: ["_"] });
async function init() {
let result = {
projectName: formatTargetDir(argv._[0]),
template: argv.template || argv.t,
packageManager: argv.package || argv.p,
version: argv.version || argv.v,
help: argv.help || argv.h
};
}
init()
交互
用户通过命令行执行命令后,我们可以把一些参数带过来,但是如果用户没有带参数,那么我们就需要提供一个可以交互的功能,让用户来输入或者选择,我们这里可以使用prompts
来实现我们想要的功能。
先简单解释下每一项的每个字段都是用来做什么的
- type - 交互类型(text:文本,null:不展示,select:选择,也可以是个函数,入参为上一步的结果)
- name - 每一项的key,得到结果时按照key:value的格式
- message - 相当于表单项的label
- initial - 默认值
- choices - type为select时配置,可以是一个函数,入参为上一步的的结果,返回一个数组{title:"",value:""}
- onState - 值变化时执行
const promptResult = await prompts(
[
{
// 如果命令行有带名称就跳过该步
type: result.projectName ? null : "text",
name: "projectName",
message: reset("Project name:"),
initial: defaultTargetDir,
onState: (state) => {
// 获取最新的输入
result.projectName =
formatTargetDir(state.value) || defaultTargetDir;
},
},
{
// 根据输入的项目名成,判断项目文件夹,再进行清空,取消,忽略操作
type: () => {
return !fs.existsSync(result.projectName) ||
isEmpty(result.projectName)
? null
: "select";
},
name: "overwrite",
message: () =>
(result.projectName === "."
? "Current directory"
: `Target directory "${result.projectName}"`) +
` is not empty. Please choose how to proceed:`,
initial: 0,
choices: [
{
title: "Remove existing files and continue",
value: overwriteMap.remove,
},
{
title: "Cancel operation",
value: overwriteMap.cancel,
},
{
title: "Ignore files and continue",
value: overwriteMap.ignore,
},
],
},
{
// 取消后,给个取消提示
type: (_, { overwrite }) => {
if (overwrite === "no") {
throw new Error(red("✖") + " Operation cancelled");
}
return null;
},
name: "overwriteChecker",
},
{
// 判断项目名称是否可以当作packageName,不可以的话(比如中文),需要用户再重新输入
type: () => (isValidPackageName(result.projectName) ? null : "text"),
name: "packageName",
message: reset("Package name:"),
initial: () => toValidPackageName(result.projectName),
validate: (dir) =>
isValidPackageName(dir) || "Invalid package.json name",
},
{
// 项目技术栈选择做一个二级交互,第一层先选择主要技术栈,比如vue或者react
type: () => {
return result.template && TEMPLATES.includes(result.template)
? null
: "select";
},
name: "framework",
message:
typeof result.template === "string" &&
!TEMPLATES.includes(result.template)
? reset(
`"${result.template}" isn't a valid template. Please choose from below: `
)
: reset("Select a framework:"),
initial: 0,
choices: FRAMEWORKS.map((framework) => {
const frameworkColor = framework.color;
return {
title: frameworkColor(framework.display || framework.name),
value: framework,
};
}),
},
{
// 第二次再选择具体模板
type: (framework) =>
framework && framework.templates ? "select" : null,
name: "template",
message: reset("Select a template:"),
choices: (framework) =>
framework.templates.map((template) => {
const variantColor = template.color;
return {
title: variantColor(template.display || template.name),
value: template.name,
};
}),
},
{
// 包管理工具选择,比如pnpm,yarn,npm
type: (value) => {
return packageManagerList.find(
(item) => item.name === result.packageManager
) && result.packageManager
? null
: "select";
},
name: "packageManager",
message: reset("Select a package manager:"),
choices: () =>
packageManagerList.map((packageManager) => {
const variantColor = packageManager.color;
return {
title: variantColor(packageManager.display),
value: packageManager.name,
};
}),
},
],
{
onCancel: () => {
throw new Error(red("✖") + " Operation cancelled");
},
}
);
效果如下:
输出结果:
下载
根据result的template字段,我们可以知道用户想要下载哪个模板,比如上面的result.template为react-vite,那么我们就可以去github下载对应分支模板。我这里推荐使用git-clone
进行https下载,我看网上很多都是使用的download-git-repo
, 但是随着github安全升级,ssh模式进行下载似乎都行不通了。
安装2个依赖 git-clone 和 ora ,分别进行下载和loading交互
pnpm install git-clone ora
/**
* 克隆指定项目的代码
* @param projectName 项目名称,用于定位具体的项目
* @param branch 要克隆的分支名称
* @returns 返回一个Promise对象,成功时无返回值,失败时返回错误信息
*/
export const cloneCode = async (projectName, branch) => {
// 创建一个新的Promise,通过ora库提供克隆过程的加载动画
return new Promise((resolve, reject) => {
const process = ora(`下载...${codingUrl}`); // 启动加载动画
process.start();
// 调用gitClone函数克隆代码
gitClone(
codingUrl,
projectName,
{
checkout: branch, // 指定要检出的分支
},
(error) => {
if (error) {
console.log(error, "error"); // 如果有错误,打印错误信息
reject(error) // 通过Promise返回错误
} else {
process.succeed(); // 克隆成功,结束加载动画
resolve() // 通过Promise表明成功
}
}
);
});
};
文件处理
为了方便我们后续对处理对下载下来的文件进行加工处理,所以我们在下载的时候把下载目录设置成一个临时目录,然后对临时文件目录处理,处理完成后再把文件复制过去
export const getTemporaryPath = (path) => {
return `${path}_${Date.now().toString()}`;
};
node的文件处理相关接口,基本都是要用绝对路径,我们可以封装一个或者绝对的方法
export const getRoot = (targetDir) => {
const cwd = process.cwd();
return path.join(cwd, targetDir);
};
根据用户交互结果result中的packageName和packageManager,我们需要修改package.json中的name和script.instal。
export const write = (root, file, content) => {
const targetPath = path.join(root, file);
fs.writeFileSync(targetPath, content);
};
export const addPackageJson = (
templateDirPath,
targetDirPath,
{ packageName, packageManager }
) => {
const pkg = JSON.parse(
fs.readFileSync(path.join(templateDirPath, `package.json`), "utf-8")
);
pkg.name = packageName;
pkg.scripts.instal =
packageManagerList.find((item) => item.name === packageManager).script ||
packageManagerList[0].script;
write(targetDirPath, "package.json", JSON.stringify(pkg, null, 2) + "\n");
};
在对文件加工完成后,就要对文件进行复制,这个时候发现一个问题带有.
前缀的文件在复制的时候无法进行复制,所以在拷贝前对所有文件进行遍历加工,把.
转化成其他指定字符,拷贝后再给转回来。
/**
* 转移文件名
*
* 该函数用于根据指定目录中的文件名前缀来重命名文件。如果`isTemplate`为真,则将文件名中以点`.`开始的文件重命名为以`-_`开始;
* 如果`isTemplate`为假,则将文件名中以`-_`开始的文件重命名为以点`.`开始。
*
* @param {string} dirPath 目录路径,函数将对此目录下的文件进行操作
* @param {boolean} isTemplate 一个布尔值,指示是否是模板文件名的转换。`true`表示将文件名中的`.`转为`-_`,`false`则将`-_`转为`.`
*/
export const transferFiles = (dirPath, isTemplate) => {
// 读取目录中的所有文件名,排除`.git`目录
const files = fs.readdirSync(dirPath);
for (const file of files.filter((f) => f !== ".git")) {
// 根据isTemplate标志,决定文件名的转换方式
if (isTemplate) {
// 如果是模板转换,将文件名中的点`.`转为`-_`
fs.renameSync(
path.join(dirPath, file),
path.join(
dirPath,
file.startsWith(".") ? file.replace(".", "_-_") : file
)
);
} else {
// 如果不是模板转换,将文件名中的`-_`转为点`.`
fs.renameSync(
path.join(dirPath, file),
path.join(
dirPath,
file.startsWith("_-_") ? file.replace("_-_", ".") : file
)
);
}
}
};
等待文件复制加工完后,再把临时的文件删除掉,这里遇到了一个问题,目前也没有找到原因,有大佬指点下么?
question:所有代码就是同步的,clone完代码后,我去删除目录,告诉我文件繁忙,无法删除
我现在的办法是加个延迟处理的,延迟了3s就是ok的
setTimeout(() => {
deleteDir(tempRoot);
}, 3000);
export const deleteDir = (dir) => {
fs.rmSync(dir, { recursive: true, force: true });
};
优化
整体流程基本都通了,我们把整体的代码逻辑尽可能的拆分一下,还有一些-v ,-help指令,我们就不需要继续往下执行prompt部分了。然后安装一下友好提示的库,进行体验优化。
分享一些好用的node 命令行相关库
- clear - 清空命令行
- figlet - 艺术字
- ora - loading
- prompts - 交互式输入选择
- kolorist - log颜色
- child_process - 执行脚本
end
一个cli工具基本搭建的都差不多了,大家可以在各个阶段做一些自己的定制化,看下最终结果:
- npc-create-app -v
- npc-create-app -help
- npc-create-app my-project --t react-vite --package pnpm
- npc-create-app
下面附下coding代码,欢迎大家star:npc-create-app