史上最貼心前端腳手架開發輔導


每當你發現本身和大多數人站在一邊,就是時候停下來思考了。—— 馬克·吐恩javascript


由於這部份內容稍有些複雜,因此講解以前先貼出github地址和視頻講解地址:java

項目源碼:github.com/Walker-Leee…
node

視頻講解,請搜索微信公衆號 《JavaScript全棧》react


相信你們在工做中都有以下經歷:git

  1. 開發新項目,不少邏輯好比:項目架構、接口請求、狀態管理、國際化、換膚等以前項目就已經存在,這時,咱們選擇「信手拈來」,ctrl + c,ctrl + v 二連,談笑間,新項目搭建完成,無非是要改改一些文件和包名;github

  2. 項目增長某個模塊時,複製一個已有模塊,改更名字,新的模塊就算建立成功了;npm

  3. 項目的規範要無時無刻不在同事耳邊說起,就算有規範文檔,你還須要苦口婆心。json

使用複製粘貼有如下缺點:promise

  1. 重複性工做,繁瑣並且浪費時間微信

  2. copy過來的模板容易存在無關的代碼

  3. 項目中有不少須要配置的地方,容易忽略一些配置點

  4. 人工操做永遠都有可能犯錯,建新項目時,總要花時間去排錯

  5. 框架也會不斷迭代,人工建項目不知道最新版本號是多少,使用的依賴都是什麼版本,很容易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 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
    }
  }
}複製代碼

好啦,到這裏咱們完成了腳手架的項目建立和模塊建立,相信你們也火燒眉毛要試試了吧,順着這個思路,咱們能夠將這個腳手架的功能更加豐富,後面更多更美好的創造咱們一塊兒去探索吧!

相關文章
相關標籤/搜索