https://juejin.cn/post/7098966073390661645
一.前言 在日常开发中,我们经常会用到各种脚手架工具,如常用的vue和react脚手架工具:vue-cli、Create React App。只需要执行内置命令和选择内置条件就可以生成对应的项目模板。极大的提高了我们开发开发效率,所以我们能不能根据自己的日常业务构建属于自己的一套脚手架工具呢,答案是可以的。接下来步入本文章的主题 二.技术栈 ● commander:^9.2.0 :完整的 node.js命令行解决方案 ● execa: ^6.1.0:执行shel命令 ● inquirer: ^8.2.4:交互式命令行用户界面,在命令工具中可提供交互 ● ejs: ^3.1.8:嵌入式JavaScript模板 ● chalk: ^5.0.1:设置终端字符串样式 ● mkdirp:^1.0.4:递归mkdir 三.特别说明 在开始之前,需要了解nodejs基础知识,常用shell执行命令,和javascript基础知识!!! 本文将从零到一完整讲解脚手架开发过程,以Vite + React18.0为模板案例,不会完整开发类似vue-cli 的所有功能!你可按照此文优化完善自己的脚手架代码。 四.构建项目 现在正式开始构建脚手架项目,可根据自己熟悉方式创建,直接创建项目文件夹
创建项目文件夹 名称为 my-cli 可自定义
mkdir my-cli
初始化 npm 根据自己的需求填写对应信息,也可以直接默认
npm init 五.安装依赖 yarn add commander execa inquirer chalk mkdirp
or npm 安装
npm install commander execa inquirer chalk mkdirp 六.目录说明 完整目录,如下所示,可根据目录设计添加对应文件。 my-cli # 项目名称 └── bin # 主目录 ├── commands # 命令文件 │ ├── options # 命令所有选项 │ │ ├── create.js # 创建命令 │ │ ├── help.js # 帮助命令 │ │ ├── help.js # 导出所有命令文件 │ ├── index.js # 导出command命令方法 ├── inquirers # 交互式命令文件 │ ├── options # 交互式命令所有选项 │ │ ├── common.js # 公共交互式命令 │ │ ├── react.js # react相关命令 │ │ ├── index.js # 导出所有交互式命令文件 │ ├── index.js # 导出command命令方法 ├── templates # 所有模板文件 │ ├── react # react相关模板 │ ├── vue # vue相关模板 ├── utils # 工具类 │ ├── index.js # 工具类处理函数 ├── index.js # 入口文件 完整代码:my-cli 七.实战 在开始前,我们需要修改package.json文件,增加”bin”: “./bin/index.js”:发布到npm设置执行入口文件; “type”: “module”:表示允许执行export、import操作;“start”: “node ./bin/index.js”:做本地测试使用,表示执行脚手架入口文件 {
- “bin”: “./bin/index.js”,
- “type”: “module”,
“scripts”: {
- “test”: “echo “Error: no test specified” && exit 1”,
- “start”: “node ./bin/index.js” } } (一).入口文件 首先从入口文件开始分析,需要的模块逐步完善。目录路径:bin/index.js #! /usr/bin/env node
import fs, { mkdirSync } from “fs”;
import path from “path”;
import commands from “./commands/index.js”;
let config = await command(); 需要注意的是#! /usr/bin/env node 表示执行环境为node,引入fs文件系统模块,创建文件项目会涉及到文件相关读取写能力;引入path读取文件路径。commands执行命令;通过let config = await command();获取用户输入、选择项;这一步涉及到commands所以接下来开始分析:bin/commands/index.js用户输入命令模块 (二).命令文件 目录路径:bin/commands/index.js import { program } from “commander”; import chalk from “chalk”;
import { create, help } from “./options/index.js”;
export default async (call) ⇒ { return new Promise((resolve, reject) ⇒ { program .name(chalk.blue(“my-cli”)) .usage(“[global options] command”);
program.version("1.0.0");
program.option("-F, --framework", "surport vue,react");
create(program, (item) => {
resolve(item);
});
help(program);
program.parse(process.argv);
}); }; 我们需要关注的是create、help分别是创建命令、帮助命令 (三).创建命令 create创建命令 ,文件路径:bin/commands/options/create.js
- @description 创建项目指令
- @author hu-snail 1217437592@qq.com */
import inquirer from “inquirer”;
import { changeTemplate, changeVariant, inputProjectName } from ”../../inquirers/options/index.js”;
import {InvalidArgumentError} from “commander”;
import chalk from “chalk”;
import { hasTemplate, getSupportTs } from ’../../utils/index.js’
export default (program, call) ⇒ {
program
.command(“create”)
.argument("
if (!projectName) {
item = await inquirer.prompt([inputProjectName(), changeTemplate(), changeVariant()]);
item.supportTs = item.supportTs === 'true' ? true : false
return call && call({ method, ...item });
}
if (!option.template) {
item = await getFramework(option);
item.supportTs = item.supportTs === 'true' ? true : false
} else {
item = option;
item.supportTs = getSupportTs(item.template)
}
call && call({ method, projectName, ...item });
});
};
-
@description 校验构建方式
-
@param {String} appName 项目名称
-
@returns appName */ function validatBuildMethod(val) { if (val === “vite”) return val; else throw new InvalidArgumentError( chalk.red(
"<build-method>构建方式,只支持值为:${chalk.green( "vite" )}!请重新输入
) ); } -
@description 校验项目名称
-
@param {String} appName 项目名称
-
@returns appName */ function validatAppName(appName) { var reg = /^[a-zA-Z][-_a-zA-Z0-9]/; if (!reg.test(appName)) { throw new InvalidArgumentError( chalk.red(
<app-name>项目名称必须以字母开头且长度大于2,请重新输入!
) ); } return appName; } -
@description 校验模板
-
@param {String} template 模板名称
-
@returns template */ function validatTemplate(template) { if (hasTemplate(template)) return template else { console.log(chalk.white(
error: option '-t, --template <value>' argument '${template}' is invalid
)) } }
async function getFramework() { let answer = await inquirer.prompt([changeTemplate(), changeVariant()]); return answer; } 思路分析:
上图所示,主要分两种情况,当用户完整输入和部分输入参数判。
关于program详细分析
.command(“create”):表示创建一个名为create的命令;
.argument("
export const inputProjectName = () ⇒ {
return {
type: “input”,
name: “projectName”,
default: “vite-app-project”,
validate: function (appName) {
var done = this.async();
var reg = /^[a-zA-Z][-_a-zA-Z0-9]/;
if (!reg.test(appName)) {
done(chalk.red(<app-name>项目名称必须以字母开头且长度大于2,请重新输入!
));
}
done(null, true);
},
message:
“Project name”,
};
};
参数解释,完整参数文档inpuirer文档
● type:表示参数类型,提供input、number、confirm、list、rawlist、expand、checkbox、password、editor
● name:表示参数属性
● choices:选择项
● message: 操作提示语
● de’fa
其中changeVariant:选择是否支持ts;inputProjectName:输入项目名称
(五).选择项目框架交互式命令
文件路径:bin/inquirers/options/template.js
- @description 创建项目类型选择
- @author hu-snail 1217437592@qq.com */
export const changeTemplate = () ⇒ { return { type: “list”, name: “template”, choices: [ { name: “react”, }, { name: “vue”, }, ], message: “Select a framework”, }; }; 创建命令,到此结束,接下来分析help命令 文件路径:bin/commands/options/help.js
- @description 帮助信息
- @author hu-snail 1217437592@qq.com */ import chalk from “chalk”;
export default (program) ⇒ {
program.addHelpText(
“after”,
Run ${chalk.green( "my-cli <command> --help" )} for detailed usage of given command.
);
};
addHelpText:表示添加帮助文字,“after”:表示在之后,可选项“before”,效果图如下
(六).导出command命令 文件目录:bin/commands/option/index.js
- @description 导出所有命令
- @author hu-snail 1217437592@qq.com */ import create from “./create.js”; import help from “./help.js”; export { create, help }; (七).导出inquirer交互式命令 文件目录:bin/inquirers/option/index.js
- @description 导出用户输入选择项
- @author hu-snail 1217437592@qq.com */ import { changeTemplate } from “./template.js”; import { changeVariant, inputProjectName } from ‘./common.js’ export { changeTemplate, changeVariant, inputProjectName }; (八).工具类代码 export function hasTemplate(template) { return [‘vue’, ‘vue-ts’, ‘react’, ‘react-ts’].includes(template) }
export function getSupportTs(template) { return [‘vue-ts’, ‘react-ts’].includes(template) } 这两个方法在react命令中使用到,补充!!! 八.本地测试 准备完以上代码后,我们开始本地测试,测试目的是检测是否能够正确的返回我们预设的结果,可根据自己的需求定义和完善脚手架。本文只是讲解demo流程!!! (一).完整输入 完成输入测试包含正确输入、构建方式有误输入、项目名称不合规输入 js版本完成输入、默认为js版本
react 版本
yarn start create vite app-test —template react
or npm
npm run start create vite app-test—template react 效果图:
ts版本正确输入
react-ts 版本
yarn start create vite app-test —template react-ts
or npm
npm run start create vite app-test —template react-ts 效果图:
以上结果也就是我们入口文件:bin/index.js中let config = await command();获取的值 构建方法输入有误vite1:为错误输入❌ 只支持vite方式构建,可根据自己的需求适配多种构建方式 yarn start create vite1 app-test
or npm
npm run start create vite1 app-test 效果图:
项目名称不合规输入 1my-test-project:为不符合规范名称 yarn start create vite 1my-test-project
or npm
npm run start create vite 1my-test-project 效果图:
(二).不完整输入 包含构建方式不输入、名称不输入、项目框架方式不输入 构建方式不输入会直接导致报错,重点是名称不输入、项目框架方式不输入,这时会给用户提供选择 项目名称不输入 yarn start create vite
or npm
npm run start create vite 效果图
项目名称不输入会提供默认值和输入给用户,同时完成项目框架选择、和是否支持ts选择。完整截图如下:
这个结果也就是我们入口文件:bin/index.js中let config = await command();获取的值 项目框架方式不输入 yarn start create vite app-test
or npm
npm run start create vite app-test 效果图:
不输入项目框架会提供选择,同时选择是否支持ts,完整截图如下:
到此结束测试,你可以根据这个思路完善自己的脚手架,接下来开始模板生成 九.导入Vite + react18模板 当我们能获取到用户输入选择的值后,就可根据用户输入的参数生成对应的项目模板,本文以vite+reat18为例 在bin/templates目录下创建React模板文件,完整目录如下: bin ├── templates │ ├── react │ │ ├── src │ │ │ ├── App.css │ │ │ ├── App.jsx.ejs │ │ │ ├── index.css │ │ │ ├── main.js.ejs │ │ │ ├── logo.svg │ │ ├── .gitignore │ │ ├── index.html.ejs │ │ ├── package.json.ejs │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ ├── vite.config.js.ejs 具体代码,查看完整代码:github.com/hu-snail/my… 如果实现vite + vue3可参考此方法生成对应模板 模板技巧:不需要动态改变文件,直接使用原后缀名,例如:静态资源(图、视频、音等资源)、需要动态改变的文件,保留原来的后缀名,在其基础上添加.ejs:例如(package.json → package.json.ejs)这样的好处是,知道原来的文件类型,方便更快的处理 十.创建生成模板代码 完成模板之后,开始重点环节,根据用户输入选择参数动态生成项目模板,跟着我的步骤完成生成模板语法 第一步:创建build文件夹 在bin/创建build文件,用于存放生成模板文件和生成配置文件,目录如下: bin ├── build # 生成模板文件 ├── config.js # 模板配置文件 ├── react.js # 生成react模板文件 第二步: config配置 因为脚手架内置了支持ts和js,所有存在差异化,具体配置如下:
- @description js需要忽略的文件 */ export const jsignoreFile = [ ‘tsconfig.json’, ‘tsconfig.node.json’ ] 其中模板中的tsconfig.json、 tsconfig.node.jsonts才存在,所以在js需要忽略。可按照此方式区分文件之间的差异化。 第三步:react生成模板 准备完以上内容后,接下正式开始生成模板,文件路径:bin/build/react.js
- @description 生成react模板
- @author hu-snail 1217437592@qq.com */
import fs from ‘fs’
import mkdirp from “mkdirp”;
import { getFiles, copyFile, getCode } from ‘../utils/index.js’
import { jsignoreFile } from ‘./config.js’
代码分析:
代码中导入了getFiles、copyFile、getCode三个方法,分别代表获取文件、拷贝文件、获取代码。首先从这三个方法开始分析,文件路径:bin/utils/index.js 。
1).getFiles 获取文件
import fs from “fs”;
let files = []
let dirs = []
export function getFiles(template, dir) {
const templatePath = ./bin/templates/${template}/
const rootFiles = fs.readdirSync(templatePath, ‘utf-8’)
rootFiles.map(item ⇒ {
const stat = fs.lstatSync(templatePath + item)
const isDir = stat.isDirectory()
if (isDir) {
const itemDir = ${template}/${item}/
.replace(/react//g, ”)
dirs.push(itemDir)
getFiles(${template}/${item}
, itemDir)
} else files.push((dir ? dir : ”) + item)
})
return {files, dirs}
}
代码分析:
该函数提供两个参数,分别是template、dir,表示模板、目录,同时在方法前定义了两个变量files、dirs分别表示文件、目录,主要作用区分文件和目录,这样方便用于生成目录和创建文件。
● templatePath:表示模板地址,根据传入template动态配置
● rootFiles:表示所有模板文件,通过fs.readdirSync读取
● stat:表示文件信息,通过fs.lstatSync获取
● isDir: 表示是否目录,通过stat.isDirectory()判断,如果是则递归该目录下的所有文件
● itemDir:表示层级目录,会把上一层接口也接入
2).copyFile拷贝文件
import fs from “fs”;
import { fileURLToPath } from “url”;
const __dirname = fileURLToPath(import.meta.url);
-
@description 复制文件,比如图片/图标静态资源
-
@param {*} rootPath 根目录
-
@param {*} template 模板
-
@param {*} item 静态模板文件 */ export function copyFile(rootPath, template, item) { const fromFileName = path.resolve( __dirname,
../../templates/${template}/${item}
); const toFileName =${rootPath}/${item}
; const rs = fs.createReadStream(fromFileName, { autoClose: true, encoding: “utf-8”, highWaterMark: 64 * 1024 * 1024, flags: “r”, }); const ws = fs.createWriteStream(toFileName, { encoding: “utf-8”, flags: “a”, highWaterMark: 16 * 1024 * 1024, autoClose: true, }); rs.on(“data”, (chunk) ⇒ { const wsFlag = ws.write(chunk, “utf-8”); if (!wsFlag) { rs.pause(); } }); ws.on(“drain”, () ⇒ {rs.resume(); });
rs.on(“end”, () ⇒ { ws.end(); }); } 代码分析: 该函数提供三个参数rootPath 、template、item,分别表示根目录,模板名称、静态模板路径。 ● fromFileName:表示读取文件地址 ● toFileName:表示拷贝至地址 ● rs:表示读取文件流,通过fs.createWriteStream创建 ● ws:表示写入文件流,通过fs.createWriteStream创建 3).getCode获取代码 import ejs from “ejs”; import fs from “fs”;
- @description 解析ejs模板
- @param {Object} config
- @param {String} templateName
- @param {String} templatePath
- @returns code
*/
export function getCode(config, templateName, templatePath) {
const template = fs.readFileSync(
path.resolve(__dirname,
../../templates/${templateName}/${templatePath}
) ); const code = ejs.render(template.toString(), { …config, }); return code; } 代码分析: 该函数接受三个参数config、templateName、 templatePath:分别表示配置信息、模板名称、模板地址 ● template:模板文件,通过fs.readFileSync读取 ● code: 模板代码,通过ejs.render提供生成 ● …config: 用户输入选择的参数值,通过此参数动态生成对应模板 第四步 分析createReact函数 分析完工具函数代码之后,我们正式分析主函数createReact - @description 生成react模板
- @author hu-snail 1217437592@qq.com */
import fs from ‘fs’ import mkdirp from “mkdirp”; import { getFiles, copyFile, getCode } from ‘../utils/index.js’ import { jsignoreFile } from ‘./config.js’ export const createReact = (config, rootPath) ⇒ {
mkdirp.sync(rootPath) const { template, supportTs } = config const reactTemplate = (template = 'react' || template = ‘react-ts’) ? ‘react’ : ” const { files, dirs } = getFiles(reactTemplate)
dirs.map(item ⇒ {
mkdirp.sync(${rootPath}/${item}
)
})
files.map(item ⇒ { const isEjs = item.indexOf(‘.ejs’) !== -1
if (isEjs) {
const fileTyep = supportTs ? 'ts' : 'js'
const newItem = item.replace(/.ejs/g, '')
const isJson = newItem.indexOf('.json') !== -1
const newFilePath = isJson ? newItem : newItem.replace(/js/g, fileTyep)
fs.writeFileSync(
`${rootPath}/${newFilePath}`,
getCode(config, reactTemplate, item)
)
} else {
if (supportTs) copyFile(rootPath, reactTemplate, item)
else {
const hasTsFile = jsignoreFile.includes(item)
if (!hasTsFile) copyFile(rootPath, reactTemplate, item)
}
}
}) return ” } 代码分析: 该函数接受两个参数config、 rootPath:分别表示用户输入选择参数值配置、项目根目录。 ● mkdirp.sync(rootPath):通过mkdirp创建项目目录 ● reactTemplate:通过template传入的值react、react-ts 统一react传入,因为模板目录只有react,可根据自己需求调整 其他项有代码注释,不一一赘述 第五步 模板动态配置 作为测试,我们对package.json.ejs和index.html.ejs进行动态配置,分别对应的目录bin/templates/react/package.json.ejs、bin/templates/react/index.html.ejs package.json.ejs:代码 { “name”: ”<%= projectName %>”, “private”: true, “version”: “0.0.0”, “scripts”: { “dev”: “vite”, “build”: “vite build”, “preview”: “vite preview” }, “dependencies”: { “react”: “^18.0.0”, “react-dom”: “^18.0.0” }, “devDependencies”: { “@types/react”: “^18.0.0”, “@types/react-dom”: “^18.0.0”, “@vitejs/plugin-react”: “^1.3.0”, <%_ if (supportTs) { -%> “typescript”: “^4.6.3”, <%_ } -%> “vite”: “^2.9.9” } } 根据supportTs:判断是否需要”typescript”,需要注意的<%_ -%>:语法会删除多余空行,如果使用<% %>:会导致留白,可以自行测试,“name”: ”<%= projectName %>“:根据创建的项目名称生成 index.html.ejs:代码
- @description 脚手架入口文件
- @author hu-snail 1217437592@qq.com */ import fs, { mkdirSync } from “fs”; import path from “path”;
import command from “./commands/index.js”; import { createReact } from ‘./build/react.js’
let config = await command();
var currentPath = path.resolve(“./”);
createReact(config, getRootPath())
function getRootPath() {
return ${currentPath}/${config.projectName}
;
}
生成模板演示
现在可以看看具体效果
(一).js版本
yarn start create vite app-test -t react
效果图
生成目录
package.json文件
index.hml文件
(二).ts版本 yarn start create vite app-test -t react-ts 效果图
生成目录
package.json文件
index.hml文件相同 转发申明 原创不易,转发请标明原文地址!!!
来自: 使用NodeJs构建属于自己的前端脚手工具一.前言 在日常开发中,我们经常会用到各种脚手架工具,如常用的vue和reac - 掘金