为什么要设计
- 减少重复性工作,无需从零开始构建项目或者手动复制粘贴项目代码;
- 有利用多人协作开发,统一团队代码规范、开发目录结构等;
- 可以集成多套项目模版,根据选择使用;
- 可以通过动态交互生成项目结构和配置;
- 针对业务或者复杂的场景痛点,可以通过集成脚本的形式实现。
满足功能
基础功能
- 命令行输入,初始化项目,渲染模版
拓展功能
- 动态交互生成模版
- 集成多套项目模版
- 集成脚本,实现业务&场景处理
DEMO
1. npm init
生成package.json
2. package.json 添加软链接
// package.json
{
"bin": {
"cj-cli": "./bin/index.js"
}
}
npm link 可以链接到全局,全局使用 cj-cli 命令
3. 编写index.js ,写一个命令行工具
#! /usr/bin/env node
console.log('hello world')
#! 符号的名称叫 Shebang,用于指定脚本的解释程序
依赖基础包
- commander
命令行工具,读取命令行,解析参数
- inquirer
交互式命令行工具
- chalk
颜色插件,可修改命令行输出文字的样式,区分info,warn,error等日志
- ora
显示加载中loading效果,
- fs-extra
node fs文件系统模块加强版
- pacote
获取node最新版本等信息
- handlebars
高效构建语义化模版
实战
1. 参考demo先搭建好架子
目录结构:
2. 安装相关依赖
package.json
"devDependencies": {
"@rollup/plugin-json": "^5.0.1",
"@rollup/plugin-terser": "^0.1.0",
"eslint": "^7.32.0 || ^8.2.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-prettier": "^4.2.1",
"prettier": "^2.7.1",
"rollup": "^3.3.0"
},
"dependencies": {
"chalk": "^4.1.2",
"child_process": "^1.0.2",
"commander": "^9.4.1",
"download-git-repo": "^3.0.2",
"fs-extra": "^10.1.0",
"handlebars": "^4.7.7",
"inquirer": "^8.0.0",
"ora": "^5.4.1",
"semver": "^7.3.8"
}
3. 配置eslint,prettier,规范,格式化代码;
.eslintrc.js
module.exports = {
// 停止在父级目录中寻找配置
root: true,
// 配置多个环境
env: {
commonjs: true,
es2021: true,
node: true,
},
// 让prettier的解析作为eslint的一部分
extends: ['airbnb-base', 'prettier'],
parserOptions: {
// 支持最新的ECMASript语法
ecmaVersion: 'latest',
},
// 关闭和eslint中以及和其他拓展中有冲突的规则
plugins: ['prettier'],
rules: {
'prettier/prettier': 'error',
'no-console': 'off',
'global-require': 'off',
'import/no-dynamic-require': 'off',
'import/no-import-module-exports': 'off',
},
}
.prettierrc
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100,
"useTabs": false
}
使用VsCode的话,开启eslint和prettier相关插件,配合使用
4. 编写工具函数
scripts/log.js
const chalk = require('chalk')
const yellow = (msg) => console.log(chalk.yellow(msg))
const red = (msg) => console.log(chalk.red(msg))
const green = (msg) => console.log(chalk.green(msg))
export default {
yellow,
red,
green,
}
5. 设置node版本检查。如果不满足版本要求,提示升级版本;
scripts/checkVersion.js
import log from './log'
const semver = require('semver')
const pkg = require('../package.json')
const requiredVersion = pkg.engines.node
// 检查node版本
export default () => {
const ok = semver.satisfies(process.version, requiredVersion, { includePrerelease: true })
if (!ok) {
log.red(
`你当前使用的Node版本 ${process.version}, 但脚手架需要 Node版本 ${requiredVersion}.\n请升级你的Node版本`
)
process.exit(-1)
}
}
bin/index.js
import checkVersion from '../scripts/checkVersion'
// 检查node版本
checkVersion()
6. 配置基础命令,输出cli的版本号,help提示等
bin/index.js
+ const { program } = require('commander')
import checkVersion from '../scripts/checkVersion'
+ const pkg = require('../package.json')
// 检查node版本
checkVersion()
+ /**
+ * 默认 -V输出版本信息
+ * 可以通过重写的信息,-v 输出包名和版本号信息
+ */
+ program.version(`${pkg.version}`, '-v', '--version').usage('<command> [options]')
+ // 解析命令行
+ program.parse(process.argv)
+ // 没有参数时,输出帮助信息
+ if (!process.argv.slice(2).length) {
+ program.outputHelp()
+ }
7.编写项目模版
暂时跳过
8. commands命令,创建模版
bin/index.js
const { program } = require('commander')
const checkVersion = require('../scripts/checkVersion')
+ import commandsOptions from '../commands/index'
const pkg = require('../package.json')
// 检查node版本
checkVersion()
+ // 遍历commands命令
+ Object.values(commandsOptions).forEach((item) => {
+ const { command, description, action } = item
+ program.command(command).description(description).action(action)
+ })
/**
* 默认 -V输出版本信息
* 可以通过重写的信息,-v 输出包名和版本号信息
*/
program.version(`${pkg.version}`, '-v', '--version').usage('<command> [options]')
// 配置命令
// 解析命令行
program.parse(process.argv)
// 没有参数时,输出帮助信息
if (!process.argv.slice(2).length) {
program.outputHelp()
}
commands/create.js
import logger from '../scripts/log'
const fs = require('fs-extra')
const inquirer = require('inquirer')
const { execFileSync } = require('child_process')
const download = require('download-git-repo')
const handlebars = require('handlebars')
const ora = require('ora')
let spinner
export default {
command: 'create <project-name>',
description: '初始化项目',
action: async (projectName) => {
const isExist = fs.existsSync(projectName)
// 已存在同名
if (isExist) {
const { override } = await inquirer.prompt([
{
type: 'confirm',
name: 'override',
message: '当前目录中已经存在同名的文件夹/项目。是否覆盖?',
default: false,
},
])
if (override) {
fs.removeSync(projectName)
} else {
logger.red('停止创建项目。')
process.exit(-1)
}
}
const gitAuthor = execFileSync('git', ['config', 'user.name'], { encoding: 'utf-8' })
const { description, author } = await inquirer.prompt([
{
type: 'input',
name: 'description',
message: '项目描述',
},
{
type: 'input',
name: 'author',
message: '你的名字',
default: gitAuthor.trim(),
},
])
spinner = ora('拉取远程模版中 loading...').start()
download('jayyoonn/cj-cli-template', projectName, (err) => {
if (err) {
spinner.fail(`拉取模版失败${err}`)
return
}
const packagePath = `${projectName}/package.json`
const packageContent = fs.readFileSync(packagePath, 'utf-8')
// 使用handlebars解析模板引擎
const packageResult = handlebars.compile(packageContent)({
description,
author,
name: projectName,
})
// 将解析后的结果重写到package.json文件中
fs.writeFileSync(packagePath, packageResult)
spinner.succeed('创建项目成功!!')
})
},
}
9. 添加changelog
"scripts": {
"test": "echo "Error: no test specified" && exit 1",
"lint": "eslint . --fix",
"build": "rm -rf dist && rollup -c",
+ "release": "standard-version"
},
"devDependencies": {
"@rollup/plugin-json": "^5.0.1",
"@rollup/plugin-terser": "^0.1.0",
"eslint": "^7.32.0 || ^8.2.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-prettier": "^4.2.1",
"prettier": "^2.7.1",
"rollup": "^3.3.0",
+ "standard-version": "^9.5.0"
},
首次执行,为您的第一个版本生成变更日志,只需执行以下操作:
pnpm release -- --first-release
10. rollup配置打包
rollup.config.js
const json = require('@rollup/plugin-json')
const teser = require('@rollup/plugin-terser')
module.exports = {
input: './bin/index.js',
output: {
format: 'cjs',
banner: '#!/usr/bin/env node',
file: './dist/index.js',
},
plugins: [teser(), json()],
}
11. npm发包
- npm adduser(登陆npm)
- npm publish
进阶&补充
commonJs + js -> ESModule + TypeScript
添加husky
git-cz 提交
1. tsconfig.json生成
{
"compilerOptions": {
"target": "es2017",
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["./bin", "./scripts", "@types"]
}
2. package.json修改,依赖版本调整
{
"name": "@jayyoonn/cj-cli",
"version": "2.3.1",
"description": "cj-cli脚手架工具",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint . --fix"
"build": "rollup -c"
},
"bin": {
"cj-cli": "./dist/index.js"
},
"keywords": [
"cj-cli"
],
"author": "jayyoonn",
"license": "ISC",
"homepage": "https://github.com/jayyoonn/cj-cli",
"repository": "https://github.com/jayyoonn/cj-cli",
"bugs": "https://github.com/jayyoonn/cj-cli/issues",
"engines": {
"node": "^16.3.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-json": "^5.0.2",
"@rollup/plugin-node-resolve": "^15.1.0",
"@rollup/plugin-terser": "^0.1.0",
"@types/inquirer": "^9.0.3",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"cz-git": "^1.6.1",
"eslint": "^8.44.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.2.1",
"husky": "^8.0.0",
"load-json-file": "^7.0.1",
"prettier": "^2.8.8",
"rollup": "^3.26.1",
"standard-version": "^9.5.0",
"tslib": "^2.6.0",
"typescript": "^5.1.6",
"@rollup/plugin-typescript": "^11.1.2",
"@types/node": "^20.3.2",
"@types/semver": "^7.5.0"
},
"dependencies": {
"chalk": "^5.3.0",
"child_process": "^1.0.2",
"commander": "^11.0.0",
"download-git-repo": "^3.0.2",
"fs-extra": "^11.1.1",
"handlebars": "^4.7.7",
"inquirer": "^9.2.7",
"ora": "^6.3.1",
"semver": "^7.3.8"
}
}
package.json type字段设置为module
依赖升级,把原有支持commonJs的package,升级为支持ESM格式
3. 文件后缀js修改为ts,补充文件内容的类型定义
由于 download-git-repo 缺少对应的类型定义,需要手动补充定义
@types/download-git-repo.d.ts
declare module 'download-git-repo' {
export default function download(repo: string, destination: string, options?: any): Promise<void>
}
4. eslint规则调整
.eslintrc.json
{
// 停止在父级目录中寻找配置
"root": true,
"parser": "@typescript-eslint/parser",
// 配置多个环境
"env": {
"es2021": true,
"node": true
},
// 让prettier的解析作为eslint的一部分
"extends": ["plugin:@typescript-eslint/recommended", "prettier"],
"parserOptions": {
// 支持最新的ECMAScript语法
"ecmaVersion": "latest"
},
// 关闭和eslint中以及和其他拓展中有冲突的规则
"plugins": ["@typescript-eslint", "prettier"],
"rules": {
"prettier/prettier": "error",
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off",
"import/no-import-module-exports": "off"
}
}
5. rollup打包逻辑调整
rollup.config.ts
import { defineConfig } from 'rollup'
import json from '@rollup/plugin-json'
import terser from '@rollup/plugin-terser'
import typescript from '@rollup/plugin-typescript'
import commonjs from '@rollup/plugin-commonjs'
import resolve from '@rollup/plugin-node-resolve'
export default defineConfig({
input: './bin/index.ts',
output: {
format: 'esm',
banner: '#!/usr/bin/env node',
file: './dist/bundle.js',
},
external: [
'commander',
'inquirer',
'child_process',
'download-git-repo',
'handlebars',
'ora',
'semver',
'chalk',
],
plugins: [typescript(), resolve(), commonjs(), json(), terser()],
})
添加 @rollup/plugin-commonjs 和 @rollup/plugin-node-resolve,处理外部依赖,打包时把package.json打进bundle.js中
添加 '@rollup/plugin-typescript' 编译ts文件
6. 添加husky、lint-stage
pnpm dlx husky-init && pnpm install # pnpm
./husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
echo husky running
npx lint-staged
添加.lintstagedrc.json
{
"*.ts": ["eslint --fix", "tsc --noEmit"]
}
7. commitizen配置
全局安装
pnpm install -g commitizen
项目安装
pnpm add -D cz-git
添加 config 指定使用的适配器,添加commit脚本,代替 git add . && git commit
{ ...
"scripts": {
"commit": "git add . && git-cz"
},
"config": {
"commitizen": {
"path": "node_modules/cz-git"
}
}
}
添加文件commitlint.config.cjs模版
// .commitlintrc.js
/** @type {import('cz-git').UserConfig} */
module.exports = {
rules: {
// @see: https://commitlint.js.org/#/reference-rules
},
prompt: {
alias: { fd: 'docs: fix typos' },
messages: {
type: '选择你要提交的类型 :',
scope: '选择一个提交范围(可选):',
customScope: '请输入自定义的提交范围 :',
subject: '填写简短精炼的变更描述 :\n',
body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
footerPrefixesSelect: '选择关联issue前缀(可选):',
customFooterPrefix: '输入自定义issue前缀 :',
footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
confirmCommit: '是否提交或修改commit ?'
},
types: [
{ value: 'feat', name: 'feat: 新增功能 | A new feature' },
{ value: 'fix', name: 'fix: 修复缺陷 | A bug fix' },
{ value: 'docs', name: 'docs: 文档更新 | Documentation only changes' },
{ value: 'style', name: 'style: 代码格式 | Changes that do not affect the meaning of the code' },
{ value: 'refactor', name: 'refactor: 代码重构 | A code change that neither fixes a bug nor adds a feature' },
{ value: 'perf', name: 'perf: 性能提升 | A code change that improves performance' },
{ value: 'test', name: 'test: 测试相关 | Adding missing tests or correcting existing tests' },
{ value: 'build', name: 'build: 构建相关 | Changes that affect the build system or external dependencies' },
{ value: 'ci', name: 'ci: 持续集成 | Changes to our CI configuration files and scripts' },
{ value: 'revert', name: 'revert: 回退代码 | Revert to a commit' },
{ value: 'chore', name: 'chore: 其他修改 | Other changes that do not modify src or test files' },
],
useEmoji: false,
emojiAlign: 'center',
themeColorCode: '',
scopes: [],
allowCustomScopes: true,
allowEmptyScopes: true,
customScopesAlign: 'bottom',
customScopesAlias: 'custom',
emptyScopesAlias: 'empty',
upperCaseSubject: false,
markBreakingChangeMode: false,
allowBreakingChanges: ['feat', 'fix'],
breaklineNumber: 100,
breaklineChar: '|',
skipQuestions: [],
issuePrefixes: [
// 如果使用 gitee 作为开发管理
{ value: 'link', name: 'link: 链接 ISSUES 进行中' },
{ value: 'closed', name: 'closed: 标记 ISSUES 已完成' }
],
customIssuePrefixAlign: 'top',
emptyIssuePrefixAlias: 'skip',
customIssuePrefixAlias: 'custom',
allowCustomIssuePrefix: true,
allowEmptyIssuePrefix: true,
confirmColorize: true,
maxHeaderLength: Infinity,
maxSubjectLength: Infinity,
minSubjectLength: 0,
scopeOverrides: undefined,
defaultBody: '',
defaultIssues: '',
defaultScope: '',
defaultSubject: ''
}
}