每當你發現自己和大多數人站在一邊,就是時候停下來思考了。—— 馬克·吐恩
因為這部分內容稍有些復雜,所以講解之前先貼出github地址和視頻講解地址:
相信大家在工作中都有如下經歷:
-
開發新項目,很多邏輯比如:項目架構、接口請求、狀態管理、國際化、換膚等之前項目就已經存在,這時,我們選擇“信手拈來”,ctrl + c,ctrl + v 二連,談笑間,新項目搭建完成,無非是要改改一些文件和包名;
-
項目增加某個模塊時,復制一個已有模塊,改改名字,新的模塊就算創建成功了;
-
項目的規范要無時無刻不在同事耳邊提及,就算有規范文檔,你還需要苦口婆心。
使用復制粘貼有以下缺點:
-
重復性工作,繁瑣而且浪費時間
-
copy過來的模板容易存在無關的代碼
-
項目中有很多需要配置的地方,容易忽略一些配置點
-
人工操作永遠都有可能犯錯,建新項目時,總要花時間去排錯
-
框架也會不斷迭代,人工建項目不知道最新版本號是多少,使用的依賴都是什么版本,很容易bug一大堆。
承受過以上一些痛苦的同學應該不少,怎么去解決這些問題呢?我覺得,腳手架能夠規避很多認為操作的問題,因為腳手架能夠根據你事先約定的規范,創建項目,定義新的模塊,打包,部署等等都能夠在一個命令敲擊后搞定,提升效率的同時降低了入職員工的培訓成本,所以,我推薦大家考慮考慮為團隊打造一個腳手架!
開發腳手架我們需要用到的三方庫
| 庫名 | 描述 |
|---|---|
| commander | 處理控制台命令 |
| chalk | 五彩斑斕的控制台 |
| semver | 版本檢測提示 |
| fs-extra | 更友好的fs操作 |
| inquirer | 控制台詢問 |
| execa | 執行終端命令 |
| download-git-repo | git遠程倉庫拉取 |
腳手架的職責和執行過程
腳手架可以為我們做很多事情,比如項目的創建、項目模塊的新增、項目打包、項目統一測試、項目發布等,我先與大家聊聊最初始的功能:項目創建。

上圖向大家展示了創建項目和項目中創建模塊的腳手架大致工作流程,下圖更詳細描述了基於模板創建的過程:

思路很簡單,接下來我們就通過代碼示例,為大家詳細講解。
package.json與入口
項目結構如圖

在package.json中指明你的包通過怎樣軟鏈接的形式啟動:bin 指定,因為是package.json包,所以我們一定要注意了dependencies、devDependencies和peerDependencies的區別,我這里不做展開。
{
"name": "awesome-test-cli",
"version": "1.0.0",
"description": "合一帶大家開發腳手架工具",
"main": "index.js",
"bin": {
"awesome-test": "bin/main.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"scaffold",
"efficient",
"react"
],
"author": "walker",
"license": "ISC",
"engines": {
"node": ">=8.9"
},
"dependencies": {
"chalk": "^2.4.2",
"commander": "^3.0.0",
"download-git-repo": "^2.0.0",
"execa": "^2.0.4",
"fs-extra": "^8.1.0",
"import-global": "^0.1.0",
"inquirer": "^6.5.1",
"lru-cache": "^5.1.1",
"minimist": "^1.2.0",
"nunjucks": "^3.2.0",
"ora": "^3.4.0",
"request-promise-native": "^1.0.7",
"semver": "^6.3.0",
"string.prototype.padstart": "^3.0.0",
"valid-filename": "^3.1.0",
"validate-npm-package-name": "^3.0.0"
}
}
接下來編寫/bin/main.js 入口文件,主要的操作就是通過commander 處理控制台命令,根據不同參數處理不同的邏輯.
// 開始處理命令 const program = require('commander') const minimist = require('minimist') program .version(require('../package').version) .usage('<command> [options]') // 創建命令 program .command('create <app-name>') .description('create a new project') .option('-p, --preset <presetName>', 'Skip prompts and use saved or remote preset') .option('-d, --default', 'Skip prompts and use default preset') .action((name, cmd) => { const options = cleanArgs(cmd) if (minimist(process.argv.slice(3))._.length > 1) { console.log(chalk.yellow('\n ⚠️ 檢測到您輸入了多個名稱,將以第一個參數為項目名,舍棄后續參數哦')) } require('../lib/create')(name, options) })
create 創建項目
將真正的處理邏輯放在 lib 中,這樣一來,我們后面希望添加更多命令或操作更友好。接下來我們編寫 lib/create 文件,該文件主要處理文件名合法檢測,文件是否存在等配置,檢測無誤,執行項目創建邏輯,該邏輯我們放在 lib/Creator 文件中處理。
async function create (projectName, options) { const cwd = options.cwd || process.cwd() // 是否在當前目錄 const inCurrent = projectName === '.' const name = inCurrent ? path.relative('../', cwd) : projectName const targetDir = path.resolve(cwd, projectName || '.') const result = validatePackageName(name) // 如果所輸入的不是合法npm包名,則退出 if (!result.validForNewPackages) { console.error(chalk.red(`不合法的項目名: "${name}"`)) result.errors && result.errors.forEach(err => { console.error(chalk.red.dim('❌ ' + err)) }) result.warnings && result.warnings.forEach(warn => { console.error(chalk.red.dim('⚠️ ' + warn)) }) exit(1) } // 檢查文件夾是否存在 if (fs.existsSync(targetDir)) { if (options.force) { await fs.remove(targetDir) } else { await clearConsole() if (inCurrent) { const { ok } = await inquirer.prompt([ { name: 'ok', type: 'confirm', message: `Generate project in current directory?` } ]) if (!ok) { return } } else { const { action } = await inquirer.prompt([ { name: 'action', type: 'list', message: `目標文件夾 ${chalk.cyan(targetDir)} 已經存在,請選擇:`, choices: [ { name: '覆蓋', value: 'overwrite' }, { name: '取消', value: false } ] } ]) if (!action) { return } else if (action === 'overwrite') { console.log(`\nRemoving ${chalk.cyan(targetDir)}...`) await fs.remove(targetDir) } } } } await clearConsole() // 前面完成准備工作,正式開始創建項目 const creator = new Creator(name, targetDir) await creator.create(options) } module.exports = (...args) => { return create(...args).catch(err => { stopSpinner(false) error(err) }) }
通過以上操作,完成了創建項目前的准備工作,接下來正式進行創建,創建操作通過一下代碼開始
const creator = new Creator(name, targetDir) await creator.create(options)
創建邏輯我們放在另外文件中 /lib/Creator,該文件中我們主要進行的操作有:
-
拉取遠程模板;
-
詢問項目創建相關配置,比如:項目名、項目版本、操作人等;
-
將拉取的模板文件拷貝到創建項目文件夾中,生成readme文檔;
-
安裝項目所需依賴;
-
創建git倉庫,完成項目創建。
const chalk = require('chalk')
const execa = require('execa')
const inquirer = require('inquirer')
const EventEmitter = require('events')
const loadRemotePreset = require('../lib/utils/loadRemotePreset')
const writeFileTree = require('../lib/utils/writeFileTree')
const copyFile = require('../lib/utils/copyFile')
const generateReadme = require('../lib/utils/generateReadme')
const {installDeps} = require('../lib/utils/installDeps')
const {
defaults
} = require('../lib/options')
const {
log,
error,
hasYarn,
hasGit,
hasProjectGit,
logWithSpinner,
clearConsole,
stopSpinner,
exit
} = require('../lib/utils/common')
module.exports = class Creator extends EventEmitter {
constructor(name, context) {
super()
this.name = name
this.context = context
this.run = this.run.bind(this)
}
async create(cliOptions = {}, preset = null) {
const { run, name, context } = this
if (cliOptions.preset) {
// awesome-test create foo --preset mobx
preset = await this.resolvePreset(cliOptions.preset, cliOptions.clone)
} else {
preset = await this.resolvePreset(defaults.presets.default, cliOptions.clone)
}
await clearConsole()
log(chalk.blue.bold(`Awesome-test CLI v${require('../package.json').version}`))
logWithSpinner(`✨`, `正在創建項目 ${chalk.yellow(context)}.`)
this.emit('creation', { event: 'creating' })
stopSpinner()
// 設置文件名,版本號等
const { pkgVers, pkgDes } = await inquirer.prompt([
{
name: 'pkgVers',
message: `請輸入項目版本號`,
default: '1.0.0',
},
{
name: 'pkgDes',
message: `請輸入項目簡介`,
default: 'project created by awesome-test-cli',
}
])
// 將下載的臨時文件拷貝到項目中
const pkgJson = await copyFile(preset.tmpdir, preset.targetDir)
const pkg = Object.assign(pkgJson, {
version: pkgVers,
description: pkgDes
})
// write package.json
log()
logWithSpinner('📄', `生成 ${chalk.yellow('package.json')} 等模板文件`)
await writeFileTree(context, {
'package.json': JSON.stringify(pkg, null, 2)
})
// 包管理
const packageManager = (
(hasYarn() ? 'yarn' : null) ||
(hasPnpm3OrLater() ? 'pnpm' : 'npm')
)
await writeFileTree(context, {
'README.md': generateReadme(pkg, packageManager)
})
const shouldInitGit = this.shouldInitGit(cliOptions)
if (shouldInitGit) {
logWithSpinner(`🗃`, `初始化Git倉庫`)
this.emit('creation', { event: 'git-init' })
await run('git init')
}
// 安裝依賴
stopSpinner()
log()
logWithSpinner(`⚙`, `安裝依賴`)
// log(`⚙ 安裝依賴中,請稍等...`)
await installDeps(context, packageManager, cliOptions.registry)
// commit initial state
let gitCommitFailed = false
if (shouldInitGit) {
await run('git add -A')
const msg = typeof cliOptions.git === 'string' ? cliOptions.git : 'init'
try {
await run('git', ['commit', '-m', msg])
} catch (e) {
gitCommitFailed = true
}
}
// log instructions
stopSpinner()
log()
log(`🎉 項目創建成功 ${chalk.yellow(name)}.`)
if (!cliOptions.skipGetStarted) {
log(
`👉 請按如下命令,開始愉快開發吧!\n\n` +
(this.context === process.cwd() ? `` : chalk.cyan(` ${chalk.gray('$')} cd ${name}\n`)) +
chalk.cyan(` ${chalk.gray('$')} ${packageManager === 'yarn' ? 'yarn start' : packageManager === 'pnpm' ? 'pnpm run start' : 'npm start'}`)
)
}
log()
this.emit('creation', { event: 'done' })
if (gitCommitFailed) {
warn(
`因您的git username或email配置不正確,無法為您初始化git commit,\n` +
`請稍后自行git commit。\n`
)
}
}
async resolvePreset (name, clone) {
let preset
logWithSpinner(`Fetching remote preset ${chalk.cyan(name)}...`)
this.emit('creation', { event: 'fetch-remote-preset' })
try {
preset = await loadRemotePreset(name, this.context, clone)
stopSpinner()
} catch (e) {
stopSpinner()
error(`Failed fetching remote preset ${chalk.cyan(name)}:`)
throw e
}
// 默認使用default參數
if (name === 'default' && !preset) {
preset = defaults.presets.default
}
if (!preset) {
error(`preset "${name}" not found.`)
exit(1)
}
return preset
}
run (command, args) {
if (!args) { [command, ...args] = command.split(/\s+/) }
return execa(command, args, { cwd: this.context })
}
shouldInitGit (cliOptions) {
if (!hasGit()) {
return false
}
// --git
if (cliOptions.forceGit) {
return true
}
// --no-git
if (cliOptions.git === false || cliOptions.git === 'false') {
return false
}
// default: true unless already in a git repo
return !hasProjectGit(this.context)
}
}
到這里,我們完成了項目的創建,接下來我們一起看看項目的模塊創建。
page 創建模塊
我們回到入口文件,添加page命令的處理
// 創建頁面命令 program .command('page <page-name>') .description('create a new page') .option('-f, --force', 'Overwrite target directory if it exists') .action((name, cmd) => { const options = cleanArgs(cmd) require('../lib/page')(name, options) })
與create類似,我們真正的邏輯處理放置在 lib/page 中,page中主要負責的內容和create類似,為創建模塊做一些准備,比如檢測項目中改模塊是否已經存在,如果存在,詢問是否覆蓋等操作。
const fs = require('fs-extra')
const path = require('path')
const chalk = require('chalk')
const inquirer = require('inquirer')
const PageCreator = require('./PageCreator')
const validFileName = require('valid-filename')
const {error, stopSpinner, exit, clearConsole} = require('../lib/utils/common')
/**
* 創建項目
* @param {*} pageName
* @param {*} options
*/
async function create (pageName, options) {
// 檢測文件名是否合規
const result = validFileName(pageName)
// 如果所輸入的不是合法npm包名,則退出
if (!result) {
console.error(chalk.red(`不合法的文件名: "${pageName}"`))
exit(1)
}
const cwd = options.cwd || process.cwd()
const pagePath = path.resolve(cwd, './src/pages', (pageName.charAt(0).toUpperCase() + pageName.slice(1).toLowerCase()))
const pkgJsonFile = path.resolve(cwd, 'package.json')
// 如果不存在package.json,說明不再根目錄,不能創建
if (!fs.existsSync(pkgJsonFile)) {
console.error(chalk.red(
'\n'+
'⚠️ 請確認您是否在項目根目錄下運行此命令\n'
))
return
}
// 如果page已經存在,詢問覆蓋還是取消
if (fs.existsSync(pagePath)) {
if (options.force) {
await fs.remove(pagePath)
} else {
await clearConsole()
const { action } = await inquirer.prompt([
{
name: 'action',
type: 'list',
message: `已存在 ${chalk.cyan(pageName)} 頁面,請選擇:`,
choices: [
{name: '覆蓋', value: true},
{name: '取消', value: false},
]
}
])
if (!action) {
return
} else {
console.log(`\nRemoving ${chalk.cyan(pagePath)}...`)
await fs.remove(pagePath)
}
}
}
// 前面完成准備工作,正式開始創建頁面
const pageCreator = new PageCreator(pageName, pagePath)
await pageCreator.create(options)
}
module.exports = (...args) => {
return create(...args).catch(err => {
stopSpinner(false)
error(err)
})
}
檢測完以后,通過以下代碼,執行page創建的邏輯
// 前面完成准備工作,正式開始創建頁面 const pageCreator = new PageCreator(pageName, pagePath) await pageCreator.create(options)
在 lib/pageCreator 文件中,我們通過讀取預先定義好的模板文件,生成目標文件,在這里使用了一個模板語言——nunjucks,我們將生成頁面的操作放置在 utils/generatePage 文件中處理,如下:
const chalk = require('chalk')
const path = require('path')
const fs = require('fs-extra')
const nunjucks = require('nunjucks')
const {
log,
error,
logWithSpinner,
stopSpinner,
} = require('./common')
const tempPath = path.resolve(__dirname, '../../temp')
const pageTempPath = path.resolve(tempPath, 'page.js')
const lessTempPath = path.resolve(tempPath, 'page.less')
const ioTempPath = path.resolve(tempPath, 'io.js')
const storeTempPath = path.resolve(tempPath, 'store.js')
async function generatePage(context, {lowerName, upperName}) {
logWithSpinner(`生成 ${chalk.yellow(`${upperName}/${upperName}.js`)}`)
const ioTemp = await fs.readFile(pageTempPath)
const ioContent = nunjucks.renderString(ioTemp.toString(), { lowerName, upperName })
await fs.writeFile(path.resolve(context, `./${upperName}.js`), ioContent, {flag: 'a'})
stopSpinner()
}
async function generateLess(context, {lowerName, upperName}) {
logWithSpinner(`生成 ${chalk.yellow(`${upperName}/${upperName}.less`)}`)
const ioTemp = await fs.readFile(lessTempPath)
const ioContent = nunjucks.renderString(ioTemp.toString(), { lowerName, upperName })
await fs.writeFile(path.resolve(context, `./${upperName}.less`), ioContent, {flag: 'a'})
stopSpinner()
}
async function generateIo(context, {lowerName, upperName}) {
logWithSpinner(`生成 ${chalk.yellow(`${upperName}/io.js`)}`)
const ioTemp = await fs.readFile(ioTempPath)
const ioContent = nunjucks.renderString(ioTemp.toString(), { lowerName, upperName })
await fs.writeFile(path.resolve(context, `./io.js`), ioContent, {flag: 'a'})
stopSpinner()
}
async function generateStore(context, {lowerName, upperName}) {
logWithSpinner(`生成 ${chalk.yellow(`${upperName}/store-${lowerName}.js`)}`)
const ioTemp = await fs.readFile(storeTempPath)
const ioContent = nunjucks.renderString(ioTemp.toString(), { lowerName, upperName })
await fs.writeFile(path.resolve(context, `./store-${lowerName}.js`), ioContent, {flag: 'a'})
stopSpinner()
}
module.exports = (context, nameObj) => {
Promise.all([
generateIo(context, nameObj),
generatePage(context, nameObj),
generateStore(context, nameObj),
generateLess(context, nameObj)
]).catch(err => {
stopSpinner(false)
error(err)
})
}
在PageCreator中引入該文件,並執行,給一些提示,會更友好。
const chalk = require('chalk')
const path = require('path')
const fs = require('fs-extra')
const nunjucks = require('nunjucks')
const {
log,
error,
logWithSpinner,
stopSpinner,
} = require('./common')
const tempPath = path.resolve(__dirname, '../../temp')
const pageTempPath = path.resolve(tempPath, 'page.js')
const lessTempPath = path.resolve(tempPath, 'page.less')
const ioTempPath = path.resolve(tempPath, 'io.js')
const storeTempPath = path.resolve(tempPath, 'store.js')
async function generatePage(context, {lowerName, upperName}) {
logWithSpinner(`生成 ${chalk.yellow(`${upperName}/${upperName}.js`)}`)
const ioTemp = await fs.readFile(pageTempPath)
const ioContent = nunjucks.renderString(ioTemp.toString(), { lowerName, upperName })
await fs.writeFile(path.resolve(context, `./${upperName}.js`), ioContent, {flag: 'a'})
stopSpinner()
}
async function generateLess(context, {lowerName, upperName}) {
logWithSpinner(`生成 ${chalk.yellow(`${upperName}/${upperName}.less`)}`)
const ioTemp = await fs.readFile(lessTempPath)
const ioContent = nunjucks.renderString(ioTemp.toString(), { lowerName, upperName })
await fs.writeFile(path.resolve(context, `./${upperName}.less`), ioContent, {flag: 'a'})
stopSpinner()
}
async function generateIo(context, {lowerName, upperName}) {
logWithSpinner(`生成 ${chalk.yellow(`${upperName}/io.js`)}`)
const ioTemp = await fs.readFile(ioTempPath)
const ioContent = nunjucks.renderString(ioTemp.toString(), { lowerName, upperName })
await fs.writeFile(path.resolve(context, `./io.js`), ioContent, {flag: 'a'})
stopSpinner()
}
async function generateStore(context, {lowerName, upperName}) {
logWithSpinner(`生成 ${chalk.yellow(`${upperName}/store-${lowerName}.js`)}`)
const ioTemp = await fs.readFile(storeTempPath)
const ioContent = nunjucks.renderString(ioTemp.toString(), { lowerName, upperName })
await fs.writeFile(path.resolve(context, `./store-${lowerName}.js`), ioContent, {flag: 'a'})
stopSpinner()
}
module.exports = (context, nameObj) => {
Promise.all([
generateIo(context, nameObj),
generatePage(context, nameObj),
generateStore(context, nameObj),
generateLess(context, nameObj)
]).catch(err => {
stopSpinner(false)
error(err)
})
}
好啦,到這里我們完成了腳手架的項目創建和模塊創建,相信大家也迫不及待要試試了吧,順着這個思路,我們可以將這個腳手架的功能更加豐富,后面更多更美好的創造我們一起去探索吧!
作者:合一大師
鏈接:https://juejin.im/post/5dd10fb76fb9a01fe303a5aa
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
