經(jīng)過一個(gè)月的探索,我如何將 AST 操作得跟呼吸一樣自然
一直以來,前端同學(xué)們對(duì)于編譯原理都存在著復(fù)雜的看法,大部分人都覺得自己寫業(yè)務(wù)也用不到這么高深的理論知識(shí),況且編譯原理晦澀難懂,并不能提升自己在前端領(lǐng)域內(nèi)的專業(yè)知識(shí)。我不覺得這種想法有什么錯(cuò),況且我之前也是這么認(rèn)為的。而在前端領(lǐng)域內(nèi),和編譯原理強(qiáng)相關(guān)的框架與工具類庫(kù)主要有這么幾種:
- 以 Babel 為代表,主要做 ECMAScript 的語(yǔ)法支持,比如 ?. 與 ?? 對(duì)應(yīng)的 babel-plugin-optional-chaining[1] 與 babel-plugin-nullish-coalescing-operator[2],這一類工具還有 ESBuild 、swc 等。類似的,還有 Scss、Less 這一類最終編譯到 CSS 的“超集”。這一類工具的特點(diǎn)是轉(zhuǎn)換前的代碼與轉(zhuǎn)換產(chǎn)物實(shí)際上是同一層級(jí)的,它們的目標(biāo)是得到標(biāo)準(zhǔn)環(huán)境能夠運(yùn)行的產(chǎn)物。
- 以 Vue、Svelte 還有剛誕生不久的 Astro 為代表,主要做其他自定義文件到 JavaScript(或其他產(chǎn)物) 的編譯轉(zhuǎn)化,如 .vue .svelte .astro 這一類特殊的語(yǔ)法。這一類工具的特點(diǎn)是,轉(zhuǎn)換后的代碼可能會(huì)有多種產(chǎn)物,如 Vue 的 SFC 最終會(huì)構(gòu)建出 HTML、CSS、JavaScript。
- 典型的 DSL 實(shí)現(xiàn),其沒有編譯產(chǎn)物,而是由獨(dú)一的編譯引擎消費(fèi), 如 GraphQL (.graphql)、Prisma (.prisma) 這一類工具庫(kù)(還有更熟悉一些的,如 HTML、SQL、Lex、XML 等),其不需要被編譯為 JavaScript,如 .graphql 文件直接由 GraphQL 各個(gè)語(yǔ)言自己實(shí)現(xiàn)的 Engine 來消費(fèi)。
- 語(yǔ)言層面的轉(zhuǎn)換,TypeScript、Flow、CoffeeScript 等,以及使用者不再一定是狹義上前端開發(fā)者的語(yǔ)言,如張宏波老師的 ReScript(原 BuckleScript)、Dart 等。
無論是哪一種情況,似乎對(duì)于非科班前端的同學(xué)來說都是地獄難度,但其實(shí)社區(qū)一直有各種各樣的方案,來嘗試降低 AST 操作的成本,如 FB 的 jscodeshift[3],相對(duì)于 Babel 的 Visitor API,jscodeshift 提供了命令式 + 鏈?zhǔn)秸{(diào)用的 API,更符合前端同學(xué)的認(rèn)知模式(因?yàn)榫拖?Lodash、RxJS 這樣),看看它們是怎么用的:
示例來自于 神光[4] 老師的文章。由于本文的重點(diǎn)并不是 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();
- };
雖然以上并不是同一類操作的對(duì)比,但還是能看出來二者 API 風(fēng)格的差異。
以及 阿里媽媽 的 gogocode[5],它基于 Babel 封裝了一層,得到了類似 jscodeshift 的命令式 + 鏈?zhǔn)?API,同時(shí)其 API 命名也能看出來主要面對(duì)的的是編譯原理小白,jscodeshift 還有 findVariableDeclaration 這種方法,但 gogocode 就完全是 find 、replace 這種了:
- $(code)
- .find("var a = 1")
- .attr("declarations.0.id.name", "c")
- .root()
- .generate();
看起來真的很簡(jiǎn)單,但這么做也可能會(huì)帶來一定的問題,為什么 Babel 要采用 Visitor API?類似的,還有 GraphQL Tools[6] 中,對(duì) GraphQL Schema 添加 Directive 時(shí)同樣采用的是 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 是聲明式的,我們聲明對(duì)哪一部分語(yǔ)句做哪些處理,比如我要把所有符合條件 If 語(yǔ)句的判斷都加上一個(gè)新的條件,然后 Babel 在遍歷 AST 時(shí)(@babel/traverse),發(fā)現(xiàn) If 語(yǔ)句被注冊(cè)了這么一個(gè)操作,那就執(zhí)行它。而 jscodeshift、gogocode 的 Chaining API 則是命令式(Imperative)的,我們需要先獲取到 AST 節(jié)點(diǎn),然后對(duì)這個(gè)節(jié)點(diǎn)使用其提供(封裝)的 API,這就使得我們很可能遺漏掉一些邊界情況而產(chǎn)生不符預(yù)期的結(jié)果。
而 TypeScript 的 API 呢?TypeScript 的 Compiler API 是絕大部分開放的,足夠用于做一些 CodeMod、AST Checker 這一類的工具,如我們使用原生的 Compiler API ,來組裝一個(gè)函數(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);
以上的代碼將會(huì)創(chuàng)建這么一個(gè)函數(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 語(yǔ)句的條件與代碼塊、函數(shù)的返回語(yǔ)句,最后通過 createFunctionDeclaration 完成組裝。簡(jiǎn)單的看一眼就知道其使用成本不低,你需要對(duì) Expression、Declaration、Statement 等相關(guān)的概念有比較清晰地了解,比如上面的 If 語(yǔ)句需要使用哪些 token 來組裝,還需要了解 TypeScript 的 AST,如 interface、類型別名、裝飾器等(你可以在 ts-ast-viewer[7] 實(shí)時(shí)的查看 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 那樣組裝的場(chǎng)景,ts-morph 沒有提供創(chuàng)建 IfStatement 這一類語(yǔ)句的 API 或者是相關(guān)能力,最方便的方式是直接調(diào)用 writeFunction 來直接寫入。
很明顯,這樣的操作是有利有弊的,我們能夠在創(chuàng)建 Function、Class、Import 這一類聲明時(shí),直接傳入其結(jié)構(gòu)即可,但對(duì)于函數(shù)(類方法)內(nèi)部的語(yǔ)句,ts-morph 目前的確只提供了這種最簡(jiǎn)單的能力,這在很多場(chǎng)景下可能確實(shí)降低了很多成本,但也注定了無法使用在過于復(fù)雜或是要求更嚴(yán)格的場(chǎng)景下。
我在寫到這里時(shí)突然想到了一個(gè)特殊的例子:Vite[9],眾所周知,Vite 會(huì)對(duì)依賴進(jìn)行一次重寫,將裸引入(Bare Import)轉(zhuǎn)換為能實(shí)際鏈接到代碼的正確導(dǎo)入,如 import consola from 'consola' 會(huì)被重寫為 import consola from '/node_modules/consola/src/index.js' (具體路徑由 main 指定,對(duì)于 esm 模塊則會(huì)由 module 指定) ,這一部分的邏輯里主要依賴了 magic-string 和 es-module-lexer 這兩個(gè)庫(kù),通過 es-module-lexer 獲取到導(dǎo)入語(yǔ)句的標(biāo)識(shí)在整個(gè)文件內(nèi)部的起始位置、結(jié)束位置,并通過 magic-string 將其替換為瀏覽器能夠解析的相對(duì)導(dǎo)入(如 importAnalysisBuild.ts[10])。這也帶來了一種新的啟發(fā):對(duì)于僅關(guān)注特定場(chǎng)景的代碼轉(zhuǎn)換,如導(dǎo)入語(yǔ)句之于 Vite,裝飾器之于 Inversify、TypeDI 這樣的場(chǎng)景,大動(dòng)干戈的使用 AST 就屬于殺雞焉用牛刀了。同樣的,在只是對(duì)粒度較粗的 AST 節(jié)點(diǎn)(如整個(gè) Class 結(jié)構(gòu))做操作時(shí),ts-morph 也有著奇效。
實(shí)際上可能還是有類似的場(chǎng)景:
- 我只想傳入文件路徑,然后希望得到這個(gè)文件里所有的 class 名,import 語(yǔ)句的標(biāo)識(shí)(如 fs 即為 import fs from 'fs' 的標(biāo)識(shí)符,也即是 Module Specifier),哪些是具名導(dǎo)入(import { spawn } from 'child_process'),哪些是僅類型導(dǎo)入 (import type { Options } from 'prettier'),然后對(duì)應(yīng)的做一些操作,ts-morph 的復(fù)雜度還是超出了我的預(yù)期。
- 我想學(xué)習(xí)編譯相關(guān)的知識(shí),但我不想從教科書和系統(tǒng)性的課程開始,就是想直接來理論實(shí)踐,看看 AST 操作究竟是怎么能玩出花來,這樣說不定以后學(xué)起來我更感興趣?
- 我在維護(hù)開源項(xiàng)目,準(zhǔn)備發(fā)一個(gè) Breaking Change,我希望提供 CodeMod,幫助用戶直接升級(jí)到新版本代碼,常用的操作可能有更新導(dǎo)入語(yǔ)句、更新 JSX 組件屬性等。或者說在腳手架 + 模板的場(chǎng)景中,我有部分模板只存在細(xì)微的代碼差異,又不想維護(hù)多份文件,而是希望抽離公共部分,并通過 AST 動(dòng)態(tài)的寫入特異于模板的代碼。但是!我沒有學(xué)過編譯原理!也不想花時(shí)間把 ts-morph 的 API 都過一下...
做了這么多鋪墊,是時(shí)候迎來今天的主角了,@ts-morpher[11] 基于 ts-morph 之上又做了一層額外封裝,如果說 TypeScript Compiler API 的復(fù)雜度是 10,那么 ts-morph 的復(fù)雜度大概是 4,而 @ts-morpher 的復(fù)雜度大概只有 1 不到了。作為一個(gè)非科班、沒學(xué)過編譯原理、沒玩過 Babel 的前端仔,它是我在需要做 AST Checker、CodeMod 時(shí)產(chǎn)生的靈感。
我們知道,AST 操作通常可以很輕易的劃分為多個(gè)單元(如果你之前不知道,恭喜你現(xiàn)在知道了),比如獲取節(jié)點(diǎn)-檢查節(jié)點(diǎn)-修改節(jié)點(diǎn) 1-修改節(jié)點(diǎn) 2-保存源文件,這其中的每一個(gè)部分都是可以獨(dú)立拆分的,如果我們能像 Lodash 一樣調(diào)用一個(gè)個(gè)職責(zé)明確的方法,或者像 RxJS 那樣把一個(gè)個(gè)操作符串(pipe)起來,那么 AST 操作好像也沒那么可怕了。可能會(huì)有同學(xué)說,為什么要套娃?一層封一層?那我只能說,管它套娃不套娃呢,好用就完事了,什么 Declaration、Statement、Assignment...,我直接統(tǒng)統(tǒng)摁死,比如像這樣(更多示例請(qǐ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)用會(huì)創(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";
再看一個(gè)稍微復(fù)雜點(diǎn)的例子:
- 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: [] }],
- });
這些代碼將會(huì)創(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;
- }
其實(shí)本質(zhì)上沒有什么復(fù)雜的地方,就是將 ts-morph 的鏈?zhǔn)?API 封裝好了針對(duì)于常用語(yǔ)句類型的增刪改查方法:
- 目前支持了 Import、Export、Class,下一個(gè)支持的應(yīng)該會(huì)是 JSX(TSX)。
- @ts-morpher 將增刪改查方法拆分到了不同的 package 下,如 @ts-morpher/helper 中的方法均用于獲取聲明或聲明 Identifier ,如你可以獲取一個(gè)文件里所有的導(dǎo)入的 Module Specifier(fs 之于 import fsMod from 'fs'),也可以獲取所有導(dǎo)入的聲明,但是你不用管這個(gè)聲明長(zhǎng)什么樣,直接扔給 @ts-morpher/checker ,調(diào)用 checkImportType,看看這是個(gè)啥類型導(dǎo)入。
為什么我要搞這個(gè)東西?因?yàn)樵谖夷壳暗捻?xiàng)目中需要做一些源碼級(jí)的約束,如我想要強(qiáng)制所有主應(yīng)用與子應(yīng)用的入口文件,都導(dǎo)入了某個(gè)新的 SDK,如 import 'foo-error-reporter' ,如果沒有導(dǎo)入的話,那我就給你整一個(gè)!由于不是所有子應(yīng)用、主應(yīng)用都能納入管控,因此就需要這么一個(gè)究極強(qiáng)制卡口來放到 CI 流水線上。如果這樣的話,那么用 ts-morph 可能差不多夠了,誒,不好意思,我就是覺得 AST 操作還可以更簡(jiǎn)單一點(diǎn),干脆自己再搞一層好了。
它也有著 100% 的單測(cè)覆蓋率和 100+ 方法,而是說它還沒有達(dá)到理想狀態(tài),比如把 AST 操作的復(fù)雜度降到 0.5 以下,這一點(diǎn)我想可以通過提供可視化的 playground,讓你點(diǎn)擊按鈕來調(diào)用方法,同時(shí)實(shí)時(shí)的預(yù)覽轉(zhuǎn)換結(jié)果,還可以在這之上組合一些常見的能力,如合并兩個(gè)文件的導(dǎo)入語(yǔ)句,批量更改 JSX 組件等等。
這也是我從零折騰 AST 一個(gè)月來的些許收獲,希望你能有所收獲。
參考資料
[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/