使用AST,实现项目包优化

2021.01.22

问题背景

我们原来项目中请求API的代码是用公司现有的脚本工具生成的,其原理就是通过 node 访问 swagger 上后端开发暴露的api,根据约定的规则自动生成api请求的代码,如下所示:

// api.js
import * as moduleA from './moduleA.js';
import * as moduleB from './moduleB.js';
//... 省略很多module
export default {
    moduleA,
    moduleB,
    //....省略很多module
};

//moduleA.js
export function methodA (opts) {
    return instance({
        method: 'get',
        url: '/carEquity/entry.json',
        opts: opts
    });
}
export function methodB (opts) {
    return instance({
        method: 'get',
        url: '/carEquity/entry.json',
        opts: opts
    });
}
//... 省略很多method

//moduleB.js
export function methodA (opts) {
    return instance({
        method: 'get',
        url: '/carEquity/entry.json',
        opts: opts
    });
}
export function methodB (opts) {
    return instance({
        method: 'get',
        url: '/carEquity/entry.json',
        opts: opts
    });
}
//... 省略很多method

其好处就是:可以一键同步api,自动生成代码省去我们的编码工作,api声明和调用规范统一。 但是它也带来了比较严重的弊端,即无论这个API方法是否被用到,它都会被打包到项目中,增大了项目的体积,影响应用的首屏启动速度。还有就是模块这种设计模式不支持代码分割,调用者必须如下的导入方式才能才能调用API:

// page.js
import API from '@/api';
API.moduleA.methodA();
API.moduleB.methodB();
API.moduleA.methodC();

可以见上述这种方式不但调用繁琐,而且也不能启用 webpack 的 tree shaking 只打包使用到的方法。

解决方案

当然重构代码是一种方法,但是项目代码量已经非常盘大,如果这时候重构原来的api模块代码工作量将会非常大,这无疑是不可取的方案。但是改代码确是唯一的解决方案,那么有没有途径可以在打包的时候自动改掉原来的代码写法呢,而不用手动改原来的代码。

当然有了,babel插件提供了可以处理AST的功能,可以在babel-loader 处理的时候修改其抽象语法树,改变其代码的写法。还不了解babel 相关知识的朋友可以看下官方文档 主要目的就是将上述的 page.js 的中的代码改写成如下的形式:

import { methodA, methodC } from '@/api/moduleA.js';
import { methodB } from '@/api/moduleB.js';
methodA();
methodB();
methodC();

写成这种形式就可以被webpack的 tree shaking 所处理,只会打包用到的代码。

处理步骤与核心代码

处理步骤

  • 1、找到导入的 API 变量名
  • 2、找到调用的API的模块名称,并收集每个模块下所调用的方法,moduleA: methodA,methodC
  • 3、替换调用的方法,即替换函数表达式,API.moduleA.methodA() 换成 methodA()
  • 4、 删掉原来的import API 的语句,改成从对应的模块里导出使用到的方法。

核心代码

// 存储API的变量名
let variableName = '';
// 存储用到的模块以及对应的方法,格式如下:
/*{
    moduleA: ['methodA1', 'methodA2'],
    moduleB: ['methodB1', 'methodB2'],
}*/
let moduleMap = {
}
function getAPICallInfo(obj) {
    if(obj.object) {
        const result = getAPICallInfo(obj.object);
        return result ? `${result}-${obj.property.name}` : ''
    } else {
        return obj.name === variableName ? variableName : '';
    }
}

module.exports = function (babel) {
    const {
        types: t
    } = babel;
    return {
        visitor:{
            ImportDeclaration(path){
              // 获取导入的 API 变量名
              const specifier = path.node.specifiers[0];
              const { value: pathValue } = path.node.source;
              // 约定 importSource 必须为 @/api
              if(pathValue === '@/api') {
                // 得到模块的变量名,先保存下来
                variableName = specifier.local.name;
                //移除相应的import语句
                path.remove();
              }
            },
            CallExpression(path) {
              // 获取api方法的调用链,API-moduleA-methodA,如果不是API调用的表达式则返回空
              const apiCallInfo = getAPICallInfo(path.node.callee);
              if(apiCallInfo) {
                const [_, moduleName, methodName] = apiCallInfo.split('-');
                // 调用信息先保存下来,方便到最后统一添加import
                if(moduleMap[moduleName]) {
                    moduleMap[moduleName].push(methodName);
                } else {
                    moduleMap[moduleName] = [methodName];
                }
                // 替换表达式
                // API.moduleA.methodA() ---> moduleA_methodA()
                // 前面加个模块名作为前缀,主要就是为了防止,不同模块存在相同的方法名
                path.replaceWith(t.CallExpression(
                    t.identifier(`${moduleName}_${methodName}`),
                        path.node.arguments
                ));
              }
            },
            Program: {
                exit(path) {
                  // 在遍历退出前统一把import语句添加在代码的最前面
                  Object.entries(moduleMap).forEach(([moduleName, methods])=>{
                    const importSource = `../api/${moduleName}.js`;
                    // import声明
                    const specifiers = methods.map(method =>
                        t.importSpecifier(t.identifier(`${moduleName}_${method}`),t.identifier(method))
                    )
                    //创建导入语句
                    const importDeclaration = t.importDeclaration(specifiers,t.stringLiteral(importSource));
                    // 插入到代码最前面
                    path.unshiftContainer('body',importDeclaration);
                  })
                  moduleMap = {}
                }
            }
        }
    };
}

经过该babel插件打包后结果代码如下所示:

import { methodA as moduleA_methodA, methodC as moduleA_methodC} from '@/api/moduleA.js';
import { methodB as moduleB_methodB } from '@/api/moduleB.js';
moduleA_methodA();
moduleB_methodB();
moduleA_methodC();

遗留问题

虽然我们使用babel 初步完成了包优化,上述的代码还是有点局限性的,例如 import 的source 必须要写成‘@/api’的形式,而不支持相对路径('../../api')的写法。还有就是要求API 里定义的模块名称和模块文件名称不一致也会出现问题,例如:

import * as moduleA from './moduleA.js';
import * as moduleB from './moduleB.js';
import * as C from './moduleC.js';
//... 省略很多module
export default {
    moduleA,
    moduleB,
    C, // 该命名就会导致出错
    //....省略很多module
};

针对这些问题,除了批量替换和规范约定外,还是可以写一个eslint插件,自定义校验该语法,如果发现不合理的写法可以直接报错。