GoGoCode - 像用 Jquery 一样方便地处理 AST

导读

本文是写【gRPC】系列文章孵化出来的,想要实现 grpc-web 的完美封装,需要对 protoc 生成的 JS/TS 文件进行二次加工。自然想到用 AST(抽象语法树)的处理工具,在同事的推荐下,试用了一下 gogocode,的确很方便。

背景

具体背景详见【gRPC】Web 请求的 TS 封装 - 完美版,由于上下文较多,所以本文可以做到即使不爬前文也能看懂 gogocode 怎么用,各位看官可以安心阅读。

GoGoCode 官网的介绍:

GoGoCode 是一个基于 AST 的 JavaScript/Typescript/HTML 代码转换工具,但相较于同类,它提供了更符合直觉的 API:一套类 JQuery 的 API 用来查找和处理 AST、一套和正则表达式接近的语法用来匹配和替换代码

另有与 babeljscodeshift对比,可见它们是同一类东西。本文会比较偏实践,直接提出问题,并展示 gogocode 如何解决,不会有太多理论层面的论述。

目标

没有阅读上下文的同学可以简单理解本文需要作如下操作:

  1. 修改下列 JS 代码,增加一个函数;
  // 原内容
  proto.helloworld.HelloRequest.prototype.getStudentInfo = function() {
    return /** @type{?proto.helloworld.Student} */ (
      jspb.Message.getWrapperField(this, proto.helloworld.Student, 2));
  };
  // 待增加内容
  proto.helloworld.HelloRequest.prototype.getStudentInfoClass = function() {
    return proto.helloworld.Student;
  }
复制代码
  1. 修改下列 TS 代码,为 Class 增加一个方法。
  export class HelloRequest extends jspb.Message {
    // 原内容
    getStudentInfo(): Student | undefined;
    // 待增加内容
    getStudentInfoClass(): typeof Student;
  }
复制代码

正文

处理 helloworld_pb.js 代码

分析

:没有阅读上下文的同学可以忽略分析过程,直接看分析结论。

阅读以下 protoc 生成的 helloworld_pb.js 代码

/**
 * optional Student student_info = 2;
 * @return {?proto.helloworld.Student}
 */
proto.helloworld.HelloRequest.prototype.getStudentInfo = function() {
  return /** @type{?proto.helloworld.Student} */ (
    jspb.Message.getWrapperField(this, proto.helloworld.Student, 2));
};


/**
 * @param {?proto.helloworld.Student|undefined} value
 * @return {!proto.helloworld.HelloRequest} returns this
*/
proto.helloworld.HelloRequest.prototype.setStudentInfo = function(value) {
  return jspb.Message.setWrapperField(this, 2, value);
};
复制代码

我们可以发现:

  1. 所有复杂类型的 get 方法,都会调用 getWrapperField,set 也一样;
  2. getWrapperField 的参数中有我们需要的 Student 类,就是上文中的 proto.helloworld.Student

有了以上两个前提,理论上我们就可以用 AST 工具做以下操作,来达到最终目的:

  1. 获取到所有 getWrapperField 调用,并取出其第二个参数 A
  2. 同时获取到外层的函数名称 getXXX,上例中为 getStudentInfo
  3. 生成 getXXXClass 函数返回 A,上例为
proto.helloworld.HelloRequest.prototype.getStudentInfoClass = function() { 
  return proto.helloworld.Student
};
复制代码

实现

直接上核心代码:

const $ = require("gogocode");

const classMethodList = []; // 收集被命中的 method,为后续 ts 处理提供筛选范围

const newCode = $(code).find(
    `$_$0 = function() {
      return jspb.Message.getWrapperField(this, $_$1, $_$2);
    };`
  )
  .each((item) => {
    const getMethod = item.match[0][0].value;
    const paramsClass = item.match[1][0].value;
    classMethodList.push(getMethod);
    item.after(`
${getMethod}Class = function() {
  return ${paramsClass};
}
  `);
  })
  .root()
  .generate();
复制代码

简要说明一下:

  1. code 即为 protoc 生成的 helloworld_pb.js 的代码;
  2. gogocode 的代码风格很像 jQuery,可读性比较强,findeachafter 等语法,即使没用过 jQuery,大概也能猜到是什么意思;
  3. 代码中的 $_$x 是正则占位符,下面的 item.match[x][x].value 就是在读取它们的值。

处理后生成的 newCode 代码片段为:

proto.helloworld.HelloRequest.prototype.getStudentInfo = function() {
  return /** @type{?proto.helloworld.Student} */ (
    jspb.Message.getWrapperField(this, proto.helloworld.Student, 2));
};
proto.helloworld.HelloRequest.prototype.getStudentInfoClass = function() {
  return proto.helloworld.Student;
}
复制代码
  1. 新生成的代码(after 方法里的内容)可以根据自己的需求定义,理论上不用非得是一个函数,直接定义一个属性反而更简单,本文为了易读性使用了函数。

处理 helloworld_pb.d.ts 文件

分析

阅读以下 protoc 生成的 helloworld_pb.d.ts 代码

export class HelloRequest extends jspb.Message {
  // other code
  getStudentInfo(): Student | undefined;
  setStudentInfo(value?: Student): HelloRequest;
  hasStudentInfo(): boolean;
  clearStudentInfo(): HelloRequest;
  // other code
}
复制代码

我们希望插入一条

    getStudentInfoClass(): typeof Student;
复制代码

利用 gogocode 提取出 getStudentInfoStudent 后拼出需要的代码就行了。但是,别看修改量不大,想实现也不太容易,需要注意几点:

  1. 理论上这部分修改的代码需要依赖修改 js 文件时,遍历的 getMethod 信息进行筛选,所以需要用一个 classMethodList 数组来收集被命中的方法名,详见上文的代码备注;
  2. typeof 这个 ts 的关键字目前 gogocode 没有提供方便的生成 AST 的方法(也可能是用的不熟),所以不得不引入 @babel/types 来生成需要的 AST 对象;
  3. 基于上一点,生成代码有两种思路:一是 clone 一份 getStudentInfo 的 AST,修改其属性;二是直接用 @babel/types 来生成。前者的易读性更好,但是代码量较多;后者代码量较少,但是需要具备一定的 babel 知识,各位视情况而定。不过即使是第一种方法,也需要稍微用一下 @babel/types 来解决 typeof 的问题。

实现

const $ = require("gogocode");
const t = require("@babel/types");

const tsNewCode = $(tsCode)
  .find("$_$1(): $_$2 | undefined")
  .each((item) => {
    const callee = item.match[1][0].value; // getStudentInfo
    const returnNode = item.match[2][0]; // returnType AST

    if (returnNode.type === "TSTypeReference" && classMethodList.includes(callee)) {
      // 方法一:clone item
      const newNode = item.clone();
      // t.tsTypeQuery 就是生成 typeof AST 的方法
      newNode.attr({
        "[0].nodePath.node.key.name": `${callee}Class`,
        "[0].nodePath.node.returnType.typeAnnotation": t.tsTypeQuery(
          returnNode.typeName // Student 的 AST
        ),
      });
      item.after(newNode);
      /* 方法二: @babel/types
      item.after(
        t.tsDeclareMethod(
          null,
          t.identifier(`${callee}Class`),
          null,
          [],
          t.tsTypeAnnotation(t.tsTypeQuery(returnNode.typeName))
        )
      );
      */
    }
  })
  .root()
  .generate();
复制代码

最终生成的代码片段:

    // other code
    getStudentInfo(): Student | undefined;

    getStudentInfoClass(): typeof Student;
    // other code
复制代码

处理 helloworld_grpc_web_pb.js

在《完美版》中还提到,需要对 sayHello 所在的文件进行处理,让其能够返回 sayHello 等 Method 的入参类。思路与方法与上文大同小异,就不赘述了。

值得一提的是本来它内部是有一个对象能够获取到这个信息的,但是因为有 bug,没有办法获取到。见如下代码:

const methodDescriptor_Greeter_SayHello = new grpc.web.MethodDescriptor(
  '/helloworld.Greeter/SayHello',
  grpc.web.MethodType.UNARY,
  proto.helloworld.HelloRequest,
  proto.helloworld.HelloReply,
  /**
   * @param {!proto.helloworld.HelloRequest} request
   * @return {!Uint8Array}
   */
  function(request) {
    return request.serializeBinary();
  },
  proto.helloworld.HelloReply.deserializeBinary
);
复制代码

实际上调用 methodDescriptor_Greeter_XXX.getRequestMessageCtor() 就能够获取到 HelloRequest 了,但是打包后的代码中,该对象并没有这个方法,所以根本调不到。

这个 bug 是同事发现的(没错,就是《完美版》中提到的那个同事),而且据说已经合了相关 PR,估计不久后会发布新版本吧。如果新版本自带了这个能力,我们也就不用再加工它了。

结语

本文的内容都是基于最简单的 demo,所以在用在实践当中时有可能会有需要调整的地方。所以各位重要的还是理解思想,知道这个思路可行即可,具体问题具体分析。

不过 gogocode 的确是一个好用的操作 AST 的工具,而且它还提供了 Vue2 转 Vue3 的功能,正好符合团队诉求,后续如果还有什么好用的工具会尽量分享出来。另外,简单了解下 Babel 尤其是 @babel/types 的用法其实性价比挺高的。

谨记,你是在寻找最好的答案,而不是你自己能得出的最好答案。——Ray Dalio

おすすめ

転載: juejin.im/post/7034082080451624974