如何通过TypeScript编译器API正确调用导入的方法?
我有一个导出foo函数的库,希望通过TypeScript编译器API转换文件,添加从该库导入foo的语句并立即调用它。
我编写了如下实现代码:
function addImportToEndOfFile(sourceFile: SourceFile, factory: NodeFactory): SourceFile { const functionName = factory.createIdentifier("foo"); const importDeclaration = factory.createImportDeclaration( undefined, factory.createImportClause( false, // is type only undefined, // named (default) import factory.createNamedImports([ factory.createImportSpecifier( false, // isTypeOnly undefined, // propertyName (for e.g. x as x) functionName ), ]) ), factory.createStringLiteral("my-library") ); const methodCall = factory.createCallExpression( functionName, undefined, // type args undefined // function params ); return factory.updateSourceFile(sourceFile, [ ...sourceFile.statements, importDeclaration, methodCall, ] as Statement[]); }
通过ts-patch作为编译器插件运行后,输出结果如下:
var my_library_1 = require("my-library"); foo()
可见导入语句生成正确,但调用表达式并未关联到导入的内容。而如果手动在原始源文件中编写:
import { foo } from "my-library"; foo()
转换后会得到正确的输出:
var my_library_1 = require("my-library"); (0, my_library_1.foo)();
对比AST节点发现,正确调用中的标识符带有flowNode属性,而我生成的节点该属性为undefined:
flowNode: { flags: 2050, id: 0, node: undefined, antecedent: undefined }
我调研了一些开源项目的解决方式,但这些方法都高度依赖打包工具,维护起来较为脆弱:
- 生成内联
require调用,如require("my-library").foo(),但每次调用都会重复输出require和属性访问逻辑 - 直接调用打包工具内置方法(如
__webpack_require__) - 生成动态
import的链式调用,如import("my-library").then(m => m.foo)
TypeScript处理手写代码时能正确生成关联代码,因此我想知道:
- 为什么
factory.createIdentifier("foo")无法生成flowNode属性? - 如何让TypeScript编译器API识别该标识符是对导入内容的引用,而非字面量?
- 是否存在通用的解决办法?
核心原因是手动创建的标识符未与导入的绑定建立语义关联。TypeScript处理手写代码时,会通过绑定解析(binding resolution)将标识符与对应的导入声明关联,而直接用工厂函数创建的节点仅为语法节点,未经过语义分析,因此不会生成flowNode这类语义相关属性。
不要直接操作flowNode(这是TypeScript内部流分析的实现细节,不同版本可能变化,直接修改会导致兼容性问题),以下是两种可靠的通用解决方式:
方式一:复用导入声明中的标识符并触发语义绑定
不要重新创建标识符,直接使用导入声明里ImportSpecifier的name属性来构建调用表达式,再手动触发语义绑定:
function addImportToEndOfFile(sourceFile: SourceFile, factory: NodeFactory, context: ts.TransformationContext): SourceFile { const functionName = factory.createIdentifier("foo"); const importSpecifier = factory.createImportSpecifier( false, undefined, functionName ); const importDeclaration = factory.createImportDeclaration( undefined, factory.createImportClause( false, undefined, factory.createNamedImports([importSpecifier]) ), factory.createStringLiteral("my-library") ); // 复用导入声明中的标识符,而非重新创建 const methodCall = factory.createCallExpression( importSpecifier.name, undefined, undefined ); const updatedSource = factory.updateSourceFile(sourceFile, [ ...sourceFile.statements, importDeclaration, methodCall, ] as Statement[]); // 触发语义绑定,让编译器关联标识符与导入 ts.forEachChild(updatedSource, node => { if (ts.isCallExpression(node)) { context.bindingsChecker?.bind(node); } }); return updatedSource; }
方式二:通过临时源文件让编译器自动完成语义绑定
先构建包含导入和调用的完整代码字符串,让TypeScript重新解析成源文件,再提取其中的节点添加到目标文件中——编译器会自动完成语义绑定:
function addImportToEndOfFile(sourceFile: SourceFile, factory: NodeFactory): SourceFile { // 构建包含正确导入和调用的代码字符串 const tempCode = `import { foo } from "my-library"; foo();`; const tempSourceFile = ts.createSourceFile( "temp.ts", tempCode, sourceFile.languageVersion, true // 启用语义绑定 ); // 从临时文件中提取处理好的导入声明和调用表达式 const [importDeclaration, methodCall] = tempSourceFile.statements as [ts.ImportDeclaration, ts.CallExpression]; return factory.updateSourceFile(sourceFile, [ ...sourceFile.statements, importDeclaration, methodCall, ] as Statement[]); }
这种方式完全利用编译器自身的语义分析能力,生成的节点会自动带有正确的绑定信息,编译时能正确关联到导入内容,且不依赖任何内部属性或打包工具。
内容的提问来源于stack exchange,提问作者toastrackengima

