TypeScript AST (抽象语法树) 结合 Angular Schematics 的应用

2,038 阅读4分钟

汤汤Tang.png

AST 简单来说就是将 typescript 语法解析为一个一个的节点,在节点信息中包括了该节点的 position, text, kind 等信息,更详细的介绍大家可以查看 AST,在这里也给大家推荐一个查看解析后的 AST 结构的网站,直接将 ts 代码粘贴到其中观察其结构能够更好的帮助你的理解。

ast.png

如果是第一次点进来的同学也可以先看看这篇文章了解一下什么是 Angular Schematics.

为了让大家有更好的理解,这篇文章将会结合其在 Schematics 中插入代码的方式来进行介绍。

1 读取 ts 文件

在使用 AST 来分析 ts 文件之前,我们需要先读取 ts 文件将其转为一个 ts.Node 类型的数组

// 在这里我们借助 Schematics 中 tree 来读取
import { Tree } from '@angular-devkit/schematics';
import * as ts from 'typescript';

// 在这里实现两个函数,将文件读取为 ts.SourceFile
function readIntoSourceFile(tree: Tree, filePath: string): ts.SourceFile {
  // 读取文件的方法使用 `fs` 也可,由于该文章主要是与 Angular Schematics 相结合,所以使用的 tree 读取
  const text = tree.read(filePath);
  if (text === null) {
    console.log('File does not exist.');
    return;
  }

  const sourceText = text.toString('utf-8');
  return ts.createSourceFile(
    filePath,
    sourceText,
    ts.ScriptTarget.Latest,
    true
  );
}

function getSourceNodes(sourceFile: ts.SourceFile): ts.Node[] {
  const nodes: ts.Node[] = [sourceFile];
  const result = [];

  while (nodes.length > 0) {
    const node = nodes.shift();

    if (node) {
      result.push(node);
      if (node.getChildCount(sourceFile) >= 0) {
        nodes.unshift(...node.getChildren());
      }
    }
  }

  return result;
}

接下来我们打印一下 readIntoSourceFilegetSourceNodes 得到的结果看一下。

ts-nodes.png

在打印的结果中可以看到我们之前提到的相关属性:

  • position: posend
  • kind: kind

打印的结果并不是非常的直观,还是推荐大家将自己的代码拷贝到前面提到过的 Typescript AST Viewer 中查看。

2 通过 AST 在特定位置插入代码

一样的我们还是先看代码,假设我们要在如下的 ts 文件中插入代码在 people 数组中。

const people = ['XiaoHua', 'XiaoLi', 'LiHua'];

首先我们先实现下面的方法来进行对数组的插入

// 由于这里主要结合了 Angular Schematics,所以会有一些特定的用法,但是核心代码与框架无关
/**
 * path: ts 文件路径
 * insertText: 插入的内容,对应我们的例子即为一个数组的元素如 'ZhangSan'
 * target: 插入的目标,对应我们的即为数组的名字 people
 * insertPosition: 插入的位置,对应我们的例子即为在数组的开头或者结尾插入
 */
function insertArray(
  tree: Tree,
  path: string,
  insertText: string,
  target: string,
  insertPosition: 'start' | 'end'
): Tree {
  // 读取成 ts 源文件的方法使用 `fs` 也可
  const sourceFile = readIntoSourceFile(tree, path);
  const nodes = getSourceNodes(sourceFile);

  let arrayNodeSiblings = null;
  let expressionNode = null;

  // 根据传入的数组名字 `people` 来定位到位置
  const arrayNode = nodes.find(
    (n) =>
      (n.kind === ts.SyntaxKind.Identifier ||
        n.kind === ts.SyntaxKind.PropertyAccessExpression) &&
      n.getText() === targetText
  );

  if (!arrayNode || !arrayNode.parent) {
    console.log('Get Component Node Filed.');
    return;
  }

  arrayNodeSiblings = arrayNode.parent.getChildren();

  // 找到我们需要插入的数组元素的位置
  let arrayNodeIndex = arrayNodeSiblings.indexOf(arrayNode);

  arrayNodeSiblings = arrayNodeSiblings.slice(arrayNodeIndex);

  expressionNode = arrayNodeSiblings.find(
    (n) => n.kind === ts.SyntaxKind.ArrayLiteralExpression
  );

  if (!expressionNode) {
    console.log('The target node is not defined');
    return;
  }

  // 根据 SyntaxKind 为 SyntaxList 的节点定位到 people 数组后 [] 的位置
  const listNode = expressionNode
    .getChildren()
    .find((n) => n.kind === ts.SyntaxKind.SyntaxList);

  if (!listNode) {
    console.log(`${targetText} The target node is not defined`);
    return;
  }
  /* 到此我们寻找指定数组的位置的过程已经结束了,目前我们需要插入代码的位置信息已经得到了,接下来我们将新的元素插入到数组中 */

  // 在这里我们可以指定在数组的开始或者结尾插入代码
  const changePosition =
    insertPosition === 'start' ? listNode.getStart() : listNode.getEnd();

  const change = new InsertChange(path, changePosition, insertText);

  const declarationRecorder = tree.beginUpdate(path);
  if (change instanceof InsertChange) {
    declarationRecorder.insertRight(change.pos, change.toAdd);
  }
  tree.commitUpdate(declarationRecorder);

  return tree;
}

以上已经完成了核心的方法,我们再总结一下,首先将 ts 文件读取为 ts 源文件形式,之后再获取到代码的所有节点,然后根据节点(node)的类型(SyntaxKind)定位到我们需要插入的任何元素,不仅限于数组,也包括 object, constructor, Function 等等都是能够找到具体位置的。

在上述方法中大家也注意到了 SyntaxKind,大家可以在 node_modulestypescript 里查看到更多详细信息。

3 更多的用法

这里我们以一段简单的 Angular 组件代码为例做简单的介绍,除了插入代码外,我们还能够通过 AST 来获取文件中我们想要获取的内容,大家可以自行尝试,会有更深的理解。

import { Component, Input } from '@angular/core';

@Component({
  selector: 'selector',
  template: `<div></div>`
})
export class AppComponent {
  @Input() input1: string;
  @Input() input2: number;
  @Input() input3: boolean = true;

  constructor() {}
}

在通过将改文件解析为 AST 之后,我们可以读取到每一个 Input 的名字,类型,默认值来生成我们的 api 文档。

function getComponentAttribute(tree: Tree, path: string) {
  const sourceFile = readIntoSourceFile(tree, filePath);
  const nodes: ts.Node[] = getSourceNodes(sourceFile);
  const propertyNodes: ts.Node[] = [];
  nodes.forEach((n) => {
    if (n.kind === ts.SyntaxKind.Decorator && n.getText() === '@Input()') {
      propertyNodes.push(n.parent);
    }
  });
  // 之后就可以使用 propertyNodes,再根据 SyntaxKind 来提取你想要的内容了
  ......
}

4 总结

本文给大家提供了一种通过 AST 来解析 ts 文件的方法,也给大家提供了一种修改 ts 文件的方法,使用 AST 能够准确的定位到位置。

利用 AST 几乎可以帮我们做一切的操作对一个 ts 文件。这里也推荐大家可以了解一下 Angular Schematics (原理图),其也是借用了 AST 来进行很多文件的操作。了解之后可以定制更加契合你的 cli。

更多的你甚至可以实现在 vscode 中打开一段 ts 代码一样,推断出一个变量的类型。总而言之,除了本文中提到的 插入代码信息提取,还有很多是可以通过分析 AST 来实现的。