史上最贴心前端脚手架开发辅导


每当你发现本身和大多数人站在一边,就是时候停下来思考了。—— 马克·吐恩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
    }
  }
}复制代码

好啦,到这里咱们完成了脚手架的项目建立和模块建立,相信你们也火烧眉毛要试试了吧,顺着这个思路,咱们能够将这个脚手架的功能更加丰富,后面更多更美好的创造咱们一块儿去探索吧!

相关文章
相关标签/搜索