前端工程化之项目脚手架

3,597 阅读4分钟

scaffold

在建筑领域,脚手架是为了保证各施工过程顺利进行而搭设的工作平台。在软件开发领域,如果把搭建项目想象成建造大型建筑的话,脚手架就是为了方便大家快速进入业务逻辑的开发,一个好的脚手架能显著提升工程效率,例如三大前端框架都提供了自己的脚手架工具:

上述工具虽好,但相信很多公司为了满足自身业务需要,也造了不少自己的轮子,约定使用自己的那一套配置,如果没有脚手架,就只能把原项目代码复制过来,删除无用的逻辑,只保留基础能力,这个过程琐碎且耗时。

因此,在这种情况下,就需要定制自己的开发模板,搭建一套属于自己的前端脚手架了。

预备知识

要写一个脚手架首先要掌握 node.js 的各种 API,然后还要充分利用别人写好的一些类库,例如下面就是必备的:

  • commander : TJ 大神的又一神作,脚手架必备工具,能够帮我们解析命令行的各种参数,通过回调完成具体逻辑实现。

  • inquirer:强大的交互式命令行工具,用户可以在命令行进行单选或多选,也可以用 prompts 这个库,用法和效果都是类似的。

  • chalk :能够在命令行中给文本上色,从而突出重点,例如 error 用红色,warning 用黄色,success 用绿色,视觉效果非常好。

  • metalsmith :静态网站生成器,可以读取指定文件夹下面的模板文件,经过一系列的插件处理,把文件输出到新的目录下。

掌握了上面的工具之后,就可以写一个自己的脚手架了。我们后端采用了 feathersjs 库,但是不太喜欢它提供的脚手架,于是自己定制了一个,效果如下:

feat-cli

制作脚手架

制作脚手架整个过程分如下 5 个步骤(简称 cpcar):

  1. cli 项目初始化
  2. parse 命令行参数
  3. clone 脚手架模板
  4. ask 用户项目配置
  5. render 项目文件

接下来逐一介绍:

cli 项目初始化

首先创建空目录并进行初始化:

$ mkdir feathers-cli
$ cd feathers-cli
$ npm init -y

然后用 vscode 打开,为 package.json 添加 bin 字段如下:

{
  "name": "feathers-cli",
  "main": "index.js",
  "bin": {
    "feat": "./bin/feat.js"
  }
}

然后创建 bin 文件夹,在里面新建一个 feat.js 文件,内容是:

#! /usr/bin/env node
console.log('My custom feathers scaffold')

然后在根目录下执行:

$ npm link
$ feat
My custom feathers scaffold

到这里,项目初始化就完成了。此时,npm 会在全局下创建一个 feat 可执行文件,它是一个软链接,指向 bin/feat.js,所以后面每次修改内容,都会输出最新的结果,不需要重新执行 npm link 命令。

parse 命令行参数

接下来需要利用 commander 来解析命令行参数,例如当用户输入 feat --help 的时候能够输出帮助提示,首先安装依赖包:

$ npm i commander

然后修改 bin/feat.js 内容为:

#! /usr/bin/env node
const program = require('commander')
program.parse(process.argv)

此时输入命令就能看到提示消息了:

$ feat --help
Usage: feat [options]

Options:
  -h, --help  display help for command

这是 commander 默认帮我们添加的帮助信息,目前还没有配置任何的命令,接下来完善代码如下:

#! /usr/bin/env node
const program = require('commander')
const pkg = require('../package.json')

program
  .command('create <app-name>')
  .description('create a new project powered by feathers-cli')
  .option('-f, --force', 'override')
  .action((name, cmd) => {
    console.log('name', name)
    console.log('cmd.options', cmd.options)
    console.log('cmd.args', cmd.args)
  })

program.version(pkg.version).usage(`<command> [options]`)

program.parse(process.argv)

此时输出内容就丰富多了:

$ feat --help            
Usage: feat <command> [options]

Options:
  -V, --version                output the version number
  -h, --help                   display help for command

Commands:
  create [options] <app-name>  create a new project powered by feathers-cli
  help [command]               display help for command

输入 feat create xxx 的时候可以在回调里面获取到相关参数:

name hello-world
cmd.options [
  Option {
    flags: '-f, --force',
    required: false,
    optional: false,
    variadic: false,
    mandatory: false,
    short: '-f',
    long: '--force',
    negate: false,
    description: 'override',
    defaultValue: undefined
  }
]
cmd.args [ 'hello-world' ]

接下来就是完善回调函数里面的逻辑了。

clone 脚手架模板

我们根据业务需求自己定义了一套模板 feathers-template-default,用脚手架创建项目的本质上就是把这套模板下载下来,然后再根据用户的喜好,按照模板生成不同结构的工程文件而已。

一般来讲,模板都是放到用户根目录下的一个隐藏文件中的,我们定义的目录名为 ~/.feat-templates,首次通过 feat create xxx 的时候通过 git clone 把这套模板下载到上面定义的目录中,后面再创建项目只需 git pull 更新即可,所以接下来就是实现仓库的下载和更新方法了,其实就是利用 spawn 对 git 命令进行封装:

git clone 的封装

// 克隆仓库
function clone(repo, opts) {
  return new Promise((resolve, reject) => {
    const args = ['clone']
    args.push(repo)
    args.push(opts.targetPath)
    const proc = spawn('git', args, {cwd: opts.workdir})
    proc.stdout.pipe(process.stdout)
    proc.stderr.pipe(process.stderr)
    proc.on('close', (status) => {
      if (status == 0) return resolve()
      reject(new Error(`'git clone' failed with status ${status}\n`))
    })
  })
}

git pull 的封装

async function pull(cwd) {
  return new Promise((resolve, reject) => {
    const process = spawn('git', ['pull'], { cwd })
    process.on('close', (status) => {
      if (status == 0) return resolve()
      reject(new Error(`'git pull' failed with status ${status}`))
    })
  })
}

ask 用户项目配置

有了模板,项目主体结构就定下来了,接下来就是定义一些问题,让用户自己选择项目配置:

const questions = {
  projectName: {
    type: 'text',
    message: '项目名',
    validate: (answer) => (answer.trim() ? true : '项目名不能为空'),
    initial: 'my-project',
  },
  projectDescription: {
    type: 'text',
    message: '项目描述',
    initial: 'My Awesome Project!',
  },
  needCacher: {
    type: 'toggle',
    message: '需要缓存吗?',
    initial: true,
    active: '是',
    inactive: '否',
  },
  cacher: {
    type: 'select',
    message: '请选择缓存方案',
    choices: [
      { title: 'Memory', value: 'Memory' },
      { title: 'Redis', value: 'Redis' },
    ],
    when(answers) {
      return answers.needCacher
    },
    initial: 1,
  },
  needWebsocket: {
    type: 'toggle',
    message: '需要 websocket 吗?',
    initial: false,
    active: '是',
    inactive: '否',
  },
  needLint: {
    type: 'toggle',
    message: '需要 ESLint 吗?',
    initial: true,
    active: '是',
    inactive: '否',
  },
  needJest: {
    type: 'toggle',
    message: '需要 Jest 吗?',
    initial: true,
    active: '是',
    inactive: '否',
  },
}

然后通过一个循环进行遍历,挨个询问:

async function ask(questions, data) {
  const names = Object.keys(questions)
  for (let i = 0; i < names.length; i++) {
    const name = names[i]
    const value = questions[name]
    // 拿到问题,然后组装成 Inquirer 或 prompts 所需要的格式
 	const question = { /* 省略组装代码 */ } 
    const answer = await prompts(question)
    Object.assign(data, answer)
  }
}

render 项目文件

模板引擎有很多,例如 ejshandlebars 等都可以用,在这里以 handlebars 为例,先定义两个帮助函数:

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)
})

然后通过 metalsmith 插件进行渲染:

Metalsmith(process.cwd())
  .metadata({
    projectName: '项目名称',
    projectDescription: '项目描述',
    // 这里的数据实际上是上一步 ask 获得的
  })
  .source('~/.feat-templates/feathers-template-default/templates/app') // 模板文件位置
  .destination(process.cwd()) // 项目位置
  .use(msPlugins.filterFiles(options.filters)) // 过滤文件
  .use(msPlugins.renderTemplateFiles()) // 渲染模板
  .build((err) => {
    if (err) {
      log(`Metalsmith build error: ${err}`)
    }
  })

项目地址:github.com/jsonfit