基于umi的前端脚手架(避坑,巨详细)
前言
每个前端初学者第一次接触的脚手架可能是vue-cli或者create-react-app吧,大家当时是否觉得不明觉厉,不明白它干了啥但是觉得很厉害。个人理解,脚手架就是一个工具,只需要一个指令,就能帮大家构建项目或者更新,发布项目,减少重复操作,提高工作效率。本篇文章将带大家打开前端脚手架大门,一步步学习如何开发脚手架~
需求分析
假设公司的系统基于antd-pro,每次开始一个新项目都需要跑一次create-umi脚手架,然后再从旧的项目拷贝一些基本配置,如icon,tsconfig等。
为了提高效率,现在需要开发一个脚手架,通过执行指令就能自动搭建一个基于 antd-pro,且已经初始化公司基本配置的项目。
这里有些熟悉的小伙伴可能疑惑,为什么不直接准备一个模版,脚手架直接拷贝模版即可。但由于antd-design和umi一直在不断更新,为了每次的新项目都能够使用最新版本的antd-pro,这里将不直接准备模版,而是选择更为灵活的方法,脚手架直接调用create-umi脚手架,再在新生成的项目里增删改。
流程设计
开发准备
工具
- 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内,啥意思,如图
{
"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"
}
}
文件目录
开发步骤
commander
路径:bin/run.js
| api | description |
|---|---|
| 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整理
| API | description | example |
|---|---|---|
| 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 | 同步写json | fs.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
}
}
}
后续,其实脚手架开发并不难~但是建议大家都敲一敲,跟着流程敲一遍,对脚手架的基础就能有个大致的理解~