阅读 690

前端工程化(6):搭一个集成了三大 UI 库的脚手架工具

距离上次更文有10个月的时间了,其实平时有总结很多技术点,但在掘金上只想发表关于前端工程化系列方面的文章,而又由于这段时间一直没有可落地的工程化项目(就是懒了🤦!),所以也不好在没有自己切身试验的情况下撰写博文。

OK,写这篇文章的契机呢,是因为我即将要做一个超级超级超级大项目,前期希望把前端基建的一些东西给搭建好,所以想着做一个脚手架工具,将基建的东西集成到模板中去,达到一个规范和提效的目的。其实这篇文章的重点并不是为了教大伙如何编写一个脚手架(掘金上关于这方面的教程太多),而是为了向你们安利我写的脚手架工具——pandly-cli😏。

正如标题所说,pandly-cli最大的特色就是集成了Element UIView DesignAnt design三大主流UI库供用户选择,并且还支持全局和按需的引入方式。当然,pandly-cli中不止这一个功能,还集成了很多提效的功能,文章后面会详细介绍。按照惯例,我还是先简单阐述下我写这个脚手架的心路历程。

脚手架

整体思路还是借鉴了vue-cli2的搭建模式(为什么不借鉴vue-cli3的?太复杂了!),然后自己做了点修改。整体目录结构如下:

 |-pandly-cli
 | |-bin  # 命令执行文件
 | | |-pandly  # 主命令
 | | |-pandly-create  # 创建命令
 | |-lib  # 工具模块
 | | |-ask.js  # 交互询问
 | | |-check-version.js  # 检查脚手架版本
 | | |-complete.js  # 命令执行完成后的操作
 | | |-generate.js  # 模板渲染
 | |-package.json
复制代码

相比较vue-cli2做了很大的简化,其中最大的不同是,vue-cli2是先通过脚手架工具将模板下载下来,然后再根据交互的输入渲染模板。我修改为,先收集交互的输入,再将模板下载下来去渲染。

使用 commander 解析命令

首先,我们使用npm init初始化一个npm工程,在这个工程的package.jsonbin字段中定义命令名和对应的可执行文件:

{
  "name": "pandly-cli",
  "version": "1.0.0",
  "description": "An awesome CLI for scaffolding Vue.js projects",
  "preferGlobal": true,
  "bin": {
    "pandly": "bin/pandly",
    "pandly-create": "bin/pandly-create"
  },
  ...
复制代码

bin/pandly文件中处理用户输入的命令:

#!/usr/bin/env node

const program = require('commander')

program
  .version(require('../package').version)
  .usage('<command> [options]')
  .command('create', '用模板创建一个新的项目')
  .parse(process.argv)
复制代码

这里有两点需要注意:

  1. 文件的头部一定要加上一行#!/usr/bin/env node代码使其成为一个可执行文件;
  2. commander中如果定义了子命令而没有显示调用action()时,commander将尝试在入口脚本的目录中搜索文件名为[command]-[subcommand]的可执行文件执行。比如.command('create')命令则会找到同文件夹下的pandly-create,所以会调用文件bin/pandly-create并执行。

pandly-cli只有一个命令pandly create xxx

使用 inquirer 实现命令行交互

由于询问的条目比较多,所以把命令行交互的逻辑单独放在了lib/ask.js文件中:

const { prompt } = require('inquirer')

const questions = [
  ...,
  {
    name: 'UI',
    type: 'list',
    message: 'Pick a UI library to install',
    choices: [{
      name: 'Element UI',
      value: 'element-ui',
      short: 'Element'
    }, {
      name: 'View Design',
      value: 'view-design',
      short: 'View'
    }, {
      name: 'Ant Design',
      value: 'ant-design-vue',
      short: 'Ant'
    }]
  },
  ...
]

module.exports = function ask () {
  return prompt(questions).then(answers => {
    return answers
  })
}
复制代码

lib/ask.js最后抛出一个promise对象,返回收集到的用户的输入。

使用 download-git-repo 下载模板

收集到用户的输入以后,开始下载模板,在bin/pandly-create中:

#!/usr/bin/env node

const program = require('commander')
const chalk = require('chalk')
const path = require('path')
const home = require('user-home')
const exists = require('fs').existsSync
const inquirer = require('inquirer')
const ora = require('ora')
const rm = require('rimraf').sync
const download = require('download-git-repo')

const checkVersion = require('../lib/check-version')
const generate = require('../lib/generate')
const ask = require('../lib/ask')

program
  .usage('[project-name]')
  .parse(process.argv)

// 创建项目的目录名
const rawName = program.args[0]
// true则表示没写或者'.',即在当前目录下构建
const inPlace = !rawName || rawName === '.'
// 如果是在当前目录下构建,则创建项目名为当前目录名;如果不是,创建项目名则为 rawName
const projectName = inPlace ? path.relative('../', process.cwd()) : rawName
// 创建项目目录的绝对路径
const projectPath = path.resolve(projectName || '.')
// 远程模板下载到本地的路径
const downloadPath = path.join(home, '.vue-pro-template')

const spinner = ora()

process.on('exit', () => {
  console.log()
})

// 在当前目录下创建或者创建的目录名已经存在,则进行询问,否则直接执行 run 函数
if (inPlace || exists(projectPath)) {
  inquirer.prompt([{
    type: 'confirm',
    message: inPlace ? 'Generate project in current directory?' : 'Target directory exists. Do you want to replace it?',
    name: 'ok'
  }]).then(answers => {
    if (answers.ok) {
      console.log(chalk.yellow('Deleting old project ...'))
      if (exists(projectPath)) rm(projectPath)
      run()
    }
  }).catch(err => console.log(chalk.red(err.message.trim())))
} else {
  run()
}

function run() {
  // 先收集用户的输入,再下载模板
  ask().then(answers => {
    if (exists(downloadPath)) rm(downloadPath)
    checkVersion(() => {
      // 模板的 github 地址为 https://github.com/pandly/vue-pro-template
      const officalTemplate = 'pandly/vue-pro-template'
      downloadAndGenerate(officalTemplate, answers)
    })
  })
}

function downloadAndGenerate (officialTemplate, answers) {
  spinner.start('Downloading template ...')
  download(officialTemplate, downloadPath, { clone: false }, err => {
    if (err) {
      spinner.fail('Failed to download repo ' + officialTemplate + ': ' + err.message.trim())
    } else {
      spinner.succeed('Successful download template!')
      generate(projectName, downloadPath, projectPath, answers)
    }
  })
}
复制代码

由于模板会不定期的更新,为了保证能使用到最新的模板,所以每次使用脚手架创建项目时,都会先把本地的老模板删除,然后再重新从github上下载最新的模板。

使用 metalsmith + handlebars 处理模板

pandly-cli提供的模板不仅仅是一个纯粹的文件,而是可以通过用户输入的参数进行编译,得到不同的目标文件。所以先通过metalsmith来获取模板中的每个文件,然后使用handlebars对每个文件进行编译。在lib/generate.js中:

const Metalsmith = require('metalsmith')
const Handlebars = require('handlebars')
const exists = require('fs').existsSync
const path = require('path')
const rm = require('rimraf').sync
const ora = require('ora')

const complete = require('./complete')

const spinner = ora()

// register handlebars helper
Handlebars.registerHelper('if_eq', function (a, b, opts) {
  return a === b
    ? opts.fn(this)
    : opts.inverse(this)
})

Handlebars.registerHelper('unless_eq', function (a, b, opts) {
  return a === b
    ? opts.inverse(this)
    : opts.fn(this)
})

module.exports = function generate (name, src, dest, answers) {
  spinner.start('Generating template ...')
  if (exists(dest)) rm(dest)
  Metalsmith(path.join(src, 'template'))
    .metadata(answers)
    .clean(false)
    .source('.')
    .destination(dest)
    .use((files, metalsmith, done) => {
      const metadata = metalsmith.metadata()
      const keys = Object.keys(files)
      keys.forEach(fileName => {
        const str = files[fileName].contents.toString()
        if (!/{{([^{}]+)}}/g.test(str)) {
          return
        }
        files[fileName].contents = Buffer.from(Handlebars.compile(str)(metadata))
      })
      done()
    })
    .build((err, files) => {
      if (err) {
        spinner.fail(`Faild to generate template: ${err.message.trim()}`)
      } else {
        new Promise((resolve, reject) => {
          setTimeout(() => {
            spinner.succeed('Successful generated template!')
            resolve()
          }, 3000)
        }).then(() => {
          const data = {...answers, ...{
            destDirName: name,
            inPlace: dest === process.cwd()
          }}
          complete(data)
        })
      }
    })
}
复制代码

模板中都是以{{}}占位符的形式来进行参数的替换和条件编译,比如在package.json中:

{
  "name": "{{ name }}",
  "version": "1.0.0",
  "description": "{{ description }}",
  "author": "{{ author }}",
  ...
  "dependencies": {
    ...
    {{#if_eq UI "element-ui"}}
    "element-ui": "^2.13.1"
    {{/if_eq}}
    {{#if_eq UI "view-design"}}
    "view-design": "^4.2.0"
    {{/if_eq}}
    {{#if_eq UI "ant-design-vue"}}
    "ant-design-vue": "^1.5.3"
    {{/if_eq}}
  },
  ...
}
复制代码

使用 child_process 创建进程执行命令

模板编译成功以后,如果用户选择了初始化git本地仓库或者使用npm来安装项目,则需要另开启一个进程来执行这些命令。在lib/complete.js中:

const spawn = require('child_process').spawn
const chalk = require('chalk')
const path = require('path')

module.exports = function complete(data) {
  const cwd = path.join(process.cwd(), data.inPlace ? '' : data.destDirName)
  if (data.git) {
    initGit(cwd).then(() => {
      if (data.autoInstall) {
        installDependencies(cwd, data.autoInstall).then(() => {
          printMessage(data)
        }).catch(e => {
          console.log(chalk.red('Error:'), e)
        })
      } else {
        printMessage(data)
      }
    }).catch(e => {
      console.log(chalk.red('Error:'), e)
    })
  } else if (data.autoInstall) {
    installDependencies(cwd, data.autoInstall).then(() => {
      printMessage(data)
    }).catch(e => {
      console.log(chalk.red('Error:'), e)
    })
  } else {
    printMessage(data)
  }
}

function initGit (cwd, executable = 'git') {
  return runCommand(executable, ['init'], {
    cwd,
  })
}

function installDependencies(cwd, executable = 'npm') {
  console.log(`\n# ${chalk.green('Installing project dependencies ...')}`)
  console.log('# ========================\n')
  return runCommand(executable, ['install'], {
    cwd,
  })
}

function runCommand(cmd, args, options) {
  return new Promise((resolve, reject) => {
    const spwan = spawn(
      cmd,
      args,
      Object.assign(
        {
          cwd: process.cwd(),
          stdio: 'inherit',
          shell: true,
        },
        options
      )
    )

    spwan.on('exit', () => {
      resolve()
    })
  })
}

function printMessage(data) {
  const message = `
# ${chalk.green('Project initialization finished!')}
# ========================

To get started:

  ${chalk.yellow(
    `${data.inPlace ? '' : `cd ${data.destDirName}\n  `}${installMsg(data)}npm run serve`
  )}
`
  console.log(message)
}

function installMsg(data) {
  return !data.autoInstall ? 'npm install\n  ' : ''
}
复制代码

整个脚手架搭建的过程大致就以上步骤,不难,只要掌握核心的几个库就能轻松的搭建起自己的脚手架。好了,接下来我要开始介绍我这个模板了。

模板

模板是基于vue-cli3的二次集成,包含了以下内容:

1. vue 全家桶

vue + vue-router + vuex

2. 初始化 git 本地仓库

3. 可选择三大 UI 库(ElementUI、ViewDesign、AntDesign)安装,并支持全局引入和按需引入

4. 直接生成与 UI 库对应的头部和导航栏布局的后台管理模板,只需要专注页面内容的编写

5. router 模块解耦方案,兼并导航栏的渲染

|-src
| |-router
| | |-modules
| | | |-index.js
| | | |-navigation1.router.js
| | |-index.js
复制代码
  • modules/navigation1.js:路由模块按功能划分,比如navigation1.router.js中存放关于navigation1模块的路由;
  • modules/index.js:使用require.context实现了modules中路由模块的自动合并,无需手动合并;
  • index.jsvue-router的相关配置。

最后导出的路由表将渲染出导航栏,无需再另外编写导航栏数据。

6. 优雅的 axios 请求方案

详情请参考:前端工程化(3):在项目中优雅的设计基于Axios的请求方案

前端缓存机制没有添加在模板中,需要的同学可以自行添加

7. vue + js 的代码风格校验

eslint模式默认是standard,同时可以自行选择vue代码风格的校验程度:

8. 只依赖webpack-dev-server的本地mock-server方案;

老方案:express + nodemon

新方案:webpack-dev-server + mocker-api

新方案的mock-server完全基于webpack-dev-server来实现,无需在项目中安装express另起服务。所以在启动前端服务的同时,mock-server就会自动启动。启动的同时,借助mocker-api,模拟api代理,并支持热mocker文件替换。

9. 基于 http-proxy-middleware 的多环境联调方案

详情请参考:前端工程化(4):http-proxy-middleware在多环境下的代理应用

10. 基于 angular 团队的约定式 git 提交规范

详情请参考:前端工程化(2):快速搭建基于angular团队代码提交规范的工作流

11. 打包分析

执行npm run build --report来进行打包分析

待完善

  1. 单元测试
  2. 支持ES7新语法
  3. 构建速度优化
  4. 首屏渲染优化

最后

本文并不是一篇技术干货文,只是提供了一个基于脚手架的项目解决方案,希望能对同学们有所帮助!

使用方式:

  • npm install pandly-cli -g

  • pandly create xxx

github地址:github.com/pandly/pand…