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、一套和正则表达式接近的语法用来匹配和替换代码
另有与 babel
和 jscodeshift
的对比,可见它们是同一类东西。本文会比较偏实践,直接提出问题,并展示 gogocode 如何解决,不会有太多理论层面的论述。
目标
没有阅读上下文的同学可以简单理解本文需要作如下操作:
修改下列 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; } 复制代码
修改下列 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); }; 复制代码
我们可以发现:
所有复杂类型的 get 方法,都会调用
getWrapperField
,set 也一样;getWrapperField
的参数中有我们需要的Student
类,就是上文中的proto.helloworld.Student
;
有了以上两个前提,理论上我们就可以用 AST
工具做以下操作,来达到最终目的:
获取到所有
getWrapperField
调用,并取出其第二个参数A
;同时获取到外层的函数名称
getXXX
,上例中为getStudentInfo
;生成
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(); 复制代码
简要说明一下:
code
即为 protoc 生成的helloworld_pb.js
的代码;gogocode 的代码风格很像 jQuery,可读性比较强,
find
、each
、after
等语法,即使没用过 jQuery,大概也能猜到是什么意思;代码中的
$_$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; } 复制代码
新生成的代码(
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 提取出 getStudentInfo
和 Student
后拼出需要的代码就行了。但是,别看修改量不大,想实现也不太容易,需要注意几点:
理论上这部分修改的代码需要依赖修改 js 文件时,遍历的
getMethod
信息进行筛选,所以需要用一个classMethodList
数组来收集被命中的方法名,详见上文的代码备注;typeof
这个 ts 的关键字目前gogocode
没有提供方便的生成 AST 的方法(也可能是用的不熟),所以不得不引入@babel/types
来生成需要的 AST 对象;基于上一点,生成代码有两种思路:一是 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
作者:DylanlZhao
链接:https://juejin.cn/post/7034082080451624974