基于umi的前端脚手架(避坑,巨详细)

6,162 阅读4分钟

基于umi的前端脚手架(避坑,巨详细)

前言

每个前端初学者第一次接触的脚手架可能是vue-cli或者create-react-app吧,大家当时是否觉得不明觉厉,不明白它干了啥但是觉得很厉害。个人理解,脚手架就是一个工具,只需要一个指令,就能帮大家构建项目或者更新,发布项目,减少重复操作,提高工作效率。本篇文章将带大家打开前端脚手架大门,一步步学习如何开发脚手架~

💡 注意:本次的脚手架稍有不同,除了生成配置文件,拷贝模版文件,它还会在已有的脚手架上面添砖加瓦,好奇的小伙伴就继续看下去吧

需求分析

假设公司的系统基于antd-pro,每次开始一个新项目都需要跑一次create-umi脚手架,然后再从旧的项目拷贝一些基本配置,如icon,tsconfig等。

为了提高效率,现在需要开发一个脚手架,通过执行指令就能自动搭建一个基于 antd-pro,且已经初始化公司基本配置的项目。

这里有些熟悉的小伙伴可能疑惑,为什么不直接准备一个模版,脚手架直接拷贝模版即可。但由于antd-designumi一直在不断更新,为了每次的新项目都能够使用最新版本的antd-pro,这里将不直接准备模版,而是选择更为灵活的方法,脚手架直接调用create-umi脚手架,再在新生成的项目里增删改。

流程设计

脚手架流程图.jpg

开发准备

工具

  • chalk => => 能在控制台打印出丰富多彩的日志
  • ora => => 在控制台显示出loading的效果
  • commander => => 一个node模块,帮助我们简化命令行开发
  • execa => => 开启子进程,可以执行shell指令
  • fs-extra => => fs模块的扩展,支持Promise
  • git-promise => => 执行git操作,支持Promise

如何开发调试脚手架

funny-cli为例:

  • 本地调试(funny-cli文件夹下)
npm link // 开启调试
npm unlink // 结束调试
  • 项目内部调试(project文件加下)
npm link funny-cli // 开启调试
npm unlink funny-cli // 结束调试

package.json

重点介绍

 "bin": {
    "funny": "index.js" // 用于存放可以执行的指令
  },

用于存放可以执行的指令,当执行npm link会把funny字段以及对应的路径安装到全局node_modules内,啥意思,如图

截屏2022-01-01 下午12.21.42.png

{
  "name": "funny-cli",
  "version": "1.0.0",
  "description": "funny-cli 脚手架",
  "main": "index.js", // 入口文件
  "bin": {
    "funny": "index.js" // 用于存放可以执行的指令
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "publishConfig": {
    "registry": "https://registry.npmjs.org/"
  },
  "files": [ // publish 时需要打包到远程的文件列表
    "bin",
    "lib",
    "scripts",
    "index.js",
    "package.json",
    "README.md"
  ],
  "engines": {
    "node": ">=8.9"
  },
  "license": "ISC",
  "dependencies": {
    "chalk": "^2.4.2",
    "commander": "^8.3.0",
    "execa": "^2.0.4",
    "fs-extra": "^10.0.0",
    "git-promise": "^1.0.0",
    "ora": "^3.4.0"
  }
}

文件目录

截屏2022-01-01 下午12.26.38.png

开发步骤

commander

路径:bin/run.js

apidescription
command申明一个指令,如 command('update')
description对指令的描述,当指令错误时出现
option初始化自定义参数, 如option('-k, —-key value, this is a key')
action指令的回调函数
#!/usr/bin/env node 
// 表明是一个可以执行的文件

const program = require('commander')
const config = require('../package.json')

// 全局配置
program.name('funny').version(config.version, '-v, --version')

// 构建项目模板
program
  .command('create [project-name]')
  .description(
    '构建funny项目',
  )
  // 监听到指令执行
  .action((name, options) => 
      require('../scripts/create')(name, options)
   )

// 更新项目packages
program
  .command('update')
  .description('更新packages版本')
  .action(require('../scripts/update'))

// 其他
program.command('*').action(function () {
  logger.error('无效命令')
  logger.info('使用 funny --help 查看工具使用帮助')
})

program.parse(process.argv)

创建项目

.
├── script
│   └──  create
│          ├── templates
│          ├── settings
│          ├── Creator.js
│          └── index.js
const { logger, stopLoading } = require('../../lib')
const path = require('path')
const fs = require('fs-extra')
const Creator = require('./creator')

// 构建项目
async function create(projectName = 'new-project', options) {
  const { key } = options
  let name = projectName

  logger.info(`你的 key 为 ${key}`)
  
  // 当前执行的路径
  let cwd = options.cwd || process.cwd()
  let localPath = path.join(cwd.toString(), name)

  // 名字相同 project 处理
  if (fs.existsSync(localPath)) {
    logger.info('已存在同名文件夹,新建立的文件夹需要加个hash')
    name += `-${Math.floor(Math.random() * 1000)}`
    localPath = path.join(cwd.toString(), name)
  }

  logger.info(`正在初始化项目,安装位置:${localPath} \n\n`)

  // 前面完成准备工作,正式开始创建项目
  const creator = new Creator({ name, cwd, projectPath: localPath, app })

  await creator.create()
}

module.exports = create()

用到fs模块API整理

APIdescriptionexample
existsSync同步判断文件是否存在fs.existsSync(path)
statSync同步获取当前文件信息fs.statSync(path)
ensureFileSync同步判断改文件是否存在fs.ensureFileSync(path)
emptyDirSync同步判断目录是否存在fs.emptyDirSync(path)
unlink异步删除文件fs.unlink(path)
rmdir异步删除目录fs.rmdir(path), 强制删除第二个参数传入 { recursive: true, force: true }
copy异步拷贝文件fs.copy(template, target),遇到已存在的路径,合并文件夹
readJson异步读取json文件,返回对象或者数组fs.readJson(path)
readdirSync同步读取文件目录fs.readdirSync(path), 返回list
writeJsonSync同步写jsonfs.writeJsonSync(path, {})

execa

如何在脚手架里面调用其他的脚手架呢?

// 初始化umi
  async initProject(name) {
    try {
     // 执行 npm install create-umi
      const { exitCode } = await execa('npm', ['install', 'create-umi'], {
        cwd: './',
      })
      //  success 运行 create-umi 脚手架
      if (!exitCode) {
        await execa(
          'npx',
          [
            'create-umi',
            name,
            '--type',
            'ant-design-pro',
            '--language',
            'TypeScript',
            '--allBlocks',
            'simple',
          ],
          {
            shell: true,
            cwd: './',
          },
        )
        // 初始化结束 uninstall create-umi
        await execa('npm', ['uninstall', 'create-umi'])
      } else {
        throw Error()
      }
    } catch (error) {
      console.log(error)
      logger.error(`umi 初始化失败, 请联络管理员`)
      exit(1)
    }

execa 实际是node child_process.execa 的扩展, 支持promise用法

const execa = require('execa')

async fn = () => {
    await execa('npm', ['install', 'react'],{
        cwd: {
        }
     )
}

初始化完create-umi 后,项目需要自定义一些基本配置,例如 package.json、tsconfig...

一般会把这些基本配置,作为模版,放在模版文件夹下,后续的工作,就是一些读写文件夹的操作

const chalk = require('chalk')
const execa = require('execa')
const {
  logger,
  logWithLoading,
  stopLoading,
  copyFile,
  fetchVersionConfig,
  exit,
  installPackage,
} = require('../../lib')
const fs = require('fs-extra')

module.exports = class Creator {
  constructor(params) {
    const { name, cwd, projectPath, app } = params
    this.name = name
    // 模版文件路径
    this.templatesPath = `${__dirname}/templates`
    // 配置文件路径
    this.settingPath = `${__dirname}/settings/index.json`
    // 项目文件路径
    this.projectPath = projectPath

    this.cwd = cwd
    this.app = app
  }

  async create() {
    try {
      const { name, templatesPath, projectPath, settingPath, app, cwd } = this

      logWithLoading(`正在初始化项目...\n`)

      await this.initProject(name)
      stopLoading(`初始化项目成功\n`)

      logWithLoading(`正在生成 ${chalk.yellow('package.json')} 等模板文件...\n`)

      // 将下载的临时文件拷贝到项目中
      const pkgJson = await copyFile(templatesPath, projectPath)

      const setting = await fs.readJson(settingPath)
      // 生成 packages.json 文件
      Object.entries(setting).forEach(([name, info]) => {
        switch (name) {
          case 'scripts':
            let newScript = {}

            // 设置appValue
            Object.keys(info).forEach(item => {
              const reg = /(APP=)[\d]+/g
              const value = info[item]
              const exist = reg.test(value)
              if (exist) {
                newScript[item] = value.replace(reg, `$1${app}`)
              } else {
                newScript[item] = value
              }
            })
            pkgJson['scripts'] = newScript
            break

          case 'devDependencies':
            break
          default:
            pkgJson[name] = { ...pkgJson[name], ...info }
            break
        }
      })

      stopLoading('生成模版文件成功\n')

      logWithLoading(`正在install...\n`)

      let packagesLists = setting['devDependencies']

      // 如果读不到远程文件,直接下载最新版本
      if (versionConfig) {
        pkgJson['dependencies'] = {
          ...pkgJson['dependencies'],
          ...versionConfig,
        }
        packagesLists = packagesLists.filter(
          pack => !Object.keys(versionConfig).includes(pack),
        )
      }

      // 写入文件
      fs.writeJsonSync(`${projectPath}/package.json`, {
        ...pkgJson,
        ...{ name, version: '1.0.0' },
      })

      await installPackage(projectPath, packagesLists, true)

      stopLoading('新项目 install 成功\n')

      logger.log(`🎉  项目创建成功 ${chalk.yellow(name)} \n`)
    } catch (error) {
      console.log(error)
      exit(1)
    }
  }

  async fetchVersion() {
    let versionJson
    logWithLoading(`正在读取远程packages配置..\n\n`)
    try {
      versionJson = await fetchVersionConfig(this.cwd)
      stopLoading('读取远程packages配置成功 \n')
    } catch (e) {
      stopLoading()
      logger.error(`读取远程packages配置失败, 将自动更新packages最新版本 \n`)
    }
    return versionJson
  }
  }
}

源码自取

后续,其实脚手架开发并不难~但是建议大家都敲一敲,跟着流程敲一遍,对脚手架的基础就能有个大致的理解~