likes
comments
collection
share

从0到1实现一个属于自己的脚手架工具

作者站长头像
站长
· 阅读数 10

Hello, 各位勇敢的小伙伴, 大家好, 我是你们的嘴强王者小五, 身体健康, 脑子没病.

本人有丰富的脱发技巧, 能让你一跃成为资深大咖.

一看就会一写就废是本人的主旨, 菜到抠脚是本人的特点, 卑微中透着一丝丝刚强, 傻人有傻福是对我最大的安慰.

欢迎来到 小五随笔系列从0到1实现一个属于自己的脚手架工具.

前言

在日常工作中,我们通常会使用 create-react-appcreate-vue 等脚手架工具来搭建自己的项目,它们都通过简单的初始化命令,完成内容的快速构建

那大家有没有看过它们的实现原理呢,如果答案是否的话,不妨跟随笔者步伐,一同实现一个属于自己的脚手架工具

本项目主要以 create-vue 源码为蓝本,create-react-app 及其它文章为辅助进行实现,最终展示成果如下图:

大家可通过 npm install deeruby-cli -g 安装体验,通过 deeruby-cli create xxx 创建项目,项目代码:【Github】ajun568

从0到1实现一个属于自己的脚手架工具

基础拾遗

这里仅列出文章所需的知识点供大家学习

「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 nodeShebang,用于指明这个脚本的解释程序

  • 使用 node 执行脚本

  • /usr/bin/env 用于解决不同用户 node 路径不同的问题,让系统动态的去查找 node 来执行脚本文件

👉 发布

🤔 编写脚手架工具时,不可能每次都发布到线上测试,如何在本地测试自己的脚手架工具呢?

npm link:可以模拟包安装后的状态,它会在系统中创建一份软连接,以达到全局调用的效果

发布npm包: npm login ⇒\Rightarrow npm publish

读取命令行参数

构建脚手架工具,最基本的就是要读取到项目名称以及其它参数

process.argv:用于读取命令行参数

从0到1实现一个属于自己的脚手架工具

如上图所示,从数组第三个元素开始才是有效参数,我们进行如下处理即可:

process.argv.slice(2) // 截取命令行参数

实际工作中,如果用 node 做脚本开发,经常需要跟命令行参数打交道,我们往往会引入第三方库辅助开发,如:commanderargparse,他们不仅会帮我们做逻辑处理,还会生成帮助文档供 --help 查看

本项目中我们选用 commander【Commander 文档链接】

从0到1实现一个属于自己的脚手架工具

从0到1实现一个属于自己的脚手架工具

命令行交互工具

使用脚手架时,通常也会内置一些问题,通过用户选择的答案来生成对应的模板;这里我们同样也引入第三方库来辅助开发,如:inquirerprompts,本项目中我们选用 prompts,文档链接:Prompts 文档

从0到1实现一个属于自己的脚手架工具

从0到1实现一个属于自己的脚手架工具

命令行样式美化

chalk,可以为命令行设置颜色和基础样式,避免视觉效果的单一导致的重点不突出

常用如下:

从0到1实现一个属于自己的脚手架工具

小试牛刀:

从0到1实现一个属于自己的脚手架工具

从0到1实现一个属于自己的脚手架工具

命令行loading

ora,可以为命令行设置loading

从0到1实现一个属于自己的脚手架工具

从0到1实现一个属于自己的脚手架工具

功能实现

👉 入口文件,使用 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);

从0到1实现一个属于自己的脚手架工具

👉 判断目录是否存在,若存在则询问用户是否覆盖

👉 生成目录并初始化 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));

从0到1实现一个属于自己的脚手架工具

👉 根据命令行参数判断是否跳过问题,若没跳过,进入问答环节,同样使用 prompts 即可

从0到1实现一个属于自己的脚手架工具

👉 处理文件,这里选用了 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到1实现一个属于自己的脚手架工具

参考链接

【Github】create-vue

【Github】create-react-app

【不太帅的程序员】从 0 构建自己的脚手架/CLI知识体系(万字)

从0到1实现一个属于自己的脚手架工具