脚手架的目的
在开展工作之前,我们要先弄清搭建一套脚手架的目的是什么。
肯定当然有同学要说,当然是让使用者快速上手啦,通过脚手架可以快速构建我们需要的前端工程,然后就可以开心的撸代码了。
一个好的脚手架肯定是需要满足自身的业务需求,是将整个团队的业务沉淀整合进去的。所以开发一个脚手架的目的其实就是将团队优秀的公共代码、架构,整合到一个模具中,让新的项目可以一比一去复刻,从而节省大量的搭建时间。
如何去开发一个脚手架
在我进行coding之前,去翻阅了公司老的脚手架,公司老的脚手架是基于vue-cli来进行开发的。其实一个脚手架无非就是做以下几件事
- 依赖安装:修改package.json文件,安装项目依赖
- 模板复制: 将脚手架中的模板复制到需要应用的项目中去
- 文件修改: 新增/修改/删除指定文件代码
以上这些事情,vue-cli都已经做掉了,我们只需要通过调用vue-cli暴露的方法就可以做到以上操作。
但是由于vue3.0的原因,官方从新搭建了一套工程,不再推荐使用vue-cli,而且为了挑战自身不足,我选择了自己用node去手撸上面的功能,然后用自身造的轮子去完成脚手架的开发。
说白了,只要能做到以上3件事,就已经拥有了三把刀,拎着就可以出去狂砍一条街。
前期所依赖的npm包
一个工程的开发不可能全部自己造轮子,挑选合适的依赖包,可以帮助我们事半功倍。
我所挑选的Npm包基本都是从vue-cli那里抄来的,主要是方便😂
- chalk 主要用于在命令行打印花里胡哨的字
- commander 主要用于定义命令
- inquirer 一个好的脚手架怎么可以缺少询问用户操作的步骤呢
- jecast|@babel/paser 主要用于AST解析
- zx 用于代码打包
- semver 用于版本解析
- javascript-stringify 用于解析文档
- execa 执行命令时使用
项目目录
|-- root
|-- .gitignore
|-- .npmrc
|-- outfile.cjs #打包产物 bin指向此文件
|-- package-lock.json
|-- package.json
|-- README.md #README文档
|-- bin #项目入口
| |-- index.js
|-- command #命令集合
| |-- add.js
| |-- init.js
|-- generator #具体执行操作集合
| |-- plugin-axios.js
| |-- plugin-commitlint.js
| |-- plugin-stylelint.js
| |-- plugin-ui.js
|-- template #模板集合
| |-- plugin-normal
| |-- plugin-axios
| |-- plugin-commitlint
| |-- plugin-ui
|-- utils #具体工具类
|-- changePackage.js #修改package.json文件
|-- checkEnv.js #环境检测
|-- checkFileExist.js #判断文件是否存在
|-- ensureEOL.js #换行编码
|-- extendJSConfig.js #修改文件(ast)
|-- getEntryFile.js #获取项目入口文件地址(main)
|-- injectImports.js #在入口文件插入Import语句
|-- insertStatements.js #在入口文件插入具体语句
|-- install.js #依赖安装
|-- loadModule.js #加载文件
|-- log.js #打印封装
|-- renderTemplate.js #复制模板文件(异步)
|-- renderTemplateSync.js #复制模板文件(同步)
|-- stringifyJS.js #stringify封装
bin/index入口文件
作为一个脚手架,用户肯定是 通过命令行工具来执行,通过执行脚手架具体的命令,来安装相应的依赖。
而作为整个脚手架的入口文件,bin/index主要的功能是定义脚手架命令。
具体就是通过commander来定义的。
**注意: 作为需要node来执行的文件 需要在顶部写清楚 #! /usr/bin/env node**
#! /usr/bin/env node
const semver = require('semver')
// 找到package.json中指定的node版本
const requiredVersion = require('../package.json').engines.node
// 命令行开发工具
const program = require('commander');
const { error, log } = require('../utils/log')
// 检测Node版本
function checkNodeVersion(wanted, id) {
if (!semver.satisfies(process.version, wanted, { includePrerelease: true })) {
error(
'You are using Node ' + process.version + ', but this version of ' + id +
' requires Node ' + wanted + '.\nPlease upgrade your Node version.'
)
process.exit(1)
}
}
checkNodeVersion(requiredVersion, 'my-cli')
// 命令定义
program
.version(`${require('../package').version}`, '-v, --version')
.helpOption('-h,--help')
.command('list')
.description('查看所有可用的项目模板')
.action(() => {
log(`
plugin-axios axios请求模板
plugin-commitlint commitlint提交模板
plugin-stylelint stylelint模板
`)
})
program
.command('add')
.argument('[template]', '输入[template]插件名称去安装')
.description('添加模板工程')
.action((templateName) => {
require('../command/add')(templateName)
})
program
.command('init')
.description('初始化项目')
.action(() => {
require('../command/init')()
})
program.on('--help', function () {
log('****************');
log('举例: npm init my-cli add axios');
log('****************');
});
// 注意 这行不能缺少,这里是用来解析命令行参数
program.parse(process.argv)
command/add.js
这里封装了add命令的操作,add命令用于安装指定的包
const { error, log } = require('../utils/log')
const pluginAxios = require('../generator/plugin-axios')
const pluginCommitlint = require('../generator/plugin-commitlint')
const pluginStylelint = require('../generator/plugin-stylelint')
const switchTemplate = async (templateName) => {
switch (templateName) {
// 安装axios模板
case 'axios':
case 'plugin-axios':
await pluginAxios()
break;
// 安装commitlint
case 'commitlint':
case 'plugin-commitlint':
await pluginCommitlint()
break;
// 安装stylelint
case 'stylelint':
case 'plugin-stylelint':
await pluginStylelint()
break;
default:
break;
}
}
async function add (templateName) {
if (templateName) {//添加指定模板
switchTemplate(templateName)
} else {//用户手动选择添加模板
const inquirer = (await import('inquirer')).default;
inquirer.prompt([{
type: 'checkbox',
message: '请选择您需要安装的插件',
name: 'templatList',
choices: [
"plugin-axios",
"plugin-commitlint",
"plugin-sso"
],
}]).then(({ templatList }) => {
templatList.forEach(templateName => {
switchTemplate(templateName)
})
})
}
}
module.exports = (...args) => {
return add(...args).catch(err => {
error(err)
process.exit(1)
})
}
举例:安装axios
下面以安装axios包举例,axios包需要做4件事情
- 安装依赖,如axios、vueAxios包
- 复制模板,将脚手架中的axios封装文件复制到项目中
- 在main.ts中引入并使用上一步复制的文件
- 修改vite.config.ts文件,将代理配置写入
module.exports = async (tempType) => {
return new Promise(async (resolve) => {
const inquirer = (await import('inquirer')).default;
const path = require('path')
const fs = require('fs')
const { error, success } = require('../utils/log')
const pkgChange = require('../utils/changePackage')
const renderTemplate = require('../utils/renderTemplate')
const injectImports = require('../utils/injectImports')
const insertStatements = require('../utils/insertStatements')
const install = require('../utils/install')
const ensureEOL = require('../utils/ensureEOL')
const getEntryFile = require('../utils/getEntryFile')
// 修改package.json
pkgChange({
dependencies: {
'axios': '^1.1.2',
'vue-axios': '^3.4.1',
}
})
//安装依赖
await install()
if (!tempType) {
const { type } = await inquirer.prompt({
type: 'list',
message: '请选择模板类型',
name: 'type',
choices: [
{
name: 'ts(default)',
value: 'ts',
},
{
name: 'js',
value: 'js',
},
],
default: true
})
tempType = type
}
// 复制template模板文件 分ts/js两个版本
const templateRoot = path.resolve(__dirname, 'template', tempType, 'plugin-axios')
renderTemplate(`${templateRoot}`, process.cwd(), err => {
error(err)
})
// 修改vite.config.ts文件
const configFilePath = `${process.cwd()}/vite.config.ts`
const extendJSConfig = require('../utils/extendJSConfig')
const configSource = fs.readFileSync(configFilePath)
const changeSource = extendJSConfig({
server: {
proxy: {
'^/api': {
target: 'https://api.example.com',
ws: true,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
},
},
define: {
'process.env': {
'BASE_API': ''
}
}
}, configSource.toString())
fs.writeFileSync(configFilePath, ensureEOL(changeSource))
const entryFile = getEntryFile()
// 入口插入import
const importedFile = injectImports(entryFile, [
'import initAxios from \'./plugins/initAxios\';',
])
fs.writeFileSync(entryFile, ensureEOL(importedFile))
// 入口添加调用
const insertedFile = insertStatements(entryFile, [
'initAxios(app)'
])
fs.writeFileSync(entryFile, ensureEOL(insertedFile))
success('plugin-axios安装成功!')
resolve()
})
如何添加依赖
changePackage方法是我定义的添加依赖方法,其实也就是去读取package.json文件,然后导出它暴露出的对象,然后修改这个对象再写回package.json。
module.exports = async (extendsData) => {
const { error } = require('../utils/log')
const fs = require('fs');
//根据命令行答询结果修改package.json文件
fs.readFile(`./package.json`, 'utf8', function (err, data) {
if (err) {
error(`读取配置失败, ${err}`);
return;
}
let pkg = JSON.parse(data)
for (let key in extendsData) {
if (pkg[key]) {
pkg[key] = Object.assign(pkg[key], extendsData[key])
} else {
pkg[key] = extendsData[key]
}
}
package = JSON.stringify(pkg, null, 4);
fs.writeFile(`./package.json`, package, 'utf8', (err) => {
if (err) {
error(`修改配置失败, ${err}`);
return;
}
});
})
}
其中extendData是我们需要写入的配置,这里其实只是做了一下对象的简单合并。
如何安装依赖
我们修改了package.json,其实只需要运行install,程序就会自动安装依赖了。但是难点就在于我们的包管理工具有很多,需要去判断当前用户使用了哪种包管理工具,然后再去install。
我这里集成了npm、pnpm、yarn三个工具的判断,首先通过尝试读取.lock文件,来判断。如果没有.lock文件,就尝试执行-v命令,来确定用户使用的哪种包管理命令。
const { hasYarn, hasProjectYarn, hasPnpm3OrLater, hasPnpmVersionOrLater, hasProjectPnpm, hasProjectNpm } = require('./checkEnv')
const execa = require('execa')
const chalk = require('chalk')
const readline = require('readline')
const PACKAGE_MANAGER_PNPM4_CONFIG = {
install: ['install', '--reporter', 'silent', '--shamefully-hoist'],
add: ['install', '--reporter', 'silent', '--shamefully-hoist'],
upgrade: ['update', '--reporter', 'silent'],
remove: ['uninstall', '--reporter', 'silent']
}
const PACKAGE_MANAGER_PNPM3_CONFIG = {
install: ['install', '--loglevel', 'error', '--shamefully-flatten'],
add: ['install', '--loglevel', 'error', '--shamefully-flatten'],
upgrade: ['update', '--loglevel', 'error'],
remove: ['uninstall', '--loglevel', 'error']
}
const PACKAGE_MANAGER_CONFIG = {
npm: {
install: ['install', '--legacy-peer-deps', '--loglevel', 'error'],
add: ['install', '--loglevel', 'error'],
upgrade: ['update', '--loglevel', 'error'],
remove: ['uninstall', '--loglevel', 'error']
},
pnpm: hasPnpmVersionOrLater('4.0.0') ? PACKAGE_MANAGER_PNPM4_CONFIG : PACKAGE_MANAGER_PNPM3_CONFIG,
yarn: {
install: [],
add: ['add'],
upgrade: ['upgrade'],
remove: ['remove']
}
}
function toStartOfLine(stream) {
if (!chalk.supportsColor) {
stream.write('\r')
return
}
readline.cursorTo(stream, 0)
}
function renderProgressBar(curr, total) {
const ratio = Math.min(Math.max(curr / total, 0), 1)
const bar = ` ${curr}/${total}`
const availableSpace = Math.max(0, process.stderr.columns - bar.length - 3)
const width = Math.min(total, availableSpace)
const completeLength = Math.round(width * ratio)
const complete = `#`.repeat(completeLength)
const incomplete = `-`.repeat(width - completeLength)
toStartOfLine(process.stderr)
process.stderr.write(`[${complete}${incomplete}]${bar}`)
}
const executeCommand = (command, args, cwd) => {
return new Promise((resolve, reject) => {
const child = execa(command, args, {
cwd,
stdio: ['inherit', 'inherit', command === 'yarn' ? 'pipe' : 'inherit']
})
if (command === 'yarn') {
child.stderr.on('data', buf => {
const str = buf.toString()
if (/warning/.test(str)) {
return
}
// progress bar
const progressBarMatch = str.match(/\[.*\] (\d+)\/(\d+)/)
if (progressBarMatch) {
// since yarn is in a child process, it's unable to get the width of
// the terminal. reimplement the progress bar ourselves!
renderProgressBar(progressBarMatch[1], progressBarMatch[2])
return
}
process.stderr.write(buf)
})
}
child.on('close', code => {
if (code !== 0) {
reject(new Error(`command failed: ${command} ${args.join(' ')}`))
return
}
resolve()
})
})
}
module.exports = async () => {
let bin;
const context = process.cwd()
if (hasProjectYarn(context)) {
bin = 'yarn'
} else if (hasProjectPnpm(context)) {
bin = 'pnpm'
} else if (hasProjectNpm(context)) {
bin = 'npm'
}
if (!bin) {
bin = hasYarn() ? 'yarn' : hasPnpm3OrLater() ? 'pnpm' : 'npm'
}
const runCommand = async (command, args) => {
await executeCommand(
bin,
[
...PACKAGE_MANAGER_CONFIG[bin][command],
...(args || [])
],
context
)
}
await runCommand('install')
}
模板的复制
这个操作其实就是去脚手架读取目录/文件,然后复制到新项目中去。这里有个路径的概念,在命令行执行的上下文环境,要访问的路径有两个
- 脚手架路径,也就是
__dirname - 项目路径,也就是
process.cwd()
const templateRoot = path.resolve(__dirname, 'template', tempType, 'plugin-axios')
renderTemplate(`${templateRoot}`, process.cwd(), err => {
error(err)
})
上面代码的意思是,你去脚手架找到plugin-axios这个目录,然后给我复制到新项目中相同路径的文件夹去,没有就创建。
而且我们知道,复制目录是一个递归的操作,因为我们不知道目录有多少层,所以代码如下:
/*
* 复制目录、子目录,及其中的文件
* @param src {String} 要复制的目录
* @param dist {String} 复制到目标目录
*/
// 异步
const fs = require('fs');
module.exports = renderTemplate = (src, dist, callback) => {
fs.access(dist, function (err) {
if (err) {
// 目录不存在时创建目录
fs.mkdirSync(dist);
}
_copy(null, src, dist);
});
function _copy(err, src, dist) {
if (err) {
callback(err);
} else {
fs.readdir(src, function (err, paths) {
if (err) {
callback(err)
} else {
paths.forEach(function (path) {
var _src = src + '/' + path;
var _dist = dist + '/' + path;
fs.stat(_src, function (err, stat) {
if (err) {
callback(err);
} else {
// 判断是文件还是目录
if (stat.isFile()) {
fs.writeFileSync(_dist, fs.readFileSync(_src));
} else if (stat.isDirectory()) {
// 当是目录是,递归复制
renderTemplate(_src, _dist, callback)
}
}
})
})
}
})
}
}
}
这是个异步的操作,这里图省事,没有用Promise去封装,而且callback其实只是错误的捕捉,没有支持真正的reslove。如果需要支持真正的reslove,需要做一个cache缓存,递归Push(),结束Pop()。
如何修改vite.config.ts
修改vite.config.ts文件,要用到ast语法解析。与package.json文件的读写不同。vite.config.ts文件有import,也有export。我们需要的是将export导出的对象取出,然后修改这个对象,再写回去。
module.exports = function extendJSConfig (value, source) {
const recast = require('recast')
const parser = require("@babel/parser");
const stringifyJS = require('./stringifyJS')
let exportsIdentifier = null
// const ast = recast.parse(source)
const ast = parser.parse(source, {
sourceType:'module'
})
recast.types.visit(ast, {
visitExportDefaultDeclaration (path) {
const { node } = path
if (
node.type === 'ExportDefaultDeclaration'
) {
let theExports = node.declaration
if (
theExports.type === 'CallExpression' &&
theExports.callee.type === 'Identifier' &&
theExports.callee.name === 'defineConfig'
) {
theExports = theExports.arguments[0]
}
if (theExports.type === 'ObjectExpression') {
augmentExports(theExports)
} else if (theExports.type === 'Identifier') {
// do a second pass
exportsIdentifier = theExports.name
}
return false
}
this.traverse(path)
}
})
if (exportsIdentifier) {
recast.types.visit(ast, {
visitVariableDeclarator ({ node }) {
if (
node.id.name === exportsIdentifier &&
node.init.type === 'ObjectExpression'
) {
augmentExports(node.init)
}
return false
}
})
}
function augmentExports (node) {
const valueAST = recast.parse(`(${stringifyJS(value, null, 2)})`)
const props = valueAST.program.body[0].expression.properties
const existingProps = node.properties
for (const prop of props) {
const isUndefinedProp =
prop.value.type === 'Identifier' && prop.value.name === 'undefined'
const existing = existingProps.findIndex(p => {
return !p.computed && p.key.name === prop.key.name
})
if (existing > -1) {
// replace
existingProps[existing].value = prop.value
// remove `undefined` props
if (isUndefinedProp) {
existingProps.splice(existing, 1)
}
} else if (!isUndefinedProp) {
// append
existingProps.push(prop)
}
}
}
return recast.print(ast).code
}
对于ast我也没咋研究,上面的代码是我从vue-cli里抄来的,具体操作就是ast解析文件后,找到export default语句,然后拿到对象。通过recast.parse将我们传入的配置也转为ast语句,最后写入解析后的ast对象。最后再转回来。
如何修改main.ts
与上面修改vite.config.ts差不多,也是通过ast来进行。这里vue-cli只是在文件顶部插入import语句。而我还需要在app.mount('#app')之前插入app.use(xxx)这些操作。所以我封装了两个方法,一个用来插入import,一个用来插入app.use(xxx)。
const { runTransformation } = require('vue-codemod')
function injectImports ({source}, api, { imports }) {
const j = api.jscodeshift
// 1. 源代码转ast
const root = j(source)
const toImportAST = i => j(`${i}\n`).nodes()[0].program.body[0]
const toImportHash = node => JSON.stringify({
// 导入后指定的变量名
specifiers: node.specifiers.map(s => s.local.name),
// 路径
sourceValue: node.source.value,
// 引入的包名
source: node.source.raw
})
// 2. 查找Import语句
const declarations = root.find(j.ImportDeclaration)
// 3. 将语句中 变量/导入的包名 解析出来
const importSet = new Set(declarations.nodes().map(toImportHash))
// 4. 去重
const nonDuplicates = node => !importSet.has(toImportHash(node))
// 5. 指定需要插入的Import语句
const importASTNodes = imports.map(toImportAST).filter(nonDuplicates)
// 判断 如果之前源文件就存在import语句 则插入到最后一个import语句后
if (declarations.length) {
declarations
.at(-1)
// a tricky way to avoid blank line after the previous import
.forEach(({ node }) => delete node.loc)
.insertAfter(importASTNodes)
} else { // 否则插入到文件开头
// no pre-existing import declarations
root.get().node.program.body.unshift(...importASTNodes)
}
// 6. ast转回代码
return root.toSource()
}
module.exports = (entryFile,imports) => {
const fs = require('fs')
const source = fs.readFileSync(entryFile, 'utf-8')
return runTransformation(
{ path:entryFile, source },
injectImports,
{ imports }
)
}
const { runTransformation } = require('vue-codemod')
function insertStatements ({source}, api, { statements }) {
const j = api.jscodeshift
// 源代码转ast
const root = j(source)
const toStatementsAST = i => j(`${i}\n`).nodes()[0].program.body[0]
// 查找Statement语句
const declarations = root.find(j.ExpressionStatement)
// 指定需要插入语句
const statementASTNodes = statements.map(toStatementsAST)
// 判断 如果之前源文件就存在import语句 则插入到最后一个import语句后
if (declarations.length) {
declarations
.filter(({ node }) => {
return node.expression.arguments[0].value === '#app'
})
// a tricky way to avoid blank line after the previous import
.forEach(({ node }) => delete node.loc)
.insertBefore(statementASTNodes)
}
// ast转回代码
return root.toSource()
}
module.exports = (entryFile,statements) => {
const fs = require('fs')
const source = fs.readFileSync(entryFile, 'utf-8')
return runTransformation(
{ path:entryFile, source },
insertStatements,
{ statements }
)
}
上面两个函数的整体逻辑都是差不多的,具体区别只是visit的语句不同,以及相关的If判断条件的区别。
项目模板
项目模板就是放在脚手架的文件,它里面的内容其实就和我们平时开发的文件一样。以initAxios为例,这里就是简单的初始化操作
import axios from './axios'
import VueAxios from 'vue-axios';
import api from '../api'
import type { App } from 'vue'
export default (app: App<Element>): void => {
// 通过app.config.globalProperties.axios访问
app.use(VueAxios, axios)
// 通过inject('$api')访问api
app.provide('$api', api)
// 通过inject('$axios')访问axios
app.provide('$axios', axios)
// 暴露为全局方法,可通过this.$api访问
app.config.globalProperties.$api = api;
}
总结
上面就是如何简单的封装一个脚手架,作为一个大型的前端团队,肯定不能各自为战。各个小组用各个小组的一套去搭建自己的项目,五花八门,这是极度不利于后期维护的。所以封装一个企业级的脚手架是很有必要的。
目前vue3.0官方使用了vite,不再推荐使用vue cli,因为vue-cli是基于webpack来集成的。但是目前官方也没有推出一套标准的脚手架解决方案,因此搭建自己的脚手架也是迫在眉睫。
脚手架其实没有听起来这么唬人,其实也就是做了简单的文件复制/修改等操作,让我们搭建的项目结构都是一样的,这有利于团队间的合作开发。