每當你發現本身和大多數人站在一邊,就是時候停下來思考了。—— 馬克·吐恩
由於這部份內容稍有些複雜,因此講解以前先貼出github地址和視頻講解地址:
項目源碼:https://github.com/Walker-Leee/awesome-test-cli
視頻講解,請搜索微信公衆號 《JavaScript全棧》node
相信你們在工做中都有以下經歷:react
使用複製粘貼有如下缺點:git
承受過以上一些痛苦的同窗應該很多,怎麼去解決這些問題呢?我以爲,腳手架可以規避不少認爲操做的問題,由於腳手架可以根據你事先約定的規範,建立項目,定義新的模塊,打包,部署等等都可以在一個命令敲擊後搞定,提高效率的同時下降了入職員工的培訓成本,因此,我推薦你們考慮考慮爲團隊打造一個腳手架!github
庫名 | 描述 |
---|---|
commander | 處理控制檯命令 |
chalk | 五彩斑斕的控制檯 |
semver | 版本檢測提示 |
fs-extra | 更友好的fs操做 |
inquirer | 控制檯詢問 |
execa | 執行終端命令 |
download-git-repo | git遠程倉庫拉取 |
腳手架能夠爲咱們作不少事情,好比項目的建立、項目模塊的新增、項目打包、項目統一測試、項目發佈等,我先與你們聊聊最初始的功能:項目建立。npm
上圖向你們展現了建立項目和項目中建立模塊的腳手架大體工做流程,下圖更詳細描述了基於模板建立的過程:json
思路很簡單,接下來咱們就經過代碼示例,爲你們詳細講解。promise
項目結構如圖微信
在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
處理控制檯命令,根據不一樣參數處理不一樣的邏輯.app
// 開始處理命令 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) })
將真正的處理邏輯放在 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
,該文件中咱們主要進行的操做有:
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命令的處理
// 建立頁面命令 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 EventEmitter = require('events') const fs = require('fs-extra') const generatePage = require('./utils/generatePage') const { log, error, logWithSpinner, clearConsole, stopSpinner, exit } = require('../lib/utils/common') module.exports = class PageCreator extends EventEmitter { constructor(name, context) { super() this.name = name this.context = context } async create(cliOptions = {}) { const fileNameObj = this.getName() const {context} = this await clearConsole() log(chalk.blue.bold(`Awesome-test CLI v${require('../package.json').version}`)) logWithSpinner(`✨`, `正在建立頁面...`) // 建立文件夾 await fs.mkdir(context, { recursive: true }) this.emit('creation', { event: 'creating' }) stopSpinner() console.log(context) await generatePage(context, fileNameObj) } getName() { const originName = this.name const tailName = originName.slice(1) const upperName = originName.charAt(0).toUpperCase() + tailName const lowerName = originName.charAt(0).toLowerCase() + tailName return { upperName, lowerName } } }
好啦,到這裏咱們完成了腳手架的項目建立和模塊建立,相信你們也火燒眉毛要試試了吧,順着這個思路,咱們能夠將這個腳手架的功能更加豐富,後面更多更美好的創造咱們一塊兒去探索吧!