用一个Babel插件简化重复工作(一),解放双手只能解放🤏一点点用一个babel插件简化重复工作(一),丢弃重复工作,
工具创作背景
这在期间依然没有放松学习,了解了 AST , Babel 插件等简单的基本原理等。 不要问怎么学的,问就是因为朋友无意的一句话,好奇,这句话就是 “他们小程序打包体积过大,然后他们使用vite 写了个插件把图片等静态资源替换成CDN上的了”。
然后加上之前对 AST有一丁点了解。这要追溯到几年前,公司的小程序项目使用wepy框架开发的,但是这个框架呢是一个个人开发者整的,随着时间的推移,项目的逐渐庞大,显得开发效率上不去,力不从新,对于一些缺陷的处理都是采用一些非正常思维去规避;同时需要开发h5,支付宝小程序等各种,基于这些种种原因,一番调研之后决定使用uniapp 进行开发;这就涉及到框架的升级,当时的处理方式就是专门一个同学开发升级工具,其他同学依然就行迭代,这个工具就是使用 babel 对项目源代码进行转化适配uniapp的语法,最后经过这位同学的不懈努力,日日加班,终于在一段时间之后工具成行,能够进行升级了,效果也还算非常理想,工具自动处理了90%+的转换工作;剩下的10%仍然需要我们进行手动的细微调整,最终升级的还是挺顺利。
在之后的一次前端技术分享会议上,该同学就讲解了AST,babel编译器,哪个时候还是非常感兴趣的,但是因为没有提前了解相关信息,当时听的也只是个大概,看演示呢,也是云里雾里的,但是核心思想还是听明白了,就是 “源代码 转换成AST,对AST进行调整,最后生成目标代码, 基于AST甚至可以跨语言进行代码转化“;你说这么有意思那指定得去玩玩啊;这颗想玩玩的种子一直深埋心底,等待合适的机会。
面临的问题
作为先行者这段时间经过了几个礼拜的陆陆续续的探索实践,整理了一些文档,包括了改动范围,以及常见问题等等,这些文档能有效降低同事频繁的找你看问题处理问题的频率,也算是解放自己的一种方式,不然真的就需要到处看问题,说解决方案等等。
经过一次简短的会议后,接下来就迎来了对项目批量升级的日程计划,工期两周完成升级,并且日常的迭代需求一个不能落下;可谓是时间紧任务重,况且针对需要升级的改动范围也是相当的广,基本上 src 下90%+的文件都需要进行调整适配;
在每个项目这么大范围的改动下,保障几十个项目的顺利升级,就需要思考以下的问题了:
1.如何保证时效性,毕竟迭代周期也有限,不可能停下来专门升级而不进行需求的迭代;
2.如何才能最小改动,毕竟最小改动就意味着工作量的陡降;测试范围的缩小;时间上的节省;
3.几十个项目的升级,平均每个人负责3个左右的项目升级,能不能有什么方法能够批量处理;
总结一下这几个问题的关键点就是,时间,工作量,准确性,大量重复操作;
分析问题
第一个需要解决的问题就是对 src/api 文件的改造,一个项目大概最少都有40多个API文件
原始API 文件的形式如下:
import {baseRequert} from '@/plugin/request'
const newPage = 'temp'
const versionList = (p)=> baseRequest.delete('xxx',{...p})
export const versionList2 = (p)=>{
return baseRequest.delete('/v1.0/xxx',{...p})
}
function versionPage(p){
return systemApi.get('/a/xxx',{...p})
}
export function versionDetail(p){
return baseRequest.post('/b/xxx',{...p})
}
const test =(id)=>{
return baseRequest.post(`test/a/${id}`)
}
const testC = (id)=>baseRequest.post(`test/a/${id}`)
const testCb = (id)=>{
return baseRequest.post(`test/a/${id}`)
}
const origin = ()=>{
return axias.create({
baseUrl:"127.0.0.01",
headers:{
contentType:'application/json;charset=utf-8'
}
}).post('/a/b/c')
}
export default {
versionList,
versionPage,
versionDetail,
test,
testCb,
origin,
testC
}
这就是我们的API文件;包含了 箭头函数,普通函数,既export 对象,又export某些单一的函数等等场景;
然而新框架对API有了新的规范和约定;需要统一放置在apis 下面;export 默认的函数,函数内 return 所有的 api 函数名;如下:
export default ($axios,{$service})=>{
return {
versionList(data={}){
$axios.$post('xxx',data)
},
}
}
我们需要处理的就是把原来的文件迁移到新的目录,并按指定的要求进行改造;所以整个改动还是挺大的,看上去改动不大但实际上一点也不小;毕竟一个项目几十个,几十个项目就是几百个,光用 CV替换大法,估计人都得改傻。
解决问题
既然手动费神费力,那我们就写工具自动转化,思来想去,这基于AST的代码转换不就非常符合这场景嘛;
经过了几个小时对AST的初步了解,和一下午demo练习,基本上大致明白该怎么玩的;
说到AST就不能不提这个强有力的在线工具astexplorer ,他能够实时的预览源代码生存的AST对象; 让我们简单的感受一下它的魅力:
左边就是源代码,右边就是AST的tree,工具功能挺丰富 有兴趣的可以去玩玩;
AST描述了每一个语句;包括了类型,位置等等各种信息;
有了这个工具我们就能轻松知道源代码对应的AST是什么样子的了;
我们再对AST树进行修改调整;
最后再把AST生成代码即可达到精准修改源码的目的;
参考示例
既然知道了整个流程接下来我们就直接可以开写了:
第一步获取API目录下的所有文件,对于有嵌套层级的文件进行拍平,让其处于只有一级目录;
import fastGlob from 'fast-glob'
// 获取所有api 文件,借助fastGlob包可以有效简化获取目录文件这类操作
const getApiFiles = ()=> fastGlob.sync(['**/vue/src/api/**/*.js'], { dot: true });
第二步 读取文件内容,替换里面的export 关键字
let originCode = fs.readFileSync(path.resolve(apiFile))
originCode = originCode.toString()
originCode = originCode.replaceAll('export const','const')
originCode = originCode.replaceAll('export function','function')
第三步 编写Babel插件修改AST
// api/file => apis/file
function changeApiFileContent(apiFile){
const exports = [] // 收集需要export 的函数
const service = []
const changeContentPlugin=()=>{
return {
visitor:{
//删除所有的import,export default 节点 'ImportDeclaration|ExportDefaultDeclaration'(path,state){
path.remove()
},
// 普通函数 获取函数名称存入 需要导出的数组
FunctionDeclaration(path,state){
const {node} = path
const functionName = node.id.name
exports.push(functionName)
},
// 处理 箭头函数
VariableDeclaration(path,state){
const {node} = path
const {declarations} = node
const node2 = declarations[0]
const functionName = node2.id.name
const {type,body} = node2.init
if(type ==='ArrowFunctionExpression'){
exports.push(functionName)
if(body.type==='CallExpression'){
const serveName = body.callee.object.name
if(!service.includes(serveName)){
service.push(serveName)
}
const method = body.callee.property.name
console.log("serveName======>",serveName,method)
body.callee.object.name ='$axios'
body.callee.property.name = '$'+method
body.arguments[0] = changeFunctionArgs(body.arguments[0],serveName)
}
}
},
// 处理return 语句
ReturnStatement(path,state){
const node = path.node
const serveName = node.argument.callee.object.name
if(!service.includes(serveName)){
service.push(serveName)
}
const method = node.argument.callee.property.name
node.argument.callee.object.name ='$axios'
node.argument.callee.property.name = '$'+method
const body =node.argument
body.arguments[0] = changeFunctionArgs(body.arguments[0],serveName)
},
}
}
}
let originCode = fs.readFileSync(path.resolve(apiFile))
originCode = originCode.toString()
originCode = originCode.replaceAll('export const','const')
originCode = originCode.replaceAll('export function','function')
// console.log("originCode>>>>>",originCode)
const targetSource = core.transform(originCode,{
plugins:[changeContentPlugin()]
})
// console.log("exports",exports)
// console.log("service",service)
// console.log("输出代码为:\n")
// console.log(targetSource.code)
return targetSource
}
第四步 对修改后的AST生成代码
// 构造目标代码
const targetCode = `
export default ($axios,{$service})=>{
const {${service.join(',')}} = $service
${targetSource.code}
return {
${exports.join(',')}
}
}
// 生成新的文件
let fileName = getFileName(fPath)
// console.log("------",fileName,path.resolve(fPath))
let content =`// ${fPath} => ${fileName} \r` // 记录一下该文件是从哪个文件生成的;
content += targetCode
// console.log("fPath",fileName,content)
fs.writeFileSync(path.resolve(targetPath+'/'+fileName),content.toString(), 'utf8');
console.log("已创建:",targetPath+'/'+fileName)
特殊场景处理
处理 url 的拼接,url 的类型有这么几种:
-
直接字符串:'/v1.0/user/page'
-
字符串表达式:'/v1.0/user/detail/'+id
-
模版字符串:`/v1.0/user/detail/${id}'
-
模版字符串表达式:`/v1.0/user/detail/${id}/'+name
// 处理url参数;
const changeFunctionArgs=(argument,serveName)=>{
const firstArg = argument
console.log("firstArg",firstArg.type)
let right = null
// StringLiteral : 'a/bb/c'
if(firstArg.type==='StringLiteral'){
right = {
type:'StringLiteral',
value: argument.value
}
}
//BinaryExpression: 'test/a/'+id
if(firstArg.type==='BinaryExpression'){
right = {
type:'BinaryExpression',
left: firstArg.left,
operator:firstArg.operator,
right:firstArg.right
}
}
// TemplateLiteral : `test/a/${id}`
if(firstArg.type ==='TemplateLiteral'){
right = {
type:'TemplateLiteral',
expressions: firstArg.expressions,
quasis:firstArg.quasis
}
}
// 构造BinaryExpression serveName + 原始参数(url)
argument.type = 'BinaryExpression'
argument.left = {
type:'Identifier',
name:serveName
}// 左节点
argument.operator ='+' //操作符
argument.right = right //右节点
return argument
}
整个流程下来就基本完成了代码的转换文件的生成;
最终生成的文件样子就是这个样子了:
// src/vue/src/api/xxx.js => xxx.js
export default ($axios,{$service})=>{
const {baseRequest} = $service
const newPage = 'xxx';
const versionList = p => $axios.$delete(baseRequest + "xxx", {
...p
});
const versionList2 = p => {
return $axios.$delete(baseRequest + "/v1.0/xxx", {
...p
});
};
function versionPage(p) {
return $axios.$get(systemApi + "/a/xxx", {
...p
});
}
function versionDetail(p) {
return $axios.$post(baseRequest + "/b/xxx", {
...p
});
}
const test = id => {
return $axios.$post(baseRequest + `test/a/${id}`);
};
const testC = id => $axios.$post(baseRequest + `test/a/${id}`);
const testCb = id => {
return $axios.$post(baseRequest + `test/a/${id}`);
};
return {
versionList,versionList2,versionPage,versionDetail,test,testC,testCb
}
}
结果符合预期,唯一不足的是,代码缩进有点问题。 当然这个是可以处理的,比如配置eslint 的插件进行格式化。或者简单暴力 直接扔进项目中 使用 lint fix 脚步去处理;
轻轻松松处理了代码的转换;
提升了效率,原本一个项目的api目录文件的迁移改动;
最终我们脚本一运行 咔咔咔 两三秒秒钟就处理好了;
最终较完整的参考代码
import core from '@babel/core'
import path from 'path'
import fs from 'fs'
import { getApiFiles } from './demo11.js'
// 处理url参数;
const changeFunctionArgs=(argument,serveName)=>{
const firstArg = argument
console.log("firstArg",firstArg.type)
let right = null
// StringLiteral : 'a/bb/c'
if(firstArg.type==='StringLiteral'){
right = {
type:'StringLiteral',
value: argument.value
}
}
//BinaryExpression: 'test/a/'+id
if(firstArg.type==='BinaryExpression'){
right = {
type:'BinaryExpression',
left: firstArg.left,
operator:firstArg.operator,
right:firstArg.right
}
}
// TemplateLiteral : `test/a/${id}`
if(firstArg.type ==='TemplateLiteral'){
right = {
type:'TemplateLiteral',
expressions: firstArg.expressions,
quasis:firstArg.quasis
}
}
// 构造BinaryExpression serveName + 原始参数(url)
argument.type = 'BinaryExpression'
argument.left = {
type:'Identifier',
name:serveName
}// 左节点
argument.operator ='+' //操作符
argument.right = right //右节点
return argument
}
// api/file => apis/files
function changeApiFileContent(apiFile){
const exports = []
const service = []
const changeContentPlugin=()=>{
return {
visitor:{
'ImportDeclaration|ExportDefaultDeclaration'(path,state){
path.remove()
},
// 普通函数
FunctionDeclaration(path,state){
const {node} = path
const functionName = node.id.name
exports.push(functionName)
},
// 箭头函数
VariableDeclaration(path,state){
const {node} = path
const {declarations} = node
const node2 = declarations[0]
const functionName = node2.id.name
const {type,body} = node2.init
if(type ==='ArrowFunctionExpression'){
exports.push(functionName)
if(body.type==='CallExpression'){
const serveName = body.callee.object.name
if(!service.includes(serveName)){
service.push(serveName)
}
const method = body.callee.property.name
console.log("serveName======>",serveName,method)
body.callee.object.name ='$axios'
body.callee.property.name = '$'+method
body.arguments[0] = changeFunctionArgs(body.arguments[0],serveName)
}
}
},
ReturnStatement(path,state){
const node = path.node
const serveName = node.argument.callee.object.name
if(!service.includes(serveName)){
service.push(serveName)
}
const method = node.argument.callee.property.name
node.argument.callee.object.name ='$axios'
node.argument.callee.property.name = '$'+method
const body =node.argument
body.arguments[0] = changeFunctionArgs(body.arguments[0],serveName)
},
}
}
}
let originCode = fs.readFileSync(path.resolve(apiFile))
originCode = originCode.toString()
originCode = originCode.replaceAll('export const','const')
originCode = originCode.replaceAll('export function','function')
// console.log("originCode>>>>>",originCode)
const targetSource = core.transform(originCode,{
plugins:[changeContentPlugin()]
})
// console.log("exports",exports)
// console.log("service",service)
// console.log("输出代码为:\n")
// console.log(targetSource.code)
const targetCode = `
export default ($axios,{$service})=>{
const {${service.join(',')}} = $service
${targetSource.code}
return {
${exports.join(',')}
}
}
`
return targetCode
}
function checkPath(dirPath) {
return new Promise(resolve => {
// 尝试访问路径,如果不存在则创建它
fs.access(dirPath, fs.constants.F_OK, (err) => {
if (err) {
// 路径不存在,创建目录
fs.mkdir(dirPath, { recursive: true }, (mkdirErr) => {
if (mkdirErr) {
console.error(`无法创建目录:${dirPath} Error: , ${mkdirErr}`);
resolve('无法创建目录')
} else {
console.log('目录已创建:',dirPath);
resolve('目录已创建')
}
});
} else {
console.log('目录已存在将进行覆盖:',dirPath);
resolve('目录已存在')
}
});
})
}
/**
*
* @param oldFiles
* @param targetPath
* @returns {Promise<void>}
*/
async function createApis(oldFiles,targetPath){
await checkPath(targetPath)
// console.log("targetPath",oldFiles,targetPath)
const getFileName = (filePath)=>{
const filePaths = filePath.split('/')
let fileName = ''
if(filePath.includes('index.js')){
fileName = filePaths[filePaths.length-2]+'.js'
}else {
fileName = filePaths[filePaths.length-1]
}
return fileName
}
oldFiles.forEach((fPath)=>{
let fileName = getFileName(fPath)
// console.log("------",fileName,path.resolve(fPath))
let content =`// ${fPath} => ${fileName} \r`
const code = changeApiFileContent(fPath)
content += code
// console.log("fPath",fileName,content)
fs.writeFileSync(path.resolve(targetPath+'/'+fileName),content.toString(), 'utf8');
console.log("已创建:",targetPath+'/'+fileName)
})
}
createApis(getApiFiles(),path.resolve('src/vue/src/apis'))
最后的最后
下面请上我们今天的主角:有请小趴菜
转载自:https://juejin.cn/post/7409948990819958799