脚摸手教你如何开发脚手架

前言

关于脚手架的基本概念在 脚手架闲聊 中已经有过说明,有兴趣的可以点击链接去了解一下,这里就不再重复的写了。

本文会逐步(尽量详细)的完成一个简单的用于快速创建指定模板的基础脚手架。

创建项目

在正常情况下,脚手架的项目理应是一个 多包存储库(Monorepo) ,因为常规的场景下,脚手架可能会有多个不同的命令,可能需要 动态 的制定某个命令或脚手架的起始的入口,这种情况脚手架足够 灵活 可以手动设置或预下载一些命令的执行 软件包 ,这里为了相对直观的理解以及是简单的脚手架,所以还是采用常规的单项目写法。

第一步:创建一个脚手架的项目文件夹 mkdir wl-cli ,当然你也可以右键新建文件夹;

脚摸手教你如何开发脚手架

第二步:使用 IDE(i like vscode) 打开该文件夹,并且在终端中打开该文件夹后使用 npm init 进行初始化操作(基本上一路回车就好,当然也可以选择性的把 author、description 等信息输入一下);

脚摸手教你如何开发脚手架

第三步:在 wl-cli 下面分别创建 bin、lib、utils 三个文件夹目录,并在 bin 下创建 index.js 文件;

注:下图是完整的项目目录结构,jym 也可以一次性创建完成,然后忽视后续的中的 文件 创建指引。

脚摸手教你如何开发脚手架

第四步:来配置一下 package.json 中软件包的一些配置(入口文件路径等)了,需要注意的只有两个配置 bin(用来指定各个内部命令对应的可执行文件的位置)files(项目作为软件包发布的时候所包含的文件),对了可能还有 type: module 的配置,有了这个就可以使用 import 了,node.js 很多的库最新版本都是用的 esm ,当然要是特喜欢 cjs 也可以不要 type: module 的配置 hhh;

{
  // 名字随意起哈,只要 源(本文默认 npm) 上没有的
  // 但是后面查询的版本的时候是需要用 源 上已有的包才能查询到
  // 可以先用我的 @weilai-cli/core 这个包名凑合一下
  "name": "@weilai-cli/core", 
  "version": "1.0.0",
  "description": "简易脚手架",
  "type": "module",
  "bin": {
    "wl-cli": "bin/index.js"
  },
  "files": [
    "bin",
    "lib"
  ],
  "directories": {
    "test": "test"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "UOrb",
  "license": "ISC"
}

至此项目已经创建完成了,没太多重点,就第四步需要注意一下。

脚手架雏形

这里会逐步的让脚手架可以在命令行中运行起来。

第一步:打开 bin/index.js 文件,并在最上方键入 #! /usr/bin/env node ,这 很重要!;

脚摸手教你如何开发脚手架

第二步:编写脚手架的初始化执行入口了,在执行命令之前,有两步准备操作要做,一个是环境校验,一个是注册命令;

// file name: bin/index.js
#! /usr/bin/env node

import prepare from "../lib/prepare.js" // jym 记得要带文件后缀名,否则是会报找不到模块的
import registerCommand from "../lib/registerCommand.js"

init()

async function init() {
    try {
        await prepare() // 预处理
        registerCommand() // 注册命令
    } catch (error) {
        console.log(error.message)
    }
}

第三步:在 lib 目下补齐 prepare.jsregisterComand.js 两个文件,并抛出一个默认的方法,这些方法的具体业务逻辑,后面再来细说,现在有就可以了;

// file name: lib/prepare.js
const prepare = async () => {
    console.log('prepare')
}
export default prepare

// -----------------------------------------------

// file name: lib/registerComand.js
const registerCommand = async () => {
    console.log('registerCommand')
}
export default registerCommand

第四步:需要测试一下是否能正常运行,在项目根目录的终端中执行 npm link 创建软连接,然后运行脚手架命令,需要注意的是脚手架命令就是 package.json 中的 bin 字段的 key

脚摸手教你如何开发脚手架

至此脚手架的雏形就完成了,是不是简简单单,接下来就来具体的开发一下 prepareregisterCommand 两个方法了。

Prepare 脚手架运行前的准备工作

在注册命令和执行命令行为之前,需要做一些准备工作,例如:脚手架版本、用户权限、环境变量等等。

首先需要用 IDE 打开 lib/prepare.js 文件,这里也会分步骤且尽量的详细,希望 jym 不要嫌弃太简单和啰嗦😂。

第一步:需要安装一些后续需要用到的一些依赖包:npm i root-check path-exists semver dotenv axios url-join,这里稍微介绍一下这些包的作用:

1. root-check 用来检验用户权限,以及权限降级的;
2. path-exists 用检验路径的有效性,也就是路径是否真实存在;
3. semver 用来检验软件包的版本的号的,并且提供很多其他的 API 例如版本号比较之类的
4. dotenv 用来处理环境变量文件的
5. axios 这个就不用解释了吧?请求用的
6. url-join 用来合并链接的

第二步:引入相关的依赖包,以及一些需要用到的常量数据;

import { homedir } from 'os'
import path from 'path'
import semver from 'semver'
import dotenv from 'dotenv'
import { pathExistsSync } from 'path-exists'
import rootCheck from 'root-check'

// 这个是获取软件包的 last 版本号的方法,碰到的时候再具体分析
import { getNpmLastVersions } from '../utils/index.js'

// package 文件,assert { type: "json" } 是类型声明,不过运行的时候会报一个告警
import pkg from '../package.json' assert { type: "json" } 

const homeDir = homedir() // 用户的 room 目录
const env = process.env // 环境变量

const prepare = async () => {
  console.log('prepare')
}

export default prepare

第三步:这里先标注一下检验的步骤,让后面的步骤更清晰一点;

... // 第二步的代码,这里省略不写了,免得看的累

const prepare = async () => {
    // 1. 打印当前版本号
    // 2. 检查用户主目录是否存在
    // 3. 降级操作
    // 4. 检查环境变量
    // 5. 检查全局最新版本
}

export default prepare

这里总共分为五个步骤:

步骤一:打印当前版本号

... // 省略的代码

// 1. 打印当前版本号
console.log('prepare')
console.log(' version:', pkg.version) // 第二步中导入了 package.json
console.log(' homedir:', homeDir) // 第二步中的 os.homedir()

... // 省略的代码

步骤二:检查用户主目录是否存在

... // 省略的代码

// 2. 检查用户主目录是否存在
console.log('prepare-homedir:', homeDir) // 简单的打印一些日志信息

// pathExistsSync 是同步执行的;所以 pathExists 是异步执行的,这里得用 同步
if (!homeDir || !pathExistsSync(homeDir)) {
    throw new Error('当前登陆用户主目录不存在!')
}

... // 省略的代码

步骤三:降级操作

... // 省略的代码

// 3. 降级操作
console.log('prepare-rootCheck')
rootCheck() // 😂没啥可说的,执行一下就完事儿了

... // 省略的代码

步骤四:检查环境变量

... // 省略的代码

// 4. 检查环境变量
console.log('prepare-env')
let config = {}

// 拼接成用户目录 + .wlrc 的路径
const dotenvPath = path.resolve(homeDir, '.wlrc')
console.log(' dotenvPath:', dotenvPath)

// 校验该路径是否存在,不过一般情况下都是不存在的
if (pathExistsSync(dotenvPath)) {
    // 存在环境变量路径就读取环境变量配置
    config = dotenv.config({ path: dotenvPath })
} else {
    // 不存在环境变量路径就生成默认的配置
    config = {
      CLI_REAL_HOME: env.CLI_HOME
        ? path.join(homeDir, env.CLI_HOME)
        : path.join(homeDir, '.wl-cli')
    }
}

// 设置环境变量
for(let key in config) env[key] = config[key]
console.log(' config:', config)

... // 省略的代码

步骤五:检查全局最新版本,这段日志有点多,可能看的有点累

... // 省略的代码

// 5. 检查全局最新版本
console.log('prepare-checkGlobalUpdate')
const { name, version } = pkg // 获取 package.json 中的包名称和版本号
console.log(' name:', name)
console.log(' version:', version)

// 请求 源 的接口,获取当前软件包的最新版本号(这里先不急,待会儿再具体说明)
const lastVersion = await getNpmLastVersions(name, version)
console.log('lastVersion:', lastVersion)

// 根据返回的最新版本号,来判断是否需要输出 软件包版本更新 的提示
if (lastVersion && semver.gt(lastVersion, version)) {
    console.log('当前版本', version)
    console.log('最新版本', lastVersion)
    console.log('更新命令', `npm install -g ${name}@${lastVersion}`)
}

... // 省略的代码

获取软件包最新版本号方法 getNpmLastVersions 的实现。
首先需要在 项目根目录下的 utils 目录下新建一个 index.js 文件,然后使用 IDE 打开该文件,代码如下所示:

import axios from 'axios' // 发起网络请求用的库
import semver from 'semver' // 版本号校验用的库
import urlJoin from 'url-join' // 合并链接用的库

// 查询软件包的信息,接受两个参数,一个是 软件包名称 ,一个是 源 的地址
// 源 默认是 npm 源
const queryNpmInfo = async (npmName, registry = 'https://registry.npmjs.org/') => {

  // 边界判断,软件包名称不存在的时候,直接返回 null
  if (!npmName) return null
  
  try {
    // 先使用 urlJoin 方法拼接 源 和 软件包名称 为一个正确的 url 地址
    // 然后使用 axios 发起一个 get 类型的请求
    const { data, status } = await axios.get(urlJoin(registry, npmName))
    if (status === 200) return data // 请求成功则返回 data 数据
    return null // 请求失败则返回 null
  } catch (error) {
    throw new Error(error) // 网络请求异常进行报错捕捉,虽然又抛了个报错出去 hhh
  }
}

// 获取软件包最新版本号的方法,记得该方法需要 导出
// 该方法接受一个参数:软件包名称
export const getNpmLastVersions = async (npmName) => {
  // 调用 查询软件包信息 的接口
  const res = await queryNpmInfo(npmName)
  // 边界判断,查询返回值为 空 的时候,返回 null
  if (!res) return null 
  
  // 获取返回数据中 versions 的所有索引名
  // 有兴趣的 jym 可以加一行 console.log(res) 了解一下请求返回的数据格式
  // 由于太长了,不利于后面的信息的展示所以我就 酸掉了
  const versions = Object.keys(res.versions)
  console.log('versions:', versions)
  
  // 当 versions 存在就使用 semver 进行排序,并返回第一个元素,否则返回 null
  return versions && (semver.rsort(versions)[0]) || null
}

最后这里可以再次运行一下脚手架命令,看看命令行中的打印信息:
脚摸手教你如何开发脚手架

OK 到这里,脚手架的准备工作就全部完成了 🎉 🎉 🎉。

注:这里我有想过发一下 prepare 的全部代码,但是有点长而且占空间,所以有需要的话,后续可以发在评论区中。

RegisterCommand 注册脚手架命令

接下来终于要开始脚手架中比较核心的位置,命令的注册。

这里会注册一些常用的命令:例如:输出版本号、输出命令帮助等等。

首先需要用 IDE 打开 lib/registerCommand.js 文件,接下来的代码都会写在该文件中。

第一步:同样是安装依赖的软件包 npm i commander ,这是一个 node.js 命令行界面的解决方案 号称完美(至少 npm 包简介中它是这么写的,当然也可能是机器翻译的问题),这里不会过多的去聊 commander 的内容,因为笔者也没有怎么去深入了解,只是简单用用而已,大体上了解几个配置项就可以了,有兴趣的也可以去看看 文档

1.  首先需要使用 commander 中到导出的 Command 类创建一个 Command 的实例;
2.  然后调用该实例的方法进行 **命令/选项/配置****注册/设置** 
3.  usage 是设置输出帮助信息的首行提示
4.  version 是设置当前实例的版本号,并且会自动注册 `-V``--version` 的命令
5.  option 注册选项参数,该方法接受三个参数,分别是:名称(可以使用 `,` 配置多个)、说明信息、默认值
6.  command 注册命令
7.  action 命令的执行
8.  on 注册事件监听
9.  parse 解析指定的入参,一般直接使用 `process.argv` 就好
10. outputHelp 输出帮助信息

第二步:因为代码不算太多,这里就干脆一次性全贴出来了;

import { Command } from 'commander'

// 创建命令的执行方法,后面再说
import execCreate from './execCreate.js'
import pkg from '../package.json' assert { type: "json" }

const env = process.env
const program = new Command()

const registerCommand = async () => {
  program
    .name(Object.keys(pkg.bin)[0])
    .usage('<command> [options]') // 修改帮助信息的首行提示
    .version(pkg.version) // 设置版本号提示也就是 -V --version
    .option('-d, --debug', '是否开启调试模式', false) // 设置调试模式配置

  program
    .command('create [projectName]') // 注册 create 的命令
    .action(execCreate) // 行为

  // 监听 debug 设置
  program.on('option:debug', () => {
    env.DEBUG = program.opts().debug
    console.log('debug:', program.opts().debug)
  })
  
  // 的第一个参数是要解析的字符串数组,也可以省略参数而使用 process.argv
  // 这里解析成功且命中了某个命令,就会执行对应的 action
  // 注:一些内置命令在执行完成后会调用 process.exit(0) 方法退出进程,所以后面的代码不会执行
  console.log('process.argv:', process.argv)
  program.parse(process.argv)

  // 当没有命令和配置的时候打印帮助文档
  if(program.args && program.args.length < 1) {
    program.outputHelp() // 输出帮助
    console.log() // 换行
  }
}

export default registerCommand

第三步:目前脚手架交互界面已经注册好了,在终端中可以输入对应的命令查看打印信息;

这里为了方便演示,已经把 prepare 中的 console 全部注释掉了,以免过多的信息看花了眼。

空命令
脚摸手教你如何开发脚手架

–version
脚摸手教你如何开发脚手架

-d
脚摸手教你如何开发脚手架

以上,便完成了 脚手架命令 的注册,最后我们只需要完成 create 命令的执行逻辑,这个简易脚手架就开发完成了。

ExecCreate 执行创建命令

这是最后的内容了,相对来说也可能是最难理解的部分,这一步需要有 用户命令行交互行为,并且根据交互所得的 信息 下载对应的 模板,并对模板进行一系列 处理,最后执行 依赖安装项目运行 命令。

第一步:照旧是安装依赖 npm i fs-extra inquirer npminstall glob ejs

1. fs-extra 可以理解成 node.js fs 文件库的 plus 版本;
2. inquirer 一组常见的交互式命令行用户界面;
3. npminstall 顾名思义,用来安装依赖包的库;
4. glob 根据条件来获取文件的;
5. ejs 嵌入式 JavaScript 模板;

第二步:依赖引入以及逻辑步骤;

import path from 'path'
import { spawnSync } from 'child_process' // 同步创建子进程

import ejs from 'ejs' // JS 模板,用来渲染模板的,对 pug 和 jsp 有了解的 jym 可能不陌生
import fs from 'fs-extra' // puls 版的 fs 库
import sermver from 'semver' // 版本号校验和比较等
import inquirer from 'inquirer' // 命令行交互
import npminstall from 'npminstall' // 软件包安装
import { glob } from 'glob' // 根据过滤规则获取指定目录下的文件集合
import { pathExistsSync } from 'path-exists' // 路径校验

// action 接受一个方法,并且会给该方法传入三个参数
// 参数分别是:命令名称、配置项、commander 的实例
const execCreate = async (name, options, command) => {
  // 1. 检查 node 版本
  // 2. 检查目标目录
  // 3. 信息收集
  // 4. 模板下载
  // 5. 模板渲染
  // 6. 安装依赖和运行
}

export default execCreate

步骤一:检查 node 版本,通过 semver 库来对比本地 node.js 和指定 node.js 的版本号,要求本地的版本号大于或等于指定的

... // 省略的代码

// 1. 检查 node 版本
if(!semver.gte(process.version, '16.0.0')) {
    throw new Error('wl-cli 需要安装 v16 及以上版本的 Node.js')
}

... // 省略的代码

步骤二:检查目标目录,并且这里相当于留了一个未完成的问题等待 jym 解决,hhh

... // 省略的代码

// 2. 检查目标目录
// 获取目标文件夹的路径
const targetDir = path.resolve(process.cwd(), name)

// 判断目标文件夹是否已存在
if (pathExistsSync(targetDir)) {
    // 目录存在
    const fileList = fs.readdirSync(targetDir)
    
    // 判断目标文件夹是否为空
    if (fileList.length > 0) {
        // 目录不为空
        console.log('wran: 目录不为空!')
        
        // TODO: 是否继续创建! 
        // 这里想留一个 未完成 的任务
        // 有兴趣的 jym 可以考虑一下这要怎么做,才可以进行 继续 和 不继续 的分支操作
        
        // 退出进程
        process.exit(1)
    }
} else {
    // 确保目录存在
    // 该方法会自行判断传入的 路径 是否能完整访问
    // 如果不能,它会自己补全路径目录,相当方便
    fs.ensureDirSync(targetDir)
}

... // 省略的代码

步骤三:信息收集,通过 inquirer 来和用户进行交互,并且收集需要的信息

注:这里的模板列表数据,也可以考虑写个接口或者配置文件动态读取,当然其他的数据也可以这样处理。

... // 省略的代码

// 3. 信息收集
const { tplNpmName, version } = await inquirer.prompt([
    {
      type: 'list',
      name: 'tplNpmName',
      message: '请选择项目类型',
      default: 'weilai-cli-template-vue3',
      choices: [
        { name: 'Vue3', value: 'weilai-cli-template-vue3' },
        { name: 'Vue2', value: 'weilai-cli-template-vue-element-admin'}
      ]
    }, {
      type: 'input',
      name: 'version',
      message: `请输入项目版本号`,
      default: '1.0.0',
      validate: function(v) {
        if(sermver.valid(v)) return true
        return '请输入合法的版本号'
      }
    }
])
... // 省略的代码

步骤四:模板下载

... // 省略的代码

// 4. 模板下载
await npminstall({
    // 这里是指的是下载软件包的 根目录
    // 这是也是直接的就在 目标目录 里面创建了
    // 其实也可以在 homedir 目录下创建一个 缓存 目录,来缓存这些模板,这样就可以避免每次都下载了
    // 当然这样的话,也需要去判断 模板 的版本是否更新,更新了的话,也是需要重新下载
    root: targetDir,
    pkgs: [{
      name: tplNpmName,
      // 也可以使用获取软件包最新版本号的接口获取
      // 当然这里的 三元表达式 是偷懒的行为,实际上应该要有个列表数据,从列表数据中过滤出来
      version: tplNpmName === 'weilai-cli-template-vue3' ? '1.0.1' : '1.0.0' 
    }]
})
// 拼接出安装的模板软件包中 template 的路径,具体可以单独安装模板的软件包,然后看看目录结构
const templatePath = path.resolve(targetDir, `node_modules/${tplNpmName}/template`)

// 拷贝模板路径中的内容到 目标目录 中
fs.copySync(templatePath, targetDir)

// 删除安装的软件包
fs.removeSync(path.resolve(targetDir, 'node_modules'))

... // 省略的代码

步骤五:模板渲染

... // 省略的代码

// 5. 模板渲染
// 前提回顾:在 模板下载 中,下载了项目模板,并且把项目模板拷贝到了 目标目录 中后,酸掉了模板的软件包
// 从 目标目录 中读取文件集合,并且屏蔽掉 node_modules 和 public 两个目录的文件
const files = await glob('**', { cwd: targetDir, ignore: ['**/node_modules/**', '**/public/**'], nodir: true })
files.map(async file => {
    // 当前文件的路径
    const filePath = path.join(targetDir, file)
    return new Promise((resolve, reject) => {
        // 使用 ejs 进行渲染文件
        // 第一个参数是文件路径;第二个参数是相关数据;第三个参数不知道啥用,第四个参数是回调函数
        ejs.renderFile(filePath, { name, version }, {}, (err, result) => {
            // 报错判断
            if(err) return reject(err)
            
            // 把渲染后的数据,写入到当前文件中,相当于做了个内容替换
            fs.writeFileSync(filePath, result)
            resolve(result)
        })
    })
})

... // 省略的代码

步骤六:安装依赖和运行

... // 省略的代码

// 6. 安装依赖和运行
// 这个没什么好说的,简单来说就是在 目标目录 中执行 npm install 和 npm run serve 命令
// 当然这里执行成功与否不影响 模板 是否成功创建
// 所以这里想表达的意思是,那两个模板有点古老了,所以可能大概运行不起来,又懒得更新模板包,hhh
const ret1 = spawnSync('npm', ['install'], { cwd: targetDir, stdio: 'inherit' })
if (ret1.status !== 0) throw new Error('安装依赖失败')
const ret2 = spawnSync('npm', ['run', 'serve'], { cwd: targetDir, stdio: 'inherit' })
if (ret2.status !== 0) throw new Error('运行失败')

... // 省略的代码

好,创建命令的逻辑写完了,那么就执行一下,看看效果如何吧!

脚摸手教你如何开发脚手架
脚摸手教你如何开发脚手架

芜湖,创建成功,很顺利,虽然模板是非常的简陋 😂😂😂
脚摸手教你如何开发脚手架

关于模板

其实项目模板没什么东西,就是把一个处理好的基础项目架构放在 template 目录里面,然后把整体当成一个软件包发不到 源 上面,大体目录如下:
脚摸手教你如何开发脚手架

然后就是模板中的数据渲染,这也是为什么代码中把 public 目录给屏蔽掉了,因为 vue-cli 创建出来的项目,在 public/index.html 中,也使用了 <%= %> 的模板语法,所以会报错,就给屏蔽掉了。
脚摸手教你如何开发脚手架

总结

以上就是简易脚手架开发的全部的内容了,是不是没有想象中的那么难,还等什么呢,赶紧打造一个自己的专用脚手架吧!!!

然后是 TODO 的存留问题,欢迎/希望大家能在评论区中讨论实现方案和分享自己的代码。

最后的最后,有不懂或者想进一步的同学可以看看 weilai-cli 的源码,虽然目前可能是无法正常运行(因为服务器到期,模板的接口服务没了),但是相对来说是简易脚手架的一个相对过时的 plus 版本?

注:本文内容算是比较长的,避免不了会有遗漏或错误的地方,欢迎 jym 指正错误。

求求了给个一键三连吧,这对我真的很重要

原文链接:https://juejin.cn/post/7223930180866490424 作者:UOrb

(0)
上一篇 2023年4月20日 上午10:10
下一篇 2023年4月20日 上午10:21

相关推荐

发表评论

登录后才能评论