前言
关于脚手架的基本概念在 脚手架闲聊 中已经有过说明,有兴趣的可以点击链接去了解一下,这里就不再重复的写了。
本文会逐步(尽量详细)的完成一个简单的用于快速创建指定模板的基础脚手架。
创建项目
在正常情况下,脚手架的项目理应是一个 多包存储库(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.js
和 registerComand.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
;
至此脚手架的雏形就完成了,是不是简简单单,接下来就来具体的开发一下 prepare
和 registerCommand
两个方法了。
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