从0到1实现一个属于自己的脚手架工具
Hello, 各位勇敢的小伙伴, 大家好, 我是你们的嘴强王者小五, 身体健康, 脑子没病.
本人有丰富的脱发技巧, 能让你一跃成为资深大咖.
一看就会一写就废是本人的主旨, 菜到抠脚是本人的特点, 卑微中透着一丝丝刚强, 傻人有傻福是对我最大的安慰.
欢迎来到 小五 的 随笔系列 之 从0到1实现一个属于自己的脚手架工具.
前言
在日常工作中,我们通常会使用 create-react-app、create-vue 等脚手架工具来搭建自己的项目,它们都通过简单的初始化命令,完成内容的快速构建
那大家有没有看过它们的实现原理呢,如果答案是否的话,不妨跟随笔者步伐,一同实现一个属于自己的脚手架工具
本项目主要以 create-vue 源码为蓝本,create-react-app 及其它文章为辅助进行实现,最终展示成果如下图:
大家可通过 npm install deeruby-cli -g
安装体验,通过 deeruby-cli create xxx
创建项目,项目代码:【Github】ajun568
基础拾遗
这里仅列出文章所需的知识点供大家学习
「path」 🦅
-
__dirname
:返回该文件所在文件夹的位置 -
process.cwd()
:运行 node 命令时所在文件夹的位置 -
path.join()
:连接路径 ~ 将全部 path 片段拼接,生成路径 -
path.resolve()
:解析路径 ~ 将多个路径解析为一个绝对路径 -
path.basename
:返回 path 的最后一部分
tips: 当我们使用EMS标准时,无法直接使用 __dirname,需通过以下方式处理
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
「JSON.stringify」 🦅
JSON.stringify(value[, replacer[, space]])
如果 space 是一个数字,则会对应缩进该 space 个空格,用于格式化文件
「文件/目录是否存在」 🦅
fs.exists()
fs.existsSync()
「创建目录」 🦅
fs.mkdir()
fs.mkdirSync()
「删除目录」 🦅
fs.rmdir()
fs.rmdirSync()
只有当目录为空时才可删除,若不为空需遍历文件,逐一删除文件后再删除目录
tips: 可引入 fs-extra,使用 fs.remove 删除
「创建并写入文件」 🦅
fs.writeFile()
fs.writeFileSync()
「文件/目录信息」 🦅
fs.stat()
、fs.statSync()
其参数为一个文件或目录,返回一个对象,包含该文件或目录的具体信息
-
stats.isDirectory()
:判断是否为目录 -
stats.isFile()
:判断是否为文件
let stats = fs.statSync(path)
if (stats.isDirectory()) { ... }
if (stats.isFile()) { ... }
「读取目录」 🦅
fs.readdir()
fs.readdirSync()
用于读取目录,返回一个包含文件和目录的数组
「复制文件」 🦅
fs.copy()
fs.copySync()
fs.copyFile()
fs.copyFileSync()
两方法都是传入 input 和 output,不同之处在于,若 output 不存在,copyFile 会报错,而 copy 会创建路径并复制
项目初始化
mkdir deeruby-cli
cd deeruby-cli
npm init
👉 配置 package.json
Nodejs 模块化标准为 CommonJS,我们想使用 ESM 标准,追加如下配置:
"type": "module"
🤔 既然是脚手架工具,我们就需要它能在全局环境下执行,那该如何做呢?
bin: 用来将可执行文件加载到全局环境中;即指定了 bin 字段的 npm 包一旦在全局安装,就会被加载到全局环境中
"bin": {
"deeruby-cli": "bin/cli.js" // deeruby-cli 为执行命令;bin/cli.js 为入口文件
},
👉 Shebang
#! /usr/bin/env node
/* ------- bin/cli.js下的逻辑代码 ------- */
其中 #! /usr/bin/env node
为 Shebang,用于指明这个脚本的解释程序
-
使用 node 执行脚本
-
/usr/bin/env
用于解决不同用户 node 路径不同的问题,让系统动态的去查找 node 来执行脚本文件
👉 发布
🤔 编写脚手架工具时,不可能每次都发布到线上测试,如何在本地测试自己的脚手架工具呢?
npm link
:可以模拟包安装后的状态,它会在系统中创建一份软连接,以达到全局调用的效果
发布npm包: npm login
⇒\Rightarrow⇒ npm publish
读取命令行参数
构建脚手架工具,最基本的就是要读取到项目名称以及其它参数
process.argv
:用于读取命令行参数
如上图所示,从数组第三个元素开始才是有效参数,我们进行如下处理即可:
process.argv.slice(2) // 截取命令行参数
实际工作中,如果用 node 做脚本开发,经常需要跟命令行参数打交道,我们往往会引入第三方库辅助开发,如:commander、argparse,他们不仅会帮我们做逻辑处理,还会生成帮助文档供 --help 查看
本项目中我们选用 commander,【Commander 文档链接】
命令行交互工具
使用脚手架时,通常也会内置一些问题,通过用户选择的答案来生成对应的模板;这里我们同样也引入第三方库来辅助开发,如:inquirer、prompts,本项目中我们选用 prompts,文档链接:Prompts 文档
命令行样式美化
chalk,可以为命令行设置颜色和基础样式,避免视觉效果的单一导致的重点不突出
常用如下:
小试牛刀:
命令行loading
ora,可以为命令行设置loading
功能实现
👉 入口文件,使用 commander 处理输入,并生成文档
#! /usr/bin/env node
import { program } from 'commander';
import create from '../utils/create.js';
program
.command('create <project>')
.description('创建项目')
.option('-d, --default', '跳过提示并使用默认配置', false)
.action((project, args) => create(project, args))
program.parse(process.argv);
👉 判断目录是否存在,若存在则询问用户是否覆盖
👉 生成目录并初始化 package.json
if (fs.existsSync(createDir)) {
const result = await prompts([
{
type: 'toggle',
name: 'isRewrite',
message: '该目录已存在,是否覆盖该目录?',
initial: false,
active: '是',
inactive: '否',
}
]);
if (!result.isRewrite) {
console.log(`${chalk.red('✖')} 操作被取消`);
return;
}
await fs.remove(createDir);
}
fs.mkdirSync(createDir);
fs.writeFile(path.resolve(cwd, createDir, 'package.json'), JSON.stringify({ name: projectName }, null, 2));
👉 根据命令行参数判断是否跳过问题,若没跳过,进入问答环节,同样使用 prompts 即可
👉 处理文件,这里选用了 create-vue 的方式,本地读取,也可以将代码存入线上库中进行拉取
👉 若为文件夹,创建并递归,若为文件则复制
👉 通过引入不同 template 和合并 package.json 来达到用户自定义效果
const render = async (src, dest) => {
// 获取文件或目录的具体信息
const stats = fs.statSync(src);
// 文件夹处理 -- 递归调用
if (stats.isDirectory()) {
fs.mkdirSync(dest, { recursive: true });
for (const file of fs.readdirSync(src)) {
render(path.resolve(src, file), path.resolve(dest, file));
}
return;
}
// 获取文件名称
const fileName = path.basename(src);
// package.json 合并
if (fileName === 'package.json' && fs.existsSync(dest)) {
const existing = JSON.parse(fs.readFileSync(dest, 'utf8'));
const newPackage = JSON.parse(fs.readFileSync(src, 'utf8'));
const pkg = deepMerge(existing, newPackage);
fs.writeFileSync(dest, JSON.stringify(pkg, null, 2)); // 首行缩进为2
return;
}
// 复制文件
fs.copyFileSync(src, dest);
}
👉 调用 render,根据用户配置生成对应内容
// 基础配置合并
const src = path.resolve(__dirname, 'template');
const baseSrc = path.resolve(src, 'base');
const dest = path.resolve(cwd, createDir);
render(baseSrc, dest);
// 基础代码合并
const codeSrcName = (userSelected.needsTypeScript ? 'typescript-' : '') + (userSelected.needsRouter ? 'router' : 'default');
const codeSrc = path.resolve(src, 'code', codeSrcName);
render(codeSrc, dest);
// 用户配置合并
if (userSelected.needsTypeScript) {
const tsConfigSrc = path.resolve(src, 'config/typescript');
render(tsConfigSrc, dest);
}
if (userSelected.needsRouter) {
const routerConfigSrc = path.resolve(src, 'config/router');
render(routerConfigSrc, dest);
}
参考链接
【不太帅的程序员】从 0 构建自己的脚手架/CLI知识体系(万字)
转载自:https://juejin.cn/post/7209657931125178426