前言
我们在开发的过程中常常会使用到一些脚手架来帮我们快速构建项目模版,常用的脚手架命令有如下这些:
create-react-app
vue-cli
create-vite
我们同样也可以根据自己常用的、习惯的技术栈去自定义一个属于自己的脚手架,让自己用的更舒服。所以我根据自己的开发习惯,实现了一个基于vite
和react
的脚手架。本文主要实现的功能有:
- 交互式命令行创建
- 动态模板生成
- 发布到
npm
仓库
GitHub
地址:github.com/jayyliang/v…
整体流程
大概看一下整个项目的结构
bin
我们要实现的命令行命令src
具体的代码实现template
模版文件
下面简单介绍一下生成过程的整体流程,主要包括以下几点
- 获取命令行参数
- 动态生成文件
- 代码美化
//bin/create.js #!/usr/bin/env node const { PROMPT } = require("../src/constants"); const { copyFolderSync,format } = require("../src/exec"); const { getPrompt } = require("../src/interactive") const path = require('path'); const run = async () => { const prompts = await getPrompt() //获取目录 const projectName = prompts[PROMPT.NAME] const projectPath = path.join(process.cwd(), projectName) //生成模版 copyFolderSync(path.join(__dirname, "../template"), projectPath, prompts) // 代码美化 await format(projectPath) console.log(`🚀 项目地址:${projectPath}`) } run()
交互式命令行
这里主要用到的是inquirer
这个库,它十分强大,可以很容易的帮我们创建一个交互式的命令行。比如我们希望创建的时候输入项目名称,选择Javascript
/Typescript
。就可以如下实现:
const inquirer = require("inquirer"); const prompts = [ { type: "input", name: PROMPT.NAME, message: "项目名称", }, { type: "list", name: PROMPT.LANG, message: "JS/TS", choices: [ENUMS[PROMPT.LANG].JavaScript, ENUMS[PROMPT.LANG].TypeScript], }, ]; const commandRes = await inquirer.prompt(prompts);
动态模版生成
在前面的命令行交互过程中,我们已经拿到了用户的各种输入。数据结构如下:
{ NAME: 'project-name', LANG: 'TypeScript', axios: 'y', mobx: 'y', LIB: [ 'antd', 'lodash', 'dayjs' ] }
这个时候我们需要一个项目模版,大致的文件目录结构如下
整个脚手架的创建流程如下
- 入口文件为/bin/create.js
- 解析命令行输入
- 根据输入递归解析模版文件夹,生成模版
- 代码美化
动态模板生成
接下来就要根据命令行的输入去替换模版的内容,这里可以做一个约定,在模版中的js
文件必须实现一个getContent
和getExt
方法,因为要根据不同的输入去生成不同的内容。而其他文件可以按需直接拷贝到目标目录中。
使用copyFolderSync
方法去动态生成模版,它主要做了以下几件事情
- 创建目标文件夹,在哪个目录下调用这个命令,目标文件夹就是这个目录(
target
) - 递归遍历模版文件夹(
source
)
- 根据命令行传入的参数(
params
)过滤掉一些不需要拷贝的文件 - 如果是
js
文件,则调用getContent
和getExt
动态获取到内容跟文件拓展名
const copyFolderSync = (source, target, params) => { // 创建目标文件夹 if (!fs.existsSync(target)) { fs.mkdirSync(target); } // 读取源文件夹 const files = fs.readdirSync(source); // 遍历文件并逐一拷贝 files.forEach(file => { const sourcePath = path.join(source, file); const targetPath = path.join(target, file); if (sourcePath.includes("api") && !params[DEPS.AXIOS.key]) { return; } if (sourcePath.includes("store") && !params[DEPS.MOBX.key]) { return; } if ( (sourcePath.includes("tsconfig") || sourcePath.includes("vite-env")) && params[PROMPT.LANG] !== ENUMS[PROMPT.LANG].TypeScript ) { return; } // 如果是目录,则递归拷贝 if (fs.statSync(sourcePath).isDirectory()) { copyFolderSync(sourcePath, targetPath, params); } else { // 如果是文件,则直接拷贝 const ext = path.extname(sourcePath); if (ext.substring(1) === "js") { const file = require(sourcePath); const { getContent, getExt } = file; const content = getContent(params); const ext = getExt(params); const fileInfo = path.parse(sourcePath); const name = `${fileInfo.name}.${ext}`; fs.writeFileSync(path.join(target, name), content, { encoding: "utf8", }); } else { fs.copyFileSync(sourcePath, targetPath); } } }); };
以下是一个js文件的例子,旨在介绍params跟内容是如何交互的。
const { PROMPT, ENUMS } = require("../../src/constants"); const getContent = params => { return ` import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.${ params[PROMPT.LANG] === ENUMS[PROMPT.LANG].JavaScript ? "jsx" : "tsx" }"; import "./global.less"; ReactDOM.createRoot(document.getElementById("root")${ params[PROMPT.LANG] === ENUMS[PROMPT.LANG].JavaScript ? "" : "!" }).render(<App />); `; }; const getExt = params => { return params[PROMPT.LANG] === ENUMS[PROMPT.LANG].JavaScript ? "jsx" : "tsx"; }; module.exports = { getContent, getExt, };
这里的实现方式基本上是根据参数拼接模版字符串,返回给调用方,动态生成文件。
代码美化
由于模版文件的内容是字符串拼接的,所以生成目标文件后不太好看。这里在生成完之后调用了prettier
对目标文件夹进行了一次代码美化。
- 递归处理文件夹和文件
- 根据不同的文件名后缀选择不同的解释器
- 把美化后的内容重新写到文件中
const getParser = filePath => { const ext = path.extname(filePath); switch (ext) { case ".js": return "babel"; case ".ts": return "typescript"; case ".jsx": return "babel"; case ".tsx": return "typescript"; case ".html": return "html"; case ".json": return "json"; default: return null; // 如果无法确定解析器,则返回 null } }; const format = async folderPath => { const files = fs.readdirSync(folderPath); for (const file of files) { const filePath = path.join(folderPath, file); const isDirectory = fs.statSync(filePath).isDirectory(); if (isDirectory) { await format(filePath); } else { const fileContent = fs.readFileSync(filePath, "utf-8"); const parser = getParser(filePath); if (parser) { const formattedContent = await prettier.format(fileContent, { parser, }); fs.writeFileSync(filePath, formattedContent, "utf-8"); } else { } } } };
发布到npm仓库
这个时候我们已经实现了这个脚手架工具,下面我们可以把它发布到npm仓库中,以便使用起来更加方便。如果你没有npm
账号,可以去https://npmjs.com/
注册一个账号。
然后命令行输入
npm login
npm publish
这样就可以发布到npm
仓库中。这里需要关注的是你的package.json
文件。
{ //包名称 "name": "@jayliang/vite-react-cli", //包的版本号 "version": "1.0.0", "description": "基于vite跟react的脚手架工具", // 这里表示你的包是否是公开的,以及发布的地址是什么 "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, //我们发布的是命令行命令,所以这里需要定义一个bin对象 "bin": { "create": "bin/create.js" }, "keywords": [ "cli", "vite", "react" ], "author": "jayliang", "license": "MIT", "dependencies": { "inquirer": "^8.2.2", "prettier": "^3.1.1" } }
成功发布到npm
仓库之后,可以使用npm i -g @jayliang/vite-react-cli
去安装这个包,然后执行命令npx @jayliang/vite-react-cli
,就可以愉快的创建项目了~
最后
本文纯属抛砖引玉,提供一个自定义脚手架的思路。如果你也有这样的需求,可以参考本文的思路去实现。欢迎评论区交流~