前端脚手架开发
lwcai

项目初始化

npm init -y

安装依赖

  • commander:命令行工具
  • prompts:交互式命令行工具
  • kolorist:修改控制台输出内容样式
  • validate-npm-package-name:用于验证npm包名称是否有效

编写脚本文件

package.json中指定项目的可执行文件:

{
...,
"bin": {
"my-cli": "./bin/index.js"
}
}

在根目录下新建一个bin文件夹,并创建脚本文件index.js

#!/usr/bin/env node

const { Command } = require('commander')
const prompts = require('prompts')
const path = require('path')
const fs = require('fs-extra')
const validateProjectName = require('validate-npm-package-name')
const os = require('os')
const {
cyan,
green,
magenta,
red,
blue,
} = require('kolorist')
const packageJson = require('../package.json')

// 可用的模板
const templateList = [
{
title: cyan('react-app'),
value: 'react-app',
},
// 其它可选项
]

async function init() {
const program = new Command(packageJson.name)
.version(packageJson.version)
.argument('<project-directory>')
.action(async (name) => {
const root = path.resolve(name)
const appName = path.basename(root)

checkAppName(appName)
fs.ensureDirSync(name)

try {
const response = await prompts(
[
{
type: () => (isSafeToCreateProjectIn(root, name) ? null : 'confirm'),
name: 'overwrite',
message: 'Remove existing files and continue?',
initial: false,
},
{
type: (_, { overwrite }) => {
if (overwrite === false) {
throw new Error(`${red('✖')} Operation cancelled`)
}
return null
},
name: 'overwriteChecker',
},
{
type: 'select',
name: 'template',
message: 'Select a template',
choices: templateList,
},
],
{
onCancel: () => {
throw new Error(`${red('✖')} Operation cancelled`)
},
},
)

const { template, overwrite } = response

if (overwrite) {
cleanDir(root)
}
createApp(root, appName, template)
} catch (cancelled) {
console.log(cancelled.message)
}
})
.on('--help', () => {
})
.parse(process.argv)
}

/**
* 创建应用至指定路径,并且将应用名称等信息与模板中预设的package.json合并
* @param appPath
* @param appName
* @param template
* @returns {Promise<void>}
*/
async function createApp(appPath, appName, template) {
const templatePath = path.join(__dirname, `../templates/${template}`)
const templateJsonPath = path.join(templatePath, 'template.json')

let templateJson = {}
if (fs.existsSync(templateJsonPath)) {
// eslint-disable-next-line global-require,import/no-dynamic-require
templateJson = require(templateJsonPath)
}

const appPackage = {
name: appName,
version: '0.1.0',
private: true,
}
const templatePackage = templateJson.package || {}
Object.assign(appPackage, templatePackage)

fs.writeFileSync(
path.join(appPath, 'package.json'),
JSON.stringify(appPackage, null, 2) + os.EOL,
)

const templateDir = path.join(templatePath, 'template')
copyDir(appPath, appName, templateDir).then(() => {
console.log('')
console.log('Success! Now run: ')
console.log('')
console.log(` cd ${appName}`)
console.log(' npm install')
console.log(' npm run dev')
})
}

/**
* 校验包名称是否有效
* @param appName
*/
function checkAppName(appName) {
const validationResult = validateProjectName(appName)
if (!validationResult.validForNewPackages) {
console.error(
red(
`Cannot create a project named ${green(
`"${appName}"`,
)} because of npm naming restrictions:\n`,
),
);
[
...(validationResult.errors || []),
...(validationResult.warnings || []),
].forEach((error) => {
console.error(red(` * ${error}`))
})
console.error(red('\nPlease choose a different project name.'))
process.exit(1)
}
}

let conflicts = []
/**
* 判断指定路径下的文件夹是否为空
* @param root
* @param name
* @returns {boolean}
*/
function isSafeToCreateProjectIn(root, name) {
const validFiles = [
'.DS_Store',
'.git',
'.gitignore',
'.idea',
'README.md',
'package.json',
'package-lock.json',
'yarn-lock.json',
]
const errorLogFilePatterns = [
'npm-debug.log',
'yarn-error.log',
'yarn-debug.log',
]
const isErrorLog = (file) => errorLogFilePatterns.some((pattern) => file.startsWith(pattern))

conflicts = fs
.readdirSync(root)
.filter((file) => !validFiles.includes(file))
.filter((file) => !isErrorLog(file))

if (conflicts.length > 0) {
console.log(
`The directory ${green(name)} contains files that could conflict:`,
)
console.log('')
conflicts.forEach((file) => {
try {
const stats = fs.lstatSync(path.join(root, file))
if (stats.isDirectory()) {
console.log(` ${blue(`${file}/`)}`)
} else {
console.log(` ${file}`)
}
} catch (e) {
console.log(` ${file}`)
}
})
console.log()
console.log(
'Either try using a new directory name, or remove the files listed above.',
)
console.log()

return false
}

fs.readdirSync(root).forEach((file) => {
if (isErrorLog(file)) {
fs.removeSync(path.join(root, file))
}
})
return true
}

/**
* 拷贝应用模版至指定路径下
* @param appPath
* @param appName
* @param templateDir
* @returns {Promise<unknown>}
*/
function copyDir(appPath, appName, templateDir) {
return new Promise((resolve) => {
if (fs.existsSync(templateDir)) {
fs.copySync(templateDir, appPath)
resolve()
} else {
console.error(`Could not locate supplied template: ${green(templateDir)}`)
process.exit(1)
}
})
}

/**
* 清空指定路径下的所有文件及文件夹
* @param root
*/
function cleanDir(root) {
if (!fs.pathExistsSync(root)) {
return
}

fs.readdirSync(root).forEach((file) => {
fs.rmSync(path.resolve(root, file), { recursive: true, force: true })
})
}

init()

创建命令

使用commander可以快速构建命令行程序:

#!/usr/bin/env node
const { Command } = require('commander')

async function init() {
const program = new Command(packageJson.name)
// 配置版本号信息
.version(packageJson.version)
// 配置必填参数
.argument('<project-directory>')
// 调用命令后执行任务
.action(() => {
})
// 完善更多帮助信息
.on('--help', () => {
})
// 解析执行命令时传入的参数
.parse(process.argv)
}

命令行交互

通过使用prompts,可以在命令行中与用户进行交互,获取用户的输入并根据需要执行相应的操作:

const response = await prompts(
[
{
type: 'select',
name: 'template',
message: 'Select a template',
choices: templateList,
},
],
{
onCancel: () => {
throw new Error(`${red('✖')} Operation cancelled`)
},
},
)

const { template } = response

使用模板

有两种使用模板的方式,并各有优缺点:

  • 拷贝本地模板
    • 优点:
      • 离线使用:用户只要安装脚手架以后,即使在没有网络链接的情况下也能够正常使用脚手架和模版。
      • 快速启动:创建项目时不需要等待下载模板的过程,可以立即开始项目的开发。
      • 简单维护:模板和脚手架在同一个版本控制系统中,维护和更新都比较方便。可以通过版本控制系统的标签或分支管理模板的不同版本。
    • 缺点:
      • 更新困难:当需要更新模板时,需要发布新版本的脚手架。用户在升级脚手架时会受到模板更新的影响,无法选择保留原来的模板。
      • 模板定制性较差:模板与脚手架耦合在一起,定制模板的难度较大。用户想要对模板进行修改时,需要修改脚手架源码。
  • 下载远程模版
    • 优点:
      • 实时更新:模板存储在远程Git仓库中,可以随时更新和维护。用户在创建项目时可以获取到最新的模板。
      • 独立维护:模板和脚手架分离,可以独立维护模板。可以针对不同的项目需求,使用不同的Git分支或标签管理不同版本的模板。
      • 定制性强:用户可以根据项目需求,对远程模板进行定制化修改,而不需要修改脚手架源码。
    • 缺点:
      • 依赖网络:用户需要有可靠的网络连接,才能下载远程模板。
      • 下载时间:创建项目时需要从远程Git仓库下载模板,这可能需要一定的时间,特别是在网络较慢的情况下。

拷贝本地模板

在根目录下创建templates文件夹,用于存放模板:

templates
└── react-app-ts
├── template
│   ├── commitlint.config.js
│   ├── react-app-env.d.ts
│   ├── src
│   ├── tsconfig.json
│   └── webpack
└── template.json

将目标模板下的文件以及文件夹拷贝至指定路径下:

function copyDir(appPath, appName, templateDir) {
return new Promise((resolve) => {
if (fs.existsSync(templateDir)) {
fs.copySync(templateDir, appPath)
resolve()
} else {
console.error(`Could not locate supplied template: ${green(templateDir)}`)
process.exit(1)
}
})
}

下载远程模版

download-git-repo 是一个专门用于从 Git 仓库下载模板的库。并且可以使用ora在命令行界面中显示加载动画和提示信息,以增强用户体验:

const download = require('download-git-repo')
const ora = require('ora')

function downloadTemplate(appPath, appName) {
const spinner = ora('初始化项目')
spinner.start()
spinner.text = `项目${appName}初始化中`
spinner.spinner = {
interval: 120,
frames: ['. ', '.. ', '...', '.. '],
}

return new Promise((resolve, reject) => {
download('direct:https://github.com/xxx.git', appPath, { clone: true }, (err) => {
if (err) {
spinner.fail('项目初始化失败')
reject(err)
} else {
spinner.succeed('项目初始化成功')
resolve()
}
})
})
}

其它用于下载远程模板的Node.js库:got一个通用的 HTTP 请求库,可以用于下载任何远程资源。

本地调试

执行命令npm link即可将脚手架链接到全局node_modules目录下,执行以下命令开始调试:

my-cli your-project-directory
image

发布

登录你的 npm 账号:

npm login --registry https://www.npmjs.com

执行发布命令:

npm publish
 Comments