MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

TypeScript自定义Transformer实战指南

2021-10-247.5k 阅读

一、TypeScript Transformer 简介

在深入探讨 TypeScript 自定义 Transformer 实战之前,我们先来了解一下什么是 Transformer。TypeScript 的 Transformer 是一种可以在 TypeScript 代码编译过程中对 AST(抽象语法树)进行操作的机制。AST 是源代码的一种抽象表示,它以树形结构展示代码的语法结构,每个节点代表一个语法元素。

TypeScript 编译器在处理代码时,会先将代码解析成 AST,然后通过一系列的阶段进行处理,Transformer 就可以在这个过程中对 AST 进行修改。这为我们提供了很大的灵活性,可以实现诸如代码转换、优化、添加自定义逻辑等功能。

例如,我们可以利用 Transformer 将特定的 TypeScript 语法结构转换为更通用的 JavaScript 语法,以确保在更多环境中能够运行,或者在编译时自动添加一些日志记录代码等。

二、准备工作

  1. 环境搭建 首先,确保你已经安装了 TypeScript。你可以通过 npm 全局安装 TypeScript:
npm install -g typescript

接下来,创建一个新的 TypeScript 项目目录,并初始化 package.json

mkdir typescript - transformer - demo
cd typescript - transformer - demo
npm init -y
  1. 项目结构 在项目目录下,创建以下结构:
typescript - transformer - demo
├── src
│   └── main.ts
├── dist
├── tsconfig.json
└── package.json

src/main.ts 中编写一些简单的 TypeScript 代码,例如:

function greet(name: string) {
    return `Hello, ${name}!`;
}
console.log(greet('World'));

然后,配置 tsconfig.json,确保编译输出到 dist 目录:

{
    "compilerOptions": {
        "target": "es5",
        "module": "commonjs",
        "outDir": "./dist",
        "rootDir": "./src"
    }
}

三、编写第一个自定义 Transformer

  1. 了解 Transformer 函数签名 一个基本的 TypeScript Transformer 函数接受一个 TransformationContext 参数,并返回另一个函数。这个返回的函数接受一个 SourceFile 参数,即输入的 TypeScript 文件的 AST 根节点,返回值是经过修改后的 SourceFile
import * as ts from 'typescript';

function myTransformer(context: ts.TransformationContext): ts.Transformer<ts.SourceFile> {
    return (sourceFile: ts.SourceFile) => {
        // 在这里对 sourceFile 进行修改
        return sourceFile;
    };
}
  1. 简单示例:替换字符串字面量 假设我们想要将所有字符串字面量 Hello 替换为 Hi。我们可以在 AST 中遍历节点,找到字符串字面量节点并进行替换。
import * as ts from 'typescript';

function replaceHelloTransformer(context: ts.TransformationContext): ts.Transformer<ts.SourceFile> {
    return (sourceFile: ts.SourceFile) => {
        function visit(node: ts.Node): ts.Node {
            if (ts.isStringLiteral(node) && node.text === 'Hello') {
                return ts.factory.createStringLiteral('Hi');
            }
            return ts.visitEachChild(node, visit, context);
        }
        return ts.visitNode(sourceFile, visit);
    };
}
  1. 应用 Transformer 为了应用我们的 Transformer,我们需要创建一个自定义的 TypeScript 编译器主机。以下是完整的示例代码,用于编译 src/main.ts 并应用我们的 replaceHelloTransformer
import * as ts from 'typescript';
import { replaceHelloTransformer } from './replaceHelloTransformer';

function compileWithTransformer() {
    const compilerOptions: ts.CompilerOptions = {
        target: ts.ScriptTarget.ES5,
        module: ts.ModuleKind.CommonJS
    };

    const program = ts.createProgram(['src/main.ts'], compilerOptions);
    const emitResult = program.emit(undefined, undefined, undefined, undefined, {
        before: [replaceHelloTransformer(program.getTransformationContext())]
    });

    const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
    allDiagnostics.forEach(diagnostic => {
        console.log(ts.formatDiagnostic(diagnostic, {
            getCurrentDirectory: () => process.cwd(),
            getCanonicalFileName: fileName => fileName,
            getNewLine: () => '\n'
        }));
    });
}

compileWithTransformer();

在上述代码中,我们通过 program.emittransformers 选项应用了我们的 replaceHelloTransformer。运行这段代码后,你会发现编译输出的 JavaScript 文件中,所有的 Hello 字符串字面量都被替换成了 Hi

四、Transformer 高级应用:添加日志记录

  1. 需求分析 假设我们想要在每个函数调用前添加一条日志记录,记录函数名和传入的参数。这可以帮助我们在运行时更好地跟踪函数的调用情况。
  2. 实现思路 我们需要在 AST 中找到函数调用表达式节点,然后在其前面插入日志记录代码。首先,我们要创建日志记录表达式,然后将其插入到函数调用表达式之前。
  3. 代码实现
import * as ts from 'typescript';

function addLoggingTransformer(context: ts.TransformationContext): ts.Transformer<ts.SourceFile> {
    return (sourceFile: ts.SourceFile) => {
        function visit(node: ts.Node): ts.Node {
            if (ts.isCallExpression(node)) {
                const functionName = (node.expression as ts.Identifier).text;
                const args = node.arguments.map(arg => ts.visitNode(arg, visit));
                const logArgs = args.map(arg => ts.visitNode(arg, visit)).join(', ');
                const logStatement = ts.factory.createExpressionStatement(
                    ts.factory.createCallExpression(
                        ts.factory.createPropertyAccessExpression(
                            ts.factory.createIdentifier('console'),
                            ts.factory.createIdentifier('log')
                        ),
                        undefined,
                        [
                            ts.factory.createStringLiteral(`Calling ${functionName} with args: [${logArgs}]`)
                        ]
                    )
                );
                return ts.factory.createBlock([logStatement, node], true);
            }
            return ts.visitEachChild(node, visit, context);
        }
        return ts.visitNode(sourceFile, visit);
    };
}
  1. 应用与测试 将上述 addLoggingTransformer 应用到编译过程中,与之前替换字符串字面量的应用方式类似。修改 src/main.ts 中的代码,添加更多函数调用,例如:
function add(a: number, b: number) {
    return a + b;
}
function multiply(a: number, b: number) {
    return a * b;
}

console.log(add(2, 3));
console.log(multiply(4, 5));

运行编译并执行生成的 JavaScript 文件,你会看到在每个函数调用前都打印了相应的日志信息,例如:

Calling add with args: [2, 3]
5
Calling multiply with args: [4, 5]
20

五、处理复杂 AST 结构:类和方法

  1. 类和方法的 AST 结构 在 TypeScript 的 AST 中,类定义由 ts.ClassDeclaration 节点表示,类中的方法由 ts.MethodDeclaration 节点表示。了解这些节点的结构对于我们在类和方法上应用 Transformer 逻辑至关重要。 ts.ClassDeclaration 节点包含 members 属性,它是一个数组,包含类的所有成员,包括方法、属性等。ts.MethodDeclaration 节点有 name(方法名)、parameters(参数列表)和 body(方法体)等属性。
  2. 示例:在类方法中添加前置逻辑 假设我们有一个类,并且想要在每个类方法执行前添加一段通用的前置逻辑,例如打印一条日志表示方法开始执行。
import * as ts from 'typescript';

function addClassMethodLoggingTransformer(context: ts.TransformationContext): ts.Transformer<ts.SourceFile> {
    return (sourceFile: ts.SourceFile) => {
        function visit(node: ts.Node): ts.Node {
            if (ts.isClassDeclaration(node)) {
                const newMembers: ts.ClassElement[] = [];
                node.members.forEach(member => {
                    if (ts.isMethodDeclaration(member)) {
                        const methodName = member.name.text;
                        const logStatement = ts.factory.createExpressionStatement(
                            ts.factory.createCallExpression(
                                ts.factory.createPropertyAccessExpression(
                                    ts.factory.createIdentifier('console'),
                                    ts.factory.createIdentifier('log')
                                ),
                                undefined,
                                [
                                    ts.factory.createStringLiteral(`Starting method ${methodName}`)
                                ]
                            )
                        );
                        const newBody = ts.factory.createBlock([logStatement, member.body!], true);
                        const newMethod = ts.factory.updateMethodDeclaration(
                            member,
                            member.decorators,
                            member.modifiers,
                            member.name,
                            member.questionToken,
                            member.typeParameters,
                            member.parameters,
                            member.type,
                            newBody
                        );
                        newMembers.push(newMethod);
                    } else {
                        newMembers.push(member);
                    }
                });
                return ts.factory.updateClassDeclaration(
                    node,
                    node.decorators,
                    node.modifiers,
                    node.name,
                    node.typeParameters,
                    node.heritageClauses,
                    newMembers
                );
            }
            return ts.visitEachChild(node, visit, context);
        }
        return ts.visitNode(sourceFile, visit);
    };
}
  1. 应用与测试src/main.ts 中定义一个类和一些方法:
class MathUtils {
    add(a: number, b: number) {
        return a + b;
    }
    subtract(a: number, b: number) {
        return a - b;
    }
}

const mathUtils = new MathUtils();
console.log(mathUtils.add(2, 3));
console.log(mathUtils.subtract(5, 2));

应用 addClassMethodLoggingTransformer 进行编译并执行生成的 JavaScript 文件,你会看到在每个类方法执行前都打印了相应的日志信息:

Starting method add
5
Starting method subtract
3

六、结合 Babel 使用 TypeScript Transformer

  1. 为什么结合 Babel 虽然 TypeScript 自身的编译器可以处理很多转换需求,但 Babel 拥有更庞大的插件生态系统,可以处理更广泛的 JavaScript 语法转换和 polyfill 需求。结合 TypeScript Transformer 和 Babel 可以让我们充分利用两者的优势。
  2. 配置流程 首先,安装必要的依赖:
npm install @babel/core @babel/preset - typescript @babel/preset - env @babel/plugin - transform - runtime typescript - babel - plugin

然后,创建一个 Babel 配置文件 .babelrc

{
    "presets": [
        "@babel/preset - typescript",
        "@babel/preset - env"
    ],
    "plugins": [
        "@babel/plugin - transform - runtime",
        "typescript - babel - plugin"
    ]
}

接下来,修改我们的 TypeScript 编译脚本,使其先通过 TypeScript Transformer 进行处理,然后再通过 Babel 进行处理。以下是一个示例脚本:

import * as ts from 'typescript';
import * as babel from '@babel/core';
import { addLoggingTransformer } from './addLoggingTransformer';

async function compileWithTransformerAndBabel() {
    const compilerOptions: ts.CompilerOptions = {
        target: ts.ScriptTarget.ESNext,
        module: ts.ModuleKind.CommonJS
    };

    const program = ts.createProgram(['src/main.ts'], compilerOptions);
    const transformedSourceFile = program.emit(undefined, undefined, undefined, undefined, {
        before: [addLoggingTransformer(program.getTransformationContext())]
    }).outputFiles[0].text;

    const babelResult = await babel.transformAsync(transformedSourceFile, {
        configFile: './.babelrc'
    });

    if (babelResult) {
        console.log(babelResult.code);
    }
}

compileWithTransformerAndBabel();

在上述代码中,我们先通过 TypeScript Transformer 对代码进行转换,然后将转换后的代码传递给 Babel 进行进一步的处理。这样,我们既可以利用 TypeScript Transformer 的强大功能对 AST 进行自定义操作,又可以借助 Babel 的生态系统进行更广泛的语法转换和 polyfill 支持。

七、注意事项和常见问题

  1. AST 节点类型判断 在编写 Transformer 时,准确判断 AST 节点类型非常重要。错误的类型判断可能导致程序崩溃或产生不符合预期的结果。例如,在处理函数调用表达式时,确保 node.expressionts.Identifier 类型,否则获取函数名时可能会出错。
  2. 作用域问题 在修改 AST 时,需要注意作用域的影响。例如,在添加新的变量声明或函数定义时,要确保它们在正确的作用域内。否则,可能会出现变量未定义或作用域冲突等问题。
  3. 性能考虑 复杂的 Transformer 逻辑可能会影响编译性能。尽量避免在 Transformer 中进行过于复杂的计算或大量的节点遍历。如果可能,可以缓存一些计算结果,以减少重复计算。
  4. 与 TypeScript 版本兼容性 TypeScript 的 AST 结构和 Transformer API 可能会随着版本的更新而发生变化。在使用 Transformer 时,要注意与所使用的 TypeScript 版本的兼容性,及时查阅官方文档以获取最新的 API 信息。

通过深入了解和实践 TypeScript 自定义 Transformer,我们可以在编译阶段对代码进行灵活的转换和优化,满足各种特定的需求。无论是简单的字符串替换,还是复杂的类和方法逻辑添加,Transformer 都为我们提供了强大的工具。同时,结合 Babel 等工具,可以进一步拓展我们的代码转换能力,确保代码在不同环境中的兼容性和高效性。在实际项目中,合理运用 Transformer 可以提升代码的质量和可维护性,为开发过程带来更多的便利和价值。