GoGoCode - 像用 Jquery 一樣方便地處理 AST

語言: CN / TW / HK

highlight: vs2015

導讀

本文是寫【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 程式碼,增加一個函式; 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; } 2. 修改下列 TS 程式碼,為 Class 增加一個方法。 ts export class HelloRequest extends jspb.Message { // 原內容 getStudentInfo(): Student | undefined; // 待增加內容 getStudentInfoClass(): typeof Student; }

正文

處理 helloworld_pb.js 程式碼

分析

:沒有閱讀上下文的同學可以忽略分析過程,直接看分析結論。

閱讀以下 protoc 生成的 helloworld_pb.js 程式碼 ```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`,上例為js proto.helloworld.HelloRequest.prototype.getStudentInfoClass = function() { return proto.helloworld.Student }; ```

實現

直接上核心程式碼: ```js 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,可讀性比較強,`find`、`each`、`after` 等語法,即使沒用過 jQuery,大概也能猜到是什麼意思; 3. 程式碼中的 `$_$x` 是正則佔位符,下面的 `item.match[x][x].value` 就是在讀取它們的值。 處理後生成的 newCode 程式碼片段為: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; } `` 4. 新生成的程式碼(after` 方法裡的內容)可以根據自己的需求定義,理論上不用非得是一個函式,直接定義一個屬性反而更簡單,本文為了易讀性使用了函式。

處理 helloworld_pb.d.ts 檔案

分析

閱讀以下 protoc 生成的 helloworld_pb.d.ts 程式碼 ts export class HelloRequest extends jspb.Message { // other code getStudentInfo(): Student | undefined; setStudentInfo(value?: Student): HelloRequest; hasStudentInfo(): boolean; clearStudentInfo(): HelloRequest; // other code } 我們希望插入一條 ts 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 的問題。

實現

```js 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(); 最終生成的程式碼片段:ts // other code getStudentInfo(): Student | undefined;

getStudentInfoClass(): typeof Student;
// other code

```

處理 helloworld_grpc_web_pb.js

在《完美版》中還提到,需要對 sayHello 所在的檔案進行處理,讓其能夠返回 sayHello 等 Method 的入參類。思路與方法與上文大同小異,就不贅述了。

值得一提的是本來它內部是有一個物件能夠獲取到這個資訊的,但是因為有 bug,沒有辦法獲取到。見如下程式碼: js 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