搭建适用于公司内部的脚手架

5,421 阅读7分钟

前言

公司项目多了,且后续会增加更多项目,为了避免每次创建项目都是重复的 copy,这里可以自己写一个适合公司的脚手架,就跟 vue-clicreate-react-app 类似。

简单描述下原理:首先你需要准备一个模板,这个模板可以存储在公司的 git 上,然后根据用户选择决定采用哪个分支。比如我们就有 h5模板web模板 两个分支。

然后这些模板会有一些我们自定义的特殊字符,让用户可以根据输入的内容替换。比如我在模板那边里有定义了 $$PROJECT_NAME$$ 这个特殊字符,通过命令行交互让用户输入创建的项目名: test-project ,最后我就通过 node 去遍历模板里的文件,找到这个字符,将 $$PROJECT_NAME$$ 替换成 test-project 即可。根据公司需求自己事先定义好一些特殊变量即可,主要用到的就是下面几个库。

package.json 里的 bin 字段

用于执行 可执行文件 ,当使用 npm 或 yarn 命令安装时,如果发现包里有该字段,那么会在 node_modules 目录下的 .bin 目录中复制 bin 字段链接的可执行文件,我们在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。


bin 文件里的 #! 含义

#! 符号的名称叫 Shebang,用于指定脚本的解释程序。

/usr/bin/env node 表示 系统可以在 PATH 目录中查找 node 程序

如果报错,说明没有在 PATH 中找到 node


npm link

npm link (组件库里用来在本地调试用的)是将整个目录链接到全局node_modules 中,如果有 bin 那么则会生成全局的可执行命令

npm link xxx (本地测试项目里使用), xxx 为 那个库的 package.jsonname。 是让你在本地测试项目中可以使用 xxx

  1. 库在开发迭代,不适合发布到线上进行调试。

  2. 可以帮助我们模拟包安装后的状态,它会在系统中做一个快捷方式映射,让本地的包就好像 install 过一样,可以直接使用。

  3. npm unlink 解除链接


commander —— 命令行指令配置

实现脚手架命令的配置, commander 中文文档

// 引入 program
const { program } = require('commander')

// 设置 program 可以输入的选项
// 每个选项可以定义一个短选项名称(-后面接单个字符)和一个长选项名称(--后面接一个或多个单词),使用逗号、空格或|分隔。
// 长选项名称可以作为 .opts() 的对象key
program.option('-p, --port <count>') // 必选参数使用 <> 表示,可选参数使用 [] 表示

// 解析后的选项可以通过Command对象上的.opts()方法获取,同时会被传递给命令处理函数。
const options = program.opts()

program.command('create <name>').action((fileName) => {
  console.log({ fileName, options })
})

program.parse(process.argv) // 执行命令

chalk —— 命令行美化工具

可以美化我们在命令行中输出内容的样式,例如实现多种颜色,花里胡哨的命令行提示等。chalk 文档

安装 chalk 时一定要注意安装 4.x 版本(小包使用的是 4.0.0),否则会因为版本过高,爆出错误。

const chalk = require('chalk')
console.log(`hello ${chalk.blue('world')}`)
console.log(chalk.blue.bgRed.bold('Hello world!'))

inquirer —— 命令行交互工具

支持 input, number, confirm, list, rawlist, expand, checkbox, password,editor 等多种交互方式。 inquirer 文档

const inquirer = require('inquirer')

inquirer
  .prompt([
    /* 输入问题 */
    {
      name: 'question1',
      type: 'checkbox',
      message: '爸爸的爸爸叫什么?',
      choices: [
        {
          name: '爸爸',
          checked: true,
        },
        {
          name: '爷爷',
        },
      ],
    },
    {
      name: 'question2',
      type: 'list',
      message: `确定要创建${fileName}的文件夹吗`,
      choices: [
        {
          name: '确定',
          checked: true,
        },
        {
          name: '否',
        },
      ],
    },
  ])
  .then((answers) => {
    // Use user feedback for... whatever!!
    console.log({ answers })
  })
  .catch((error) => {
    if (error.isTtyError) {
      // Prompt couldn't be rendered in the current environment
    } else {
      // Something else went wrong
    }
  })

ora —— 命令行 loading 效果

现在的最新版本为 es6 模块,需要用以前的版本,例如: V5.4.1 才是 cjs 模块 : ora 文档

const ora = require('ora')

const spinner = ora('Loading unicorns').start()

setTimeout(() => {
  spinner.color = 'yellow'
  spinner.text = 'Loading rainbows'
}, 1000)

spinner.succeed()

fs-extra —— 更友好的文件操作

是系统 fs 模块的扩展,提供了更多便利的 API,并继承了 fs 模块的 API。比 fs 使用起来更加友好。 fs-extra 文档


download-git-repo —— 命令行下载工具

从 git 中拉取仓库,提供了 download 方法,该方法接收 4 个参数。 download-git-repo 文档

/**
 * download-git-repo 源码
 * Download `repo` to `dest` and callback `fn(err)`.
 *
 * @param {String} repo 仓库地址
 * @param {String} dest 仓库下载后存放路径
 * @param {Object} opts 配置参数
 * @param {Function} fn 回调函数
 */

function download(repo, dest, opts, fn) {}

【注】 download-git-repo 不支持 Promise


项目源码举例

最近一直在忙公司业务,基本是有朋友私我要源码,我会发给他,现在想了想,还是将源码整理下发出来。

// package.json
{
  "name": "mother-cli",
  "version": "1.0.2",
  "description": "",
  "scripts": {
    "cli": "node ./bin/index.js"
  },

  // 这里的字段在上面有说到,这里再说下,当你弄到私库上了之后
  // 你再拉下来,那么 mother-cli 就会变成你的可执行命令
  "bin": {
    "mother-cli": "./bin/index.js"
  },
  "keywords": [],
  "author": "PNM",
  "license": "ISC",

  // 这些包上面一一都有介绍,直接安装
  "dependencies": {
    "axios": "^1.3.4",
    "commander": "^9.3.0",
    "download-git-repo": "^3.0.2",
    "fs-extra": "^10.1.0",
    "inquirer": "^8.2.4",
    "ora": "^5.4.1"
  }
}

我项目用的是 node 写的,纯 js 代码,功能简单,大家应该看一下就懂。

【注】各个公司项目不一样,这里只以本人公司项目为视角出发。

// 此文件为 ./bin/index.js 文件,为 mother-cli 的路径,可以看到第一行为 #! 开头,在前面有提到这个的作用
#! /usr/bin/env node

// 模板路径,# 后面跟着 分支名,由用户选择
const REPO_PATH = 'http://xxx/pengnima/empty-web.git#'

const { program } = require('commander')

const { checkName } = require('./check') // 检测目录名的方法
const { onPrompt } = require('./inquirer') // 封装了 命令行交互工具 的一个方法
const { pull } = require('./pull') // 拉取git项目的方法
const { init, readDirs } = require('./replace') // 因为模板有些内容需要替换,这里就是做替换功能的
const shell = require('./shell') // 内部封装了,执行一些 shell 脚本的功能
const axios = require('axios') // 老搭档了
const fse = require('fs-extra') // 文件操作

/**
 * todo: 主要做的就是 4 点
 *
 * 1. 检测当前目录是否有同名文件夹
 *
 * 2. 检测用户选项,例如:是否输入了 -p 选项 (记录 port)
 *
 * 3. 拉取 git 项目
 *
 * 4. 根据不同参数改写文件(根据公司项目情况而定)
 */

// program 的功能前面介绍过了,这里不做过多介绍
program.name('mother-cli').usage(`<command>`)
program.version('0.0.1')
program
  .command('init')
  .description('创建项目')
  .action(async () => {
    // 与用户交互,看用户是要选择哪种模板,默认为 web模板,这里的就对应了 git 上的分支
    onPrompt('list', {
      message: '请选择项目类型:',
      choices: [
        {
          name: 'web',
          checked: true,
        },
        {
          name: 'h5',
        },
      ],
      callback: async (type) => {
        //...省略一些交互代码
        let name, dirName
        while (!name) {
          ;({ name, dirName } = await onPrompt('input', {
            message: '请输入系统名:',
            // 我这里的系统名及项目的目录名,所以这里需要轮询检测是否有重名的目录
            callback: (_name) => checkName(_name, type),
          }))
        }

        // 通过上面的代码,dirName 和 name 就有值了,一个为目录地址,一个为目录名

        console.log(`即将创建 ${name}-${type} 项目`)

        // 拉取项目代码 type 为分支名,即选择的项目类型 web 或 h5
        await pull('direct:' + REPO_PATH + type, dirName, { clone: true })

        // 拉取代码之后,通过 fs 改写一些模板中的信息
        init({ name })
        readDirs(dirName)

        console.log('完成...')

        // 执行 git init 命令
        await shell('git init', dirName)
      },
    })
  })

// 执行命令
program.parse(process.argv)

上面为主要内容,然后就是一些封装的文件:

// ./inquirer 文件中,封装了 inquirer 交互工具的 prompt 方法
const inquirer = require('inquirer')

const onPrompt = (type, options) => {
  const { callback, ..._options } = options
  return inquirer
    .prompt([
      {
        name: 'question',
        type,
        ..._options,
      },
    ])
    .then((answers) => {
      return callback ? callback(answers.question) : answers.question
    })
    .catch((err) => {
      return undefined
    })
}

module.exports = {
  onPrompt,
}
// ./check 文件
const fse = require('fs-extra')
const path = require('path')
const { onPrompt } = require('./inquirer')

// 校验目录名
async function checkName(name, type) {
  const dirName = path.join(process.cwd(), name + '-' + type)

  if (fse.existsSync(dirName)) {
    return onPrompt('input', {
      message: '该目录已存在,请输入新的目录名:',
      callback: (_name) => checkName(_name, type),
    })
  } else {
    return {
      name,
      dirName,
    }
  }
}

module.exports = { checkName }
// ./pull 从git中拉取模板
const downGitRepo = require('download-git-repo')
const util = require('util')
const ora = require('ora')

// 使用 promisify 将 downGitRepo 转换成 Promise 格式
const _downloadPromise = util.promisify(downGitRepo)

function _sleep(n) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve()
    }, n)
  })
}

const pull = async (repo, dest, opts) => {
  const spinner = ora('正在创建...').start()
  try {
    await _downloadPromise(repo, dest, opts)
    spinner.succeed('创建成功')
  } catch (error) {
    spinner.fail('拉取失败,1s 后重试')

    await _sleep(1000)
    return pull(repo, dest, opts)
  }
}

module.exports = {
  pull,
}
// ./replace 文件, 替换模板中的一些特殊标识符
// 简单介绍下,就是我在模板中写了一些 $__CLI_PATH__$ 之类的标识符,
// 然后我将模板拉取下来之后,遍历文件,再用前面与用户交互所获得的 变量 ,把那些标识符挨个替换成对应的变量。
const fse = require('fs-extra')
const path = require('path')

let REPLACE_REG = ''
let REPLACE_MAP = {}
let WHITE_FILE_PATH = ['yarn.lock', 'node_modules']

const init = ({ name, ptName, preFixTitle }) => {
  // 生成替换的 keyValue
  REPLACE_MAP = {
    $__CLI_PATH__$: name,
    $__CLI_PTNAME_PATH__$: ptName,
    $__CLI_TITLE_PATH__$: preFixTitle,
  }

  // 替换 $ 为 \\$ 便于生成正则
  const keyList = Object.keys(REPLACE_MAP)
  REPLACE_REG = new RegExp(keyList.map((v) => `(${v.replace(/\$/gi, '\\$')})`).join('|'), 'ig')
}

const readDirs = async (pPath) => {
  const fileList = fse.readdirSync(pPath)

  fileList.forEach((file) => {
    // 不存在与白名单的文件,才去替换
    if (WHITE_FILE_PATH.every((v) => v != file)) {
      const tempPath = path.join(pPath, file)
      const state = fse.statSync(tempPath)

      // 检测是否为目录,如果是则递归
      if (state.isDirectory()) {
        readDirs(tempPath)
      } else {
        const res = fse.readFileSync(tempPath).toString()
        let newContent = res

        if (file == '.gitignore') {
          newContent = res.replace('yarn.lock', '')
        } else {
          newContent = res.replace(REPLACE_REG, (key) => {
            return REPLACE_MAP[key]
          })
        }

        fse.writeFileSync(tempPath, newContent) // 写入
      }
    } else {
      // console.log(`${file} 文件为白名单`)
    }
  })
}

module.exports = { readDirs, init }
// child_process 可以帮我们执行shell命令,封装起来
const exec = require('child_process').exec
const util = require('util')
const promiseExec = util.promisify(exec)

const shell = async (command, cwd) => {
  return new Promise(async (resolve, reject) => {
    try {
      const { stdout } = await promiseExec(command, { cwd })
      resolve(stdout)
    } catch (error) {
      console.log({ error })
      reject()
    }
  })
}

module.exports = shell