經(jīng)過一個月的探索,我如何將 AST 操作得跟呼吸一樣自然
一直以來,前端同學(xué)們對于編譯原理都存在著復(fù)雜的看法,大部分人都覺得自己寫業(yè)務(wù)也用不到這么高深的理論知識,況且編譯原理晦澀難懂,并不能提升自己在前端領(lǐng)域內(nèi)的專業(yè)知識。我不覺得這種想法有什么錯,況且我之前也是這么認(rèn)為的。而在前端領(lǐng)域內(nèi),和編譯原理強相關(guān)的框架與工具類庫主要有這么幾種:
- 以 Babel 為代表,主要做 ECMAScript 的語法支持,比如 ?. 與 ?? 對應(yīng)的 babel-plugin-optional-chaining[1] 與 babel-plugin-nullish-coalescing-operator[2],這一類工具還有 ESBuild 、swc 等。類似的,還有 Scss、Less 這一類最終編譯到 CSS 的“超集”。這一類工具的特點是轉(zhuǎn)換前的代碼與轉(zhuǎn)換產(chǎn)物實際上是同一層級的,它們的目標(biāo)是得到標(biāo)準(zhǔn)環(huán)境能夠運行的產(chǎn)物。
- 以 Vue、Svelte 還有剛誕生不久的 Astro 為代表,主要做其他自定義文件到 JavaScript(或其他產(chǎn)物) 的編譯轉(zhuǎn)化,如 .vue .svelte .astro 這一類特殊的語法。這一類工具的特點是,轉(zhuǎn)換后的代碼可能會有多種產(chǎn)物,如 Vue 的 SFC 最終會構(gòu)建出 HTML、CSS、JavaScript。
- 典型的 DSL 實現(xiàn),其沒有編譯產(chǎn)物,而是由獨一的編譯引擎消費, 如 GraphQL (.graphql)、Prisma (.prisma) 這一類工具庫(還有更熟悉一些的,如 HTML、SQL、Lex、XML 等),其不需要被編譯為 JavaScript,如 .graphql 文件直接由 GraphQL 各個語言自己實現(xiàn)的 Engine 來消費。
- 語言層面的轉(zhuǎn)換,TypeScript、Flow、CoffeeScript 等,以及使用者不再一定是狹義上前端開發(fā)者的語言,如張宏波老師的 ReScript(原 BuckleScript)、Dart 等。
無論是哪一種情況,似乎對于非科班前端的同學(xué)來說都是地獄難度,但其實社區(qū)一直有各種各樣的方案,來嘗試降低 AST 操作的成本,如 FB 的 jscodeshift[3],相對于 Babel 的 Visitor API,jscodeshift 提供了命令式 + 鏈?zhǔn)秸{(diào)用的 API,更符合前端同學(xué)的認(rèn)知模式(因為就像 Lodash、RxJS 這樣),看看它們是怎么用的:
示例來自于 神光[4] 老師的文章。由于本文的重點并不是 jscodeshift 與 gogocode,這里就直接使用現(xiàn)成的示例了。
- // Babel
- const { declare } = require("@babel/helper-plugin-utils");
- const noFuncAssignLint = declare((api, options, dirname) => {
- api.assertVersion(7);
- return {
- pre(file) {
- file.set("errors", []);
- },
- visitor: {
- AssignmentExpression(path, state) {
- const errors = state.file.get("errors");
- const assignTarget = path.get("left").toString();
- const binding = path.scope.getBinding(assignTarget);
- if (binding) {
- if (
- binding.path.isFunctionDeclaration() ||
- binding.path.isFunctionExpression()
- ) {
- const tmp = Error.stackTraceLimit;
- Error.stackTraceLimit = 0;
- errors.push(
- path.buildCodeFrameError("can not reassign to function", Error)
- );
- Error.stackTraceLimit = tmp;
- }
- }
- },
- },
- post(file) {
- console.log(file.get("errors"));
- },
- };
- });
- module.exports = noFuncAssignLint;
- // jscodeshift
- module.exports = function (fileInfo, api) {
- return api
- .jscodeshift(fileInfo.source)
- .findVariableDeclarators("foo")
- .renameTo("bar")
- .toSource();
- };
雖然以上并不是同一類操作的對比,但還是能看出來二者 API 風(fēng)格的差異。
以及 阿里媽媽 的 gogocode[5],它基于 Babel 封裝了一層,得到了類似 jscodeshift 的命令式 + 鏈?zhǔn)?API,同時其 API 命名也能看出來主要面對的的是編譯原理小白,jscodeshift 還有 findVariableDeclaration 這種方法,但 gogocode 就完全是 find 、replace 這種了:
- $(code)
- .find("var a = 1")
- .attr("declarations.0.id.name", "c")
- .root()
- .generate();
看起來真的很簡單,但這么做也可能會帶來一定的問題,為什么 Babel 要采用 Visitor API?類似的,還有 GraphQL Tools[6] 中,對 GraphQL Schema 添加 Directive 時同樣采用的是 Visitor API,如:
- import { SchemaDirectiveVisitor } from "graphql-tools";
- export class DeprecatedDirective extends SchemaDirectiveVisitor {
- visitSchema(schema: GraphQLSchema) {}
- visitObject(object: GraphQLObjectType) {}
- visitFieldDefinition(field: GraphQLField<any, any>) {}
- visitArgumentDefinition(argument: GraphQLArgument) {}
- visitInterface(iface: GraphQLInterfaceType) {}
- visitInputObject(object: GraphQLInputObjectType) {}
- visitInputFieldDefinition(field: GraphQLInputField) {}
- visitScalar(scalar: GraphQLScalarType) {}
- visitUnion(union: GraphQLUnionType) {}
- visitEnum(type: GraphQLEnumType) {}
- visitEnumValue(value: GraphQLEnumValue) {}
- }
Visitor API 是聲明式的,我們聲明對哪一部分語句做哪些處理,比如我要把所有符合條件 If 語句的判斷都加上一個新的條件,然后 Babel 在遍歷 AST 時(@babel/traverse),發(fā)現(xiàn) If 語句被注冊了這么一個操作,那就執(zhí)行它。而 jscodeshift、gogocode 的 Chaining API 則是命令式(Imperative)的,我們需要先獲取到 AST 節(jié)點,然后對這個節(jié)點使用其提供(封裝)的 API,這就使得我們很可能遺漏掉一些邊界情況而產(chǎn)生不符預(yù)期的結(jié)果。
而 TypeScript 的 API 呢?TypeScript 的 Compiler API 是絕大部分開放的,足夠用于做一些 CodeMod、AST Checker 這一類的工具,如我們使用原生的 Compiler API ,來組裝一個函數(shù):
- import * as ts from "typescript";
- function makeFactorialFunction() {
- const functionName = ts.factory.createIdentifier("factorial");
- const paramName = ts.factory.createIdentifier("n");
- const paramType = ts.factory.createKeywordTypeNode(
- ts.SyntaxKind.NumberKeyword
- );
- const paramModifiers = ts.factory.createModifier(
- ts.SyntaxKind.ReadonlyKeyword
- );
- const parameter = ts.factory.createParameterDeclaration(
- undefined,
- [paramModifiers],
- undefined,
- paramName,
- undefined,
- paramType
- );
- // n <= 1
- const condition = ts.factory.createBinaryExpression(
- paramName,
- ts.SyntaxKind.LessThanEqualsToken,
- ts.factory.createNumericLiteral(1)
- );
- const ifBody = ts.factory.createBlock(
- [ts.factory.createReturnStatement(ts.factory.createNumericLiteral(1))],
- true
- );
- const decrementedArg = ts.factory.createBinaryExpression(
- paramName,
- ts.SyntaxKind.MinusToken,
- ts.factory.createNumericLiteral(1)
- );
- const recurse = ts.factory.createBinaryExpression(
- paramName,
- ts.SyntaxKind.AsteriskToken,
- ts.factory.createCallExpression(functionName, undefined, [decrementedArg])
- );
- const statements = [
- ts.factory.createIfStatement(condition, ifBody),
- ts.factory.createReturnStatement(recurse),
- ];
- return ts.factory.createFunctionDeclaration(
- undefined,
- [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
- undefined,
- functionName,
- undefined,
- [parameter],
- ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
- ts.factory.createBlock(statements, true)
- );
- }
- const resultFile = ts.createSourceFile(
- "func.ts",
- "",
- ts.ScriptTarget.Latest,
- false,
- ts.ScriptKind.TS
- );
- const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
- const result = printer.printNode(
- ts.EmitHint.Unspecified,
- makeFactorialFunction(),
- resultFile
- );
- console.log(result);
以上的代碼將會創(chuàng)建這么一個函數(shù):
- export function factorial(readonly n: number): number {
- if (n <= 1) {
- return 1;
- }
- return n * factorial(n - 1);
- }
可以看到,TypeScript Compiler API 屬于命令式,但和 jscodeshift 不同,它的 API 不是鏈?zhǔn)降模袷墙M合式的?我們從 identifier 開始創(chuàng)建,組裝參數(shù)、if 語句的條件與代碼塊、函數(shù)的返回語句,最后通過 createFunctionDeclaration 完成組裝。簡單的看一眼就知道其使用成本不低,你需要對 Expression、Declaration、Statement 等相關(guān)的概念有比較清晰地了解,比如上面的 If 語句需要使用哪些 token 來組裝,還需要了解 TypeScript 的 AST,如 interface、類型別名、裝飾器等(你可以在 ts-ast-viewer[7] 實時的查看 TypeScript AST 結(jié)構(gòu))。
因此,在這種情況下 ts-morph[8] 誕生了(原 ts-simple-ast ),它在 TypeScript Compiler API 的基礎(chǔ)上做了一層封裝,大大降低了使用成本,如上面的例子轉(zhuǎn)換為 ts-morph 是這樣的:
- import { Project } from "ts-morph";
- const s = new Project().createSourceFile("./func.ts", "");
- s.addFunction({
- isExported: true,
- name: "factorial",
- returnType: "number",
- parameters: [
- {
- name: "n",
- isReadonly: true,
- type: "number",
- },
- ],
- statements: (writer) => {
- writer.write(`
- if (n <=1) {
- return 1;
- }
- return n * factorial(n - 1);
- `);
- },
- }).addStatements([]);
- s.saveSync();
- console.log(s.getText());
是的,為了避免像 TypeScript Compiler API 那樣組裝的場景,ts-morph 沒有提供創(chuàng)建 IfStatement 這一類語句的 API 或者是相關(guān)能力,最方便的方式是直接調(diào)用 writeFunction 來直接寫入。
很明顯,這樣的操作是有利有弊的,我們能夠在創(chuàng)建 Function、Class、Import 這一類聲明時,直接傳入其結(jié)構(gòu)即可,但對于函數(shù)(類方法)內(nèi)部的語句,ts-morph 目前的確只提供了這種最簡單的能力,這在很多場景下可能確實降低了很多成本,但也注定了無法使用在過于復(fù)雜或是要求更嚴(yán)格的場景下。
我在寫到這里時突然想到了一個特殊的例子:Vite[9],眾所周知,Vite 會對依賴進(jìn)行一次重寫,將裸引入(Bare Import)轉(zhuǎn)換為能實際鏈接到代碼的正確導(dǎo)入,如 import consola from 'consola' 會被重寫為 import consola from '/node_modules/consola/src/index.js' (具體路徑由 main 指定,對于 esm 模塊則會由 module 指定) ,這一部分的邏輯里主要依賴了 magic-string 和 es-module-lexer 這兩個庫,通過 es-module-lexer 獲取到導(dǎo)入語句的標(biāo)識在整個文件內(nèi)部的起始位置、結(jié)束位置,并通過 magic-string 將其替換為瀏覽器能夠解析的相對導(dǎo)入(如 importAnalysisBuild.ts[10])。這也帶來了一種新的啟發(fā):對于僅關(guān)注特定場景的代碼轉(zhuǎn)換,如導(dǎo)入語句之于 Vite,裝飾器之于 Inversify、TypeDI 這樣的場景,大動干戈的使用 AST 就屬于殺雞焉用牛刀了。同樣的,在只是對粒度較粗的 AST 節(jié)點(如整個 Class 結(jié)構(gòu))做操作時,ts-morph 也有著奇效。
實際上可能還是有類似的場景:
- 我只想傳入文件路徑,然后希望得到這個文件里所有的 class 名,import 語句的標(biāo)識(如 fs 即為 import fs from 'fs' 的標(biāo)識符,也即是 Module Specifier),哪些是具名導(dǎo)入(import { spawn } from 'child_process'),哪些是僅類型導(dǎo)入 (import type { Options } from 'prettier'),然后對應(yīng)的做一些操作,ts-morph 的復(fù)雜度還是超出了我的預(yù)期。
- 我想學(xué)習(xí)編譯相關(guān)的知識,但我不想從教科書和系統(tǒng)性的課程開始,就是想直接來理論實踐,看看 AST 操作究竟是怎么能玩出花來,這樣說不定以后學(xué)起來我更感興趣?
- 我在維護(hù)開源項目,準(zhǔn)備發(fā)一個 Breaking Change,我希望提供 CodeMod,幫助用戶直接升級到新版本代碼,常用的操作可能有更新導(dǎo)入語句、更新 JSX 組件屬性等。或者說在腳手架 + 模板的場景中,我有部分模板只存在細(xì)微的代碼差異,又不想維護(hù)多份文件,而是希望抽離公共部分,并通過 AST 動態(tài)的寫入特異于模板的代碼。但是!我沒有學(xué)過編譯原理!也不想花時間把 ts-morph 的 API 都過一下...
做了這么多鋪墊,是時候迎來今天的主角了,@ts-morpher[11] 基于 ts-morph 之上又做了一層額外封裝,如果說 TypeScript Compiler API 的復(fù)雜度是 10,那么 ts-morph 的復(fù)雜度大概是 4,而 @ts-morpher 的復(fù)雜度大概只有 1 不到了。作為一個非科班、沒學(xué)過編譯原理、沒玩過 Babel 的前端仔,它是我在需要做 AST Checker、CodeMod 時產(chǎn)生的靈感。
我們知道,AST 操作通常可以很輕易的劃分為多個單元(如果你之前不知道,恭喜你現(xiàn)在知道了),比如獲取節(jié)點-檢查節(jié)點-修改節(jié)點 1-修改節(jié)點 2-保存源文件,這其中的每一個部分都是可以獨立拆分的,如果我們能像 Lodash 一樣調(diào)用一個個職責(zé)明確的方法,或者像 RxJS 那樣把一個個操作符串(pipe)起來,那么 AST 操作好像也沒那么可怕了。可能會有同學(xué)說,為什么要套娃?一層封一層?那我只能說,管它套娃不套娃呢,好用就完事了,什么 Declaration、Statement、Assignment...,我直接統(tǒng)統(tǒng)摁死,比如像這樣(更多示例請參考官網(wǎng)):
- import { Project } from "ts-morph";
- import path from "path";
- import fs from "fs-extra";
- import { createImportDeclaration } from "@ts-morpher/creator";
- import { checkImportExistByModuleSpecifier } from "@ts-morpher/checker";
- import { ImportType } from "@ts-morpher/types";
- const sourceFilePath = path.join(__dirname, "./source.ts");
- fs.rmSync(sourceFilePath);
- fs.ensureFileSync(sourceFilePath);
- const p = new Project();
- const source = p.addSourceFileAtPath(sourceFilePath);
- createImportDeclaration(source, "fs", "fs-extra", ImportType.DEFAULT_IMPORT);
- createImportDeclaration(source, "path", "path", ImportType.NAMESPACE_IMPORT);
- createImportDeclaration(
- source,
- ["exec", "execSync", "spawn", "spawnSync"],
- "child_process",
- ImportType.NAMED_IMPORT
- );
- createImportDeclaration(
- source,
- // First item will be regarded as default import, and rest will be used as named imports.
- ["ts", "transpileModule", "CompilerOptions", "factory"],
- "typescript",
- ImportType.DEFAULT_WITH_NAMED_IMPORT
- );
- createImportDeclaration(
- source,
- ["SourceFile", "VariableDeclarationKind"],
- "ts-morph",
- ImportType.NAMED_IMPORT,
- true
- );
這一連串的方法調(diào)用會創(chuàng)建:
- import fs from "fs-extra";
- import * as path from "path";
- import { exec, execSync, spawn, spawnSync } from "child_process";
- import ts, { transpileModule, CompilerOptions, factory } from "typescript";
- import type { SourceFile, VariableDeclarationKind } from "ts-morph";
再看一個稍微復(fù)雜點的例子:
- import { Project } from "ts-morph";
- import path from "path";
- import fs from "fs-extra";
- import {
- createBaseClass,
- createBaseClassProp,
- createBaseClassDecorator,
- createBaseInterfaceExport,
- createImportDeclaration,
- } from "@ts-morpher/creator";
- import { ImportType } from "@ts-morpher/types";
- const sourceFilePath = path.join(__dirname, "./source.ts");
- fs.rmSync(sourceFilePath);
- fs.ensureFileSync(sourceFilePath);
- const p = new Project();
- const source = p.addSourceFileAtPath(sourceFilePath);
- createImportDeclaration(
- source,
- ["PrimaryGeneratedColumn", "Column", "BaseEntity", "Entity"],
- "typeorm",
- ImportType.NAMED_IMPORTS
- );
- createBaseInterfaceExport(
- source,
- "IUser",
- [],
- [],
- [
- {
- name: "id",
- type: "number",
- },
- {
- name: "name",
- type: "string",
- },
- ]
- );
- createBaseClass(source, {
- name: "User",
- isDefaultExport: true,
- extends: "BaseEntity",
- implements: ["IUser"],
- });
- createBaseClassDecorator(source, "User", {
- name: "Entity",
- arguments: [],
- });
- createBaseClassProp(source, "User", {
- name: "id",
- type: "number",
- decorators: [{ name: "PrimaryGeneratedColumn", arguments: [] }],
- });
- createBaseClassProp(source, "User", {
- name: "name",
- type: "string",
- decorators: [{ name: "Column", arguments: [] }],
- });
這些代碼將會創(chuàng)建:
- import { PrimaryGeneratedColumn, Column, BaseEntity, Entity } from "typeorm";
- export interface IUser {
- id: number;
- name: string;
- }
- @Entity()
- export default class User extends BaseEntity implements IUser {
- @PrimaryGeneratedColumn()
- id: number;
- @Column()
- name: string;
- }
其實本質(zhì)上沒有什么復(fù)雜的地方,就是將 ts-morph 的鏈?zhǔn)?API 封裝好了針對于常用語句類型的增刪改查方法:
- 目前支持了 Import、Export、Class,下一個支持的應(yīng)該會是 JSX(TSX)。
- @ts-morpher 將增刪改查方法拆分到了不同的 package 下,如 @ts-morpher/helper 中的方法均用于獲取聲明或聲明 Identifier ,如你可以獲取一個文件里所有的導(dǎo)入的 Module Specifier(fs 之于 import fsMod from 'fs'),也可以獲取所有導(dǎo)入的聲明,但是你不用管這個聲明長什么樣,直接扔給 @ts-morpher/checker ,調(diào)用 checkImportType,看看這是個啥類型導(dǎo)入。
為什么我要搞這個東西?因為在我目前的項目中需要做一些源碼級的約束,如我想要強制所有主應(yīng)用與子應(yīng)用的入口文件,都導(dǎo)入了某個新的 SDK,如 import 'foo-error-reporter' ,如果沒有導(dǎo)入的話,那我就給你整一個!由于不是所有子應(yīng)用、主應(yīng)用都能納入管控,因此就需要這么一個究極強制卡口來放到 CI 流水線上。如果這樣的話,那么用 ts-morph 可能差不多夠了,誒,不好意思,我就是覺得 AST 操作還可以更簡單一點,干脆自己再搞一層好了。
它也有著 100% 的單測覆蓋率和 100+ 方法,而是說它還沒有達(dá)到理想狀態(tài),比如把 AST 操作的復(fù)雜度降到 0.5 以下,這一點我想可以通過提供可視化的 playground,讓你點擊按鈕來調(diào)用方法,同時實時的預(yù)覽轉(zhuǎn)換結(jié)果,還可以在這之上組合一些常見的能力,如合并兩個文件的導(dǎo)入語句,批量更改 JSX 組件等等。
這也是我從零折騰 AST 一個月來的些許收獲,希望你能有所收獲。
參考資料
[1]babel-plugin-optional-chaining: https://github.com/babel/babel/blob/main/packages/babel-plugin-proposal-optional-chaining
[2]babel-plugin-nullish-coalescing-operator: https://github.com/babel/babel/blob/main/packages/babel-plugin-proposal-nullish-coalescing-operator
[3]jscodeshift: https://github.com/facebook/jscodeshift
[4]神光: https://www.zhihu.com/people/di-xu-guang-50
[5]gogocode: https://gogocode.io/
[6]GraphQL Tools: https://github.com/ardatan/graphql-tools
[7]ts-ast-viewer: https://ts-ast-viewer.com/#
[8]ts-morph: https://ts-morph.com/
[9]Vite: https://github.com/vitejs/vite
[10]importAnalysisBuild.ts: https://github.com/vitejs/vite/blob/545b1f13cec069bbae5f37c7540171128f439e7b/packages/vite/src/node/plugins/importAnalysisBuild.ts#L217
[11]@ts-morpher: https://ts-morpher-docs.vercel.app/