likes
comments
collection
share

通过AST识别目标代码的依赖语句的方法

作者站长头像
站长
· 阅读数 12

通过AST识别目标代码的依赖语句的方法

AST

AST(Abstract syntax tree 抽象语法树),可以让我们通过编写一段代码,来分析另一段代码的结构。在webpack打包过程中,一般需要使用babel对源代码进行转换,将es6语法的代码降级为es5,这就需要将源代码转换为ast -> 对ast做修改 -> 将修改后的ast生成新代码。

但ast除了可以对语法做降级,还有很多功能。比如扫描代码漏洞,扫描代码合规问题等等。

通过astexplorer可以在线预览一段代码的ast,或者在vscode内安装插件vscode-ast-explorer直接查看本地代码的ast。

以vscode中的vscode-ast-explorer为例,安装后,我在命令面板内输入“show ast”,得到了下面的效果。左侧是代码,右侧是ast分析结果。我这边使用TypeScript进行ast分析,但也可以选择其他解析器,比如babel。

通过AST识别目标代码的依赖语句的方法

当我鼠标点击了一下关键字“CommonTitle”后,右边的ast也更新了。被标黄的ast节点提供了非常多的信息,比如节点类型是79,位置是是525~536,标识符的名称是“CommonTitle”。

通过AST识别目标代码的依赖语句的方法

AST解析器

一段ts代码可以被多种解析器解析成ast格式,下面的截图只是一部分。每一种解析器解析出来的格式都不太一样,需要根据每种ast解析器的文档来对比着看。

通过AST识别目标代码的依赖语句的方法

babel

我最初用的比较多的是babel,因为webpack上用的就是babel。用babel的好处就是文档非常完善,而且包体积不会很大。但缺陷就是对新语法支持能力弱,虽然可以通过增加plugin启用一部分语法,但还是感觉很麻烦。还有就是babel无法处理有语法错误的代码,代码内只要有一个地方出错了,那整段代码都无法解析。

TypeScript

后面转用TypeScript。一开始并不知道TypeScript还可以对代码做ast解析,以为只能做类型检查。但TypeScript比想象中的强大很多。

TypeScript官网并没有很明显的告诉我们TypeScript编译器的api怎么用,但是在github上的wiki有介绍。

使用TypeScript作为ast解析器的优势有对新语法的支持好,因为我们只需要解析目标代码的依赖,不需要对语法正确与否做判断,那最好能支持最新的语法,并且忽视局部错误。

唯一的缺点就是TypeScript的包体积很大,打包进vscode插件最少都2mb了。

ts-morph

ts-morph对TypeScript的编译器api进行了封装,虽然TypeScript很好用,但提供的api文档少,所以使用ts-morph来修改ast会更方便。

不过本篇文章的重点并不是介绍ts-morph怎么使用,所以更感兴趣的可以看看ts-morph的文档,我感觉他封装的还是非常好用。

实现效果

可以发现当我选择15~19行创建代码片段时,代码相关的依赖也被识别到了。这个就是最终的实现效果。在我最初开发FE Snippets+的时候就希望能做的比vscode原生的snippets好用,所以这个功能是必须实现的。因为vscode自带的代码片段没有识别依赖语句,导致我每次使用都需要手动导入依赖,不够方便。

通过AST识别目标代码的依赖语句的方法

实现思路

我想了一下其实有3种实现方法,来跟着我的思路走一下~

不太用AST的实现方法

最开始我虽然打算用ast做分析,但是不确定ast能不能实现,或者说实现起来会不会很麻烦。所以想了如果用ast做不出来的兜底方案。

import classNames from 'classnames/bind';
import React from 'react';
import CommonTitle from '../common-title';
import styles from './index.module.less';
import NewsBox from './news-box';
        <CommonTitle
          title="News and Updates"
          subtitle="The latest ESLint news, case studies, tutorials, and resources"
          style={{ marginBottom: 96 }}
        />

对于上面这段代码,其实唯一的有可能用到外部模块的只有“CommonTitle”,这是因为除了“CommonTitle”是标识符外,其余的信息都是常量。

所以第一步就是把标识符给提取出来,不过这一步,还是得依赖AST。因为单纯依靠if-else还是蛮难判断出哪些内容是标识符的。

然后把所有的import声明找出来,看看import语句导入的标识符有没有一个名叫“CommonTitle”的东西。如果有就标记下来,最后返回所有被标记的import声明。

这样做的思路是已经对了一半,但还有一个问题,假设代码是下面的样子呢?

import CommonTitle from '../common-title';

const comp = () => {
    const CommonTitle = (props) => <div {...props}></div>;
    return <CommonTitle
      title="News and Updates"
      subtitle="The latest ESLint news, case studies, tutorials, and resources"
      style={{ marginBottom: 96 }}
    />
}
       

对于这种情况,如果还是直接比较import声明当中有没有“CommonTitle”,那得到的结果是错误的,这边并没有依赖importDeclaration。所以这需要有一个更精确的比较方法。

references

在vscode里面有一个功能,右键标识符并点击“查找所有引用”,就会发现某个模块在整个项目内的所有引用,这可比在搜索框里搜索“CommonTitle”好用的多。

通过AST识别目标代码的依赖语句的方法

这就给了我一个启发,既然vscode能实现,就说明肯定有方案了。而查找所有引用的英文是find all references吧,ts-morph里面刚好有一个方法叫做findReferences

我的思路:

  • 遍历所有importDeclaration的Specifiers
  • 查找Specifier的references,判断是否包含目标选区范围内的标识符,做好标记
  • 根据标记好的Specifiers生成新的importDeclaration

知识补充:

对于esm的importDeclaration,需要了解在ast当中的核心组成部分。importDeclaration有3种导入外部变量的的方式,默认导入,具名导入,命名空间导入。

import React, {useState, useCallback as useCb} from "react";
// defaultImport React
// namedImports [useState, useCallback]
// source 'react'
import * as vscode from "vscode";
// namespaceImport vscode
// source 'vscode'

标记依赖的伪代码:

import parser from "ast-parser";

const needToMark = (node: {start: number, end: number}, start: number, end: number) => {
    return node.start >= start && node.end <= end;
}

// 考虑到namedImport可能会有别名,增加了origin
const createMark = (source: string, type: 'default' | 'named' | 'namespace', local: string , origin?: string) => {
    return type === 'named' 
    ? `${source} -> ${type} -> ${origin || local} / ${local}` 
    : `${source} -> ${type} -> ${local}`
}

export const resolveSnippetDependencies = (code: string, start: number, end: number) => {
    const ast = parser(code);
    const importDeclarations = ast.getImportDeclarations();
    
    const markedDependencies = []
    
    importDeclarations.forEach(node => {
        const source = node.getSource();
        
        const defaultImport = node.getDefaultImport();
        if (defaultImport?.findReferences().some((node) => needToMark(node, start, end))) {
            markedDependencies.push(createMark(source, 'default', defaultImport.getText()));
        }
        
        const namespaceImport = node.getNamespaceImport();
        if (namespaceImport?.findReferences().some((node) => needToMark(node, start, end))) {
            markedDependencies.push(createMark(source, 'namespace', namespaceImport.getText()));
        }
        
        const namedImports = node.getNamedImports();
        namedImports.forEach(namedImport => {
            if (namedImport?.findReferences().some((node) => needToMark(node, start, end))) {
                markedDependencies.push(createMark(source, 'named', namedImport.getText()));
            }        
        })

    })
    
    return uniq(markedDependencies)
}

对于上面的CommonTitle,应该会生成的标记语句是

../common-title -> default -> CommonTitle

通过这种标记的好处是方便去重。

得到这种标记结果后,再转换成ast,再最终生成代码语句即可。预期生成结果是:

import CommonTitle from '../common-title'

definitions

上面的思路是查找所有importDeclaration中的Specifiers,检测每个Specifier是否被目标范围内的标识符引用。还有一种方法是直接查找目标范围内的标识符的定义,看看定义是否是importDeclaration中的Specifier。

ts-morph提供了api,getDefinitions。

通过AST识别目标代码的依赖语句的方法

通过definition反查依赖的伪代码:

import parser from "ast-parser";

// 考虑到namedImport可能会有别名,增加了origin
const createMark = (source: string, type: 'default' | 'named' | 'namespace', local: string , origin?: string) => {
    return type === 'named' 
    ? `${source} -> ${type} -> ${origin || local} / ${local}` 
    : `${source} -> ${type} -> ${local}`
}

export const resolveSnippetDependencies = (code: string, start: number, end: number) => {
    const ast = parser(code);
    const identifiers = ast.getIdentifiers().filter(node => node.start >= start && node.end <= end) // 用户选区范围内的标识符
    
    const markedDependencies = []
    
    identifiers.forEach(id => {
        id.getDefinitionNodes().forEach(node => {
            // default
            if (node.parend.kind === 'ImportClause') {
                markedDependencies.push(createMark(
                    node.parent.parent.getSource(),
                    'default',
                    node.getText()
                ))
                return
            }
            
            // namespace
            if (node.parend.kind === 'NamespaceImport') {
                markedDependencies.push(createMark(
                    node.parent.parent.parent.getSource(),
                    'namespace',
                    node.getText()
                ))
                return
            }
            
            // named
            if (node.parend.kind === 'ImportSpecifier') {
                markedDependencies.push(createMark(
                    node.parent.parent.parent.parent.getSource(),
                    'named',
                    node.getText()
                ))
                return
            }
        })
    })
    
    return uniq(markedDependencies)
}

至于被标记的依赖如何生成ast,感兴趣可以自己研究下~,受限于篇幅,暂不展开。