- 开发工具
【免费下载链接】ts-morph
TypeScript Compiler API wrapper for static analysis and programmatic code changes.
导读
对象字面量(Object Literal)是 JavaScript / TypeScript 中最常用的数据结构表达形式,而 ts-morph 作为 TypeScript 编译器 API 的封装库,为它提供了完整的“读取—修改—写出”能力。本文以 ts-morph 官方文档中 object-literal-expressions.md 为骨架,结合 ObjectLiteralExpression 源码 与 单元测试,系统讲解如何获取对象字面量的属性、如何通过结构(Structure)以编程方式新增各类成员(属性赋值、简写属性、展开赋值、get/set 访问器、方法)以及如何删除成员,并深入剖析其底层的插入与格式化原理,帮助读者在静态分析与代码生成场景中直接落地使用。
什么是对象字面量表达式
在 ts-morph 中,对象字面量表达式对应 TypeScript AST 中的ObjectLiteralExpression节点,其外观就是我们在代码中常见的{ ... }字面量。一个对象字面量可以混合包含多种成员,其典型形态如下:
const obj = { propertyAssignment: 5, shorthandPropertyAssignment, ...spreadAssignment, get getAccessor() { return 5; }, set setAccessor(value: number) { // do something }, method() { return "some string"; }, };上述成员依次对应 ts-morph 中以下节点类型与结构:
| 成员形态 | 节点类 | 对应结构 |
|---|---|---|
key: value | PropertyAssignment | PropertyAssignmentStructure |
key(简写属性) | ShorthandPropertyAssignment | ShorthandPropertyAssignmentStructure |
...expr | SpreadAssignment | SpreadAssignmentStructure |
get key()/set key() | GetAccessorDeclaration/SetAccessorDeclaration | GetAccessorDeclarationStructure/SetAccessorDeclarationStructure |
method() | MethodDeclaration | MethodDeclarationStructure |
从源码结构看,所有可出现在对象字面量内部的成员都被统一为ObjectLiteralElementLike别名(见 aliases.ts),getProperties()的返回值即以此为类型。
获取 ObjectLiteralExpression 节点
在使用任何属性操作方法之前,首先需要拿到ObjectLiteralExpression节点本身。可以通过 AST 遍历接口来定位,例如从一个源码文件中查找:
import { SyntaxKind } from "ts-morph"; const objectLiteralExpression = sourceFile.getFirstDescendantByKindOrThrow(SyntaxKind.ObjectLiteralExpression);测试代码中正是采用这种方式获取节点(见 objectLiteralExpressionTests.ts)。也可以从某个初始化的变量声明出发,先取到初始化表达式再向下获取:
const variableDeclaration = sourceFile.getVariableDeclarationOrThrow("obj"); const objectLiteralExpression = variableDeclaration.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);更一般地,initializers 文档描述了如何从带初始化器的声明中定位表达式节点,对象字面量最常见的出现位置正是变量声明的初始化器。
读取对象的属性
拿到ObjectLiteralExpression节点后,读取属性有以下几种方式,对应 ObjectLiteralExpression.ts 中的实现:
// 获取全部属性(返回 ObjectLiteralElementLike[]) const properties = objectLiteralExpression.getProperties(); // 按名称获取第一个匹配的属性,找不到返回 undefined const property = objectLiteralExpression.getProperty("propertyAssignment"); // 用查找函数过滤,适合名称不便直接书写(如含扩展运算符)的情况 const spreadAssignment = objectLiteralExpression.getProperty( p => p.getText() === "...spreadAssignment", ); // 按名称获取,找不到时抛出异常 const method = objectLiteralExpression.getPropertyOrThrow("method");getProperty的内部实现值得关注(源码 L56-L69):当传入字符串名称时,它会先检查成员上是否存在getName()方法(用于排除展开赋值等无名称成员),再逐一比较;当传入函数时则直接作为find谓词使用。这意味着getProperty既支持精确名称匹配,也支持任意复杂的自定义条件,例如按getKind()过滤:
const firstPropertyAssignment = objectLiteralExpression.getProperty( p => p.getKind() === SyntaxKind.PropertyAssignment, );getPropertyOrThrow则在找不到时通过errors.throwIfNullOrUndefined抛出带名称的报错信息(源码 L37-L42)。测试用例验证了这些行为:名称匹配成功时返回节点文本、找不到时getProperty返回undefined而getPropertyOrThrow抛出异常(测试 L63-L100)。
关于注释的处理
需要注意的是,getProperties()只返回真实的 AST 成员,不包含注释。如果希望把对象内部的注释也作为可操作元素(例如在删除时保留或一并处理),应使用getPropertiesWithComments():
const propertiesWithComments = objectLiteralExpression.getPropertiesWithComments();从实现看,该方法通过ExtendedParser.getContainerArray读取语法列表容器内的全部元素,包括注释节点(源码 L81-L84),返回类型为(ObjectLiteralElementLike | CommentObjectLiteralElement)[]。对应测试(L52-L61)展示了const t = {\n //a\n /*b*/\n};场景下getProperties()返回空数组而getPropertiesWithComments()返回两条注释文本。对注释语义感兴趣的读者还可进一步参考 comments 与 comment-ranges 文档。
新增对象成员:操作 API 总览
ts-morph 为每种成员类型都提供了add与insert两族方法,命名模式完全一致:
- add 系列:追加到对象字面量的末尾,返回新节点;
- insert 系列:在指定索引处插入,返回新节点。
每种成员的方法清单如下:
| 成员类型 | add 方法 | insert 方法 |
|---|---|---|
| 属性赋值 | addPropertyAssignment/addPropertyAssignments | insertPropertyAssignment/insertPropertyAssignments |
| 简写属性 | addShorthandPropertyAssignment/addShorthandPropertyAssignments | insertShorthandPropertyAssignment/insertShorthandPropertyAssignments |
| 展开赋值 | addSpreadAssignment/addSpreadAssignments | insertSpreadAssignment/insertSpreadAssignments |
| get 访问器 | addGetAccessor/addGetAccessors | insertGetAccessor/insertGetAccessors |
| set 访问器 | addSetAccessor/addSetAccessors | insertSetAccessor/insertSetAccessors |
| 方法 | addMethod/addMethods | insertMethod/insertMethods |
此外还有一组通用方法addProperty/addProperties/insertProperty/insertProperties,它们接受统一的联合类型ObjectLiteralExpressionPropertyStructures(或字符串、WriterFunction),可混合插入任意成员。该联合类型定义于 aliases.ts:
export type ObjectLiteralExpressionPropertyStructures = | PropertyAssignmentStructure | ShorthandPropertyAssignmentStructure | SpreadAssignmentStructure | GetAccessorDeclarationStructure | SetAccessorDeclarationStructure | MethodDeclarationStructure;add系列内部本质上是insert(index = 当前成员总数)的语法糖:源码中的#getAddIndex()返回当前容器成员数量(L87-L90),而addPropertyAssignment等均调用对应的insert族方法。
新增属性赋值(Property Assignment)
通过addPropertyAssignment传入PropertyAssignmentStructure即可:
const propertyAssignment = objectLiteralExpression.addPropertyAssignment({ name: "propertyAssignment", initializer: "5", });PropertyAssignmentStructure的核心字段(见 PropertyAssignmentStructure.ts)为:
name:属性名,继承自PropertyNamedNodeStructure;initializer:初始化器文本,类型为string | WriterFunction(可传字符串或写入函数,如writer => writer.write("5"))。
initializer支持字符串与WriterFunction两种形态,测试用例中就有{ name: "prop2", initializer: writer => writer.write("5") }的用法(测试 L273)。在索引位置插入的写法为:
objectLiteralExpression.insertPropertyAssignment(0, { name: "prop1", initializer: "4" });对应测试验证了在开头、中间、末尾插入的文本结果(L269-L308)。
新增简写属性(Shorthand Property Assignment)
简写属性只需提供name,无需初始化器,这也是它与属性赋值在结构上的唯一差别(见 ShorthandPropertyAssignmentStructure.ts):
const shorthandPropertyAssignment = objectLiteralExpression.addShorthandPropertyAssignment({ name: "shorthandPropertyAssignment", });多个简写属性可通过addShorthandPropertyAssignments/insertShorthandPropertyAssignments批量处理,测试中展示了中间插入两个简写属性的格式化效果(L372-L379)。
新增展开赋值(Spread Assignment)
展开赋值通过addSpreadAssignment添加,其结构继承自ExpressionedNodeStructure,核心字段是expression(展开目标表达式文本):
const spreadAssignment = objectLiteralExpression.addSpreadAssignment({ expression: "spreadAssignment" });expression同样支持字符串或WriterFunction,例如测试中的{ expression: writer => writer.write("prop3") }(L448)。批量或按索引插入分别使用addSpreadAssignments/insertSpreadAssignments。
新增 get / set 访问器
get 与 set 访问器在结构上属于访问器声明(GetAccessorDeclarationStructure/SetAccessorDeclarationStructure),与类中的访问器共用结构类型,因此可以携带returnType、parameters、statements等字段:
const getAccessor = objectLiteralExpression.addGetAccessor({ name: "someNumber", returnType: "number", statements: ["return someNumber;"], }); const setAccessor = objectLiteralExpression.addSetAccessor({ name: "someNumber", parameters: [{ name: "value", type: "number" }], statements: ["someNumber = value;"], });statements是写入函数体内的语句数组,逐行渲染到{ }中。对应测试验证了插入后的文本形如get prop1() {\n }(L588-L648)。注意这里isAmbient: false被固定写入结构打印器选项(见下文源码分析),因为对象字面量内的访问器不可能处于 ambient 环境。
新增方法(Method)
方法与访问器一样复用类成员的MethodDeclarationStructure:
const method = objectLiteralExpression.addMethod({ name: "method", statements: [`return "some string";`], });同样支持insertMethod/insertMethods/addMethods,测试验证了批量插入多个方法的输出(L552-L558)。方法结构还可携带parameters、returnType、scope(对象字面量内无实际意义但结构兼容)、decorators等通用声明字段。
混合插入与注释
当需要一次性插入不同类型成员时,可以使用通用insertProperties/addProperties,甚至混入注释文本:
const result = objectLiteralExpression.addProperties([ { kind: StructureKind.PropertyAssignment, name: "p1", initializer: "5" }, { kind: StructureKind.ShorthandPropertyAssignment, name: "p2" }, { kind: StructureKind.SpreadAssignment, expression: "p3" }, { kind: StructureKind.Method, name: "m1", statements: ["return 1;"] }, "// 这是一条注释", writer => writer.write("// 通过 WriterFunction 写入的注释"), ]);通用方法接受string | WriterFunction | ObjectLiteralExpressionPropertyStructures(或其数组),字符串与写入函数会作为原始文本直接输出。测试用例 L113-L146 验证了这种混合插入的完整输出格式。
删除对象成员
删除操作极为简洁:对获取到的成员节点调用.remove()即可。
const obj: ObjectLiteralExpression; // 假设已获取节点 obj.getPropertyOrThrow("prop1").remove();.remove()是所有 ts-morph 节点通用的删除接口,其底层由RemoveChildrenTextManipulator/RemoveChildrenWithFormattingTextManipulator处理文本替换(相关实现见 manipulations/removal.ts),并自动处理逗号与换行的清理。测试验证了三种典型删除场景(L235-L257):
- 属性独占一行时删除,
const t = {\n prop1: 5\n};变为const t = {\n};; - 属性与花括号同行时删除,
const t = { prop1: 5 };变为const t = { };; - 删除夹在两个属性之间的方法,其余成员保持正确逗号。
若需同时处理对象内部的注释,可先通过getPropertiesWithComments()拿到包含注释元素的列表,再逐个.remove()。
源码级原理:一次插入发生了什么
理解内部机制有助于预判格式化结果、调试复杂场景。以属性插入为例,insertPropertyAssignments最终调用私有方法#insertProperty(源码 L388-L406),其流程为:
- 索引校验:
verifyAndGetIndex确保索引位于合法范围[0, 当前成员数]; - 文本生成:使用
_getWriterWithChildIndentation()创建带缩进的写入器,再由CommaNewLineSeparatedStructuresPrinter按成员结构逐项打印代码; - 文本插入:调用
insertIntoCommaSeparatedNodes将生成的文本插入到SyntaxList语法列表中的指定位置; - 节点重建:
getNodesToReturn依据旧列表与新列表的差异重建包装节点,返回新成员对应的节点数组。
关键格式化行为由 manipulation settings 控制:
useTrailingCommas:是否在新成员后生成尾逗号,由project.manipulationSettings.getUseTrailingCommas()读取。测试 L148-L172 展示了开启尾逗号后p1,\n};的输出效果;- 缩进:新成员统一使用子级缩进层级,并与现有成员的换行风格对齐。
而各类型成员的打印则通过 ObjectLiteralExpressionPropertyStructurePrinter 按kind分发:PropertyAssignment、ShorthandPropertyAssignment、SpreadAssignment、Method、GetAccessor、SetAccessor分别交给对应的结构打印器,其中访问器与方法打印器固定使用{ isAmbient: false }选项。这也是为什么文档示例中只需给出name、initializer、expression等最小字段即可生成正确代码——其余细节由打印器按统一约定补齐。
完整实战:动态构造并修改一个对象
将上述 API 组合起来,可以完成一次完整的“读取—修改”闭环。以下示例基于Project读取内存中的源码并执行修改:
import { Project, StructureKind, SyntaxKind, ObjectLiteralExpression, } from "ts-morph"; const project = new Project({ useInMemoryFileSystem: true }); const sourceFile = project.createSourceFile("test.ts", `const config = { name: "app", version, };`); const objectLiteralExpression = sourceFile.getFirstDescendantByKindOrThrow( SyntaxKind.ObjectLiteralExpression, ) as ObjectLiteralExpression; // 1. 读取 const nameProperty = objectLiteralExpression.getProperty("name"); console.log(nameProperty?.getText()); // name: "app" // 2. 修改已有属性初始化器 if (nameProperty?.isKind(SyntaxKind.PropertyAssignment)) nameProperty.setInitializer(`"my-app"`); // 3. 追加新成员 objectLiteralExpression.addPropertyAssignment({ name: "debug", initializer: "false", }); objectLiteralExpression.addShorthandPropertyAssignment({ name: "nodeEnv" }); objectLiteralExpression.addSpreadAssignment({ expression: "defaults" }); objectLiteralExpression.addGetAccessor({ name: "label", returnType: "string", statements: [`return this.name;`], }); objectLiteralExpression.addMethod({ name: "print", statements: ["console.log(this.label);"], }); // 4. 删除旧成员 objectLiteralExpression.getPropertyOrThrow("version").remove(); console.log(sourceFile.getFullText());输出结果大致为:
const config = { name: "my-app", nodeEnv, ...defaults, get label() { return this.name; }, print() { console.log(this.label); }, debug: false };通过getText()可以随时读取修改后的源码文本,再配合 emitting.md 中的emit与文件系统写入流程,即可将改动落盘。
小结与进一步阅读
对象字面量表达式操作是 ts-morph 表达式体系中最常用的一组能力:getProperties/getProperty/getPropertyOrThrow覆盖读取,按成员类型配对的add/insert族方法覆盖新增,通用的.remove()覆盖删除,而ObjectLiteralExpressionPropertyStructures联合类型让混合插入变得简洁。底层的结构打印器与insertIntoCommaSeparatedNodes机制保证了缩进、逗号与换行的正确性,且可通过manipulationSettings微调输出风格。
相关主题可继续深入:
- expressions:表达式节点体系概览;
- initializers:如何从变量声明等带初始化器的节点定位表达式;
- object literal 相关节点源码:
PropertyAssignment、ShorthandPropertyAssignment、SpreadAssignment等节点的完整实现; - structures 文档:结构对象(Structure)的通用设计规范;
- settings 文档:
useTrailingCommas等全局操作设置; - 对象字面量结构打印器:各成员类型的打印分发逻辑;
- ObjectLiteralExpression 测试套件:覆盖本文所述全部 API 的行为验证。
- 开发工具
【免费下载链接】ts-morph
TypeScript Compiler API wrapper for static analysis and programmatic code changes.
相关推荐
ts-morph 函数操作完整指南:Function Declaration 的检索、增删、重载与函数表达式处理
ts morph 函数操作完整指南:Function Declaration 的检索、增删、重载与函数表达式处理 本文是 ts morph 文档体系中关于函数(
开发工具Apollo Save Tool:PS4存档管理的终极免费解决方案
Apollo Save Tool:PS4存档管理的终极免费解决方案 还在为PS4游戏存档丢失而烦恼吗?Apollo Save Tool正是你需要的专业存档管理工
游戏开发Penrose 的 Substance 字面量表达式(Literal Expressions)完全指南:Number/String 内置类型与索引语句的数值展开
Penrose 的 Substance 字面量表达式(Literal Expressions)完全指南:Number/String 内置类型与索引语句的数值展开
开发工具数据可视化
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考