前端ast实战

344 阅读9分钟

确定需求

关于 ast 的概念我在这里就不过多赘述了。我们在这里直接提出我们的需求:

我们需要实现一个代码风格校验器,类似于 eslint。它能够同时支持校验 js、ts、vue 代码的编码规范。并且允许用户自定义校验插件。我们给出测试例子:

const tsCode = `
  const str: string = 'http://123.com' as string;
  console.log(str);
`

// 编写一段 vue sfc 组件代码

const vueCode = `
  <template>
    <div>
      <span>{{ str }}</span>
      <span>http://www.aaa.com/</span>
    </div>
  </template>
  <script setup lang="ts">
  const str: string = 'http://123.com' as string;
  console.log(str);
  </script>
`

const esCode = `
  const str = 'http://123.com';
  let b = 'A';
  b = 'abc'
  console.log(str);
`

我们需要校验这些代码片段中是否包含了http 超链接地址。如果包含了,那么就提出警告,哪一段代码,哪一行出现了http链接,必须改成 https 链接,并且校验 http 的规则要以插件的形式注入。

方案设计

提出需求之后,紧接着就是需要进行方案设计:

解决支持多语言的问题

和官方的 eslint 一样,我们需要能够让用户方便的切换语言编译器以及处理器。所以我们想到的第一个方向就是我们导出一个入口函数,这个入口函数接收语言处理器作为参数:

export default function eslint(options: {
  spec: 语言处理器
  }) {
  // 预设语言处理器默认值
  spec = spec || esSoec
  return (
    // 需要校验的代码
    code: string
  ) => {
    // 利用预设的语言处理器处理代码
    return spec(code)
    }
}

这里其实就是利用了一个偏函数的处理技巧。这样定义之后,我们就可以方便的产生各类语言的处理器了:

const es = eslint()

const ts = eslint(ts)

const vue = eslint(vue)

也可以利用产生的处理器来处理各类语言了:

es(js 代码)
ts(ts 代码)
vue(vue 代码)

而在每一个语言处理器内部,都会针对当前的语言特性针对性的进行语法编译以及ast遍历等操作。这一块我们可以自研,也可以直接利用社区提供好的npm 库来进行处理。因为我们目前不涉及到很多自定义的语言和语法,所以直接利用社区提供好的语言处理器来去进行 ast 的转换操作。

espree:处理 es 的编译和语法树的转换 @typescript-eslint/parser:处理 ts 的编译和语法树的转换 vue-eslint-parser 以及 @typescript-eslint/parser 处理 ts 语言的vue单文件组件的语法树的转换

至此我们就完成了多语言处理器的支持。

支持自定义插件的问题

要支持自定义插件,首先我们需要理解一个设计模式,那就是访问器模式:

{
  勾子函数1() {
  
  },
  勾子函数2() {
  
  }
}

实际上就是一个对象。对象上挂载了很多个定的勾子,在一个任务的特定阶段,不断的调用特定的勾子。 而代码本质上就是一个由各类具有语义的 token 构成的字符串。我们解析代码,实际上就是不断的扫描这些token,每当离开原本的token以及进入一个新的token,我们都可以抽象为 离开了原本的生命周期勾子以及进入了一个新的生命周期勾子。所以非常适合这个设计模式的语义。 理解了访问器模式之后,插件协议就非常好定义了,我们认为每一个插件都是一个访问者对象,上面挂载了各个特定 token 的生命周期回调函数:

  specHook: {
    // 利用访问器注册插件访问器
    variableDeclarationHook: [
      ({ value }) => {
        console.log('value', value)
        if (typeof value === 'string' && value.startsWith('http://')) {
           return {
            code: 'error',
            message: '超链接的协议不能以 http:// 开头'
           }
        }
        return {
          code: 'success'
        }
      }
    ]
  }

上面就是一个典型的访问器对象,定义了一个 hook,这个hook 会在变量声明语句的时候调用。然后我们的 eslint 在调用这个 hook 的时候自动就会注入当前声明语句的初始值。我们就可以在这个 hook 内部来针对这个值进行校验了。

定义好了插件协议,剩下的就是支持插件的注册了:

export default function eslint(options: {
  spec: 语言处理器
  options: {
    specHook?: SpecHookOptions
  }
  }) {
  // 预设语言处理器默认值
  spec = spec || esSoec
  const defaultSpecHook = options?.specHook || {}
  return (
    // 需要校验的代码
    code: string,
    options: {
      specHook?: SpecHookOptions
    }
  ) => {
    
    // 利用预设的语言处理器处理代码
    return spec(code, {
      ...defaultSpecHook,
      ...options?.specHook
      })
    }
}

实际上也是一个偏函数,我们允许定义一些全局的生命周期勾子,也允许定义针对某一次代码处理特定的生命周期勾子。

总体处理流程设计

对于复杂的代码段的解析,我们肯定是需要分为以下步骤的:

  1. 词法分析以及语法分析,也就是将代码字符串解析为抽象语法树。
  2. 遍历抽象语法树,当遍历到特定的节点的时候回调注册的勾子函数,并且将节点的值以及其他的上下文注入到勾子函数中进行处理。

因为 eslint 目前只考虑语法的校验,不需要考虑语法修复以及加工等功能,所以不需要对抽象语法树进行额外的处理。

项目整体模块的划分:

image.png

这个文件夹下针对各个语言分别提供整个编译和语法校验逻辑的处理:包括语法编译、ast遍历、代码风格校验整体流程的控制。

image.png

这个文件夹下面负责进行语法的编译,也就是将代码转化为抽象语法树,每一个模块都导出一个函数,接收字符串代码作为入参,返回编译之后的 ast。

image.png

这个模块负责提供遍历 ast 以及解析当前的节点类型,并且调用对应的声命周期勾子函数的操作。

每一个模块都会针对各类语言特性进行针对性的导出处理函数,需要增添和删除对应的语言处理只需要创建和删除语言对应的处理函数就可以了。

工程化处理:

工程化技术选型:

开发模式下:使用 vite-node 来进行项目执行和测试 在生产模式下:使用 tsup 进行项目的打包 整个项目结构最优的解法其实是使用monorepo的结构:

core 包中包含 lint 的核心启动代码。 每一类语言的 secValied 都拆成一个子包单独进行维护。

但是因为我们目前的重心不是在这一方面,所以先简单的按照文件夹拆分和打包了,没有使用monorepo。

实现细节:

入口模块的处理:

我们入口模块导出一个 eslint 函数,根据前面的架构设计,代码就很好实现了:

import type { EslintOptions, SpecHookOptions } from "./type"
import esSpec from './codeSpecValied/es'


/**
 * @param options 
 * @returns 
 */

export function eslint(options?: EslintOptions) {
  const spec = options?.spec || esSpec
  // 提取注入的默认校验插件
  const defaultSpecHook = options?.specHook || {}
  return ( code: string,
    options?: SpecHookOptions) => {
    return spec(code, {
      ...defaultSpecHook,
      ...options
    })
  }
}

入口模块主要就是以下功能:

  1. 接收语言处理器,并且设置默认的语言处理器 es 的语言处理器。
  2. 接收并且处理用户指定的语言处理插件
  3. 返回一个高阶函数,高阶函数允许用户传入需要扫描的代码字符串,在高阶函数内部会聚合全局处理插件以及局部处理插件。并且调用指定的语言处理器来触发代码的检查。

语言扫描器的细节处理

1. 核心流程

这里我就不一一编写所有的语言扫描器的实现细节了,我在这里就以 es 代码为例:

image.png

首先在 codeSpecValied 中添加上 es 代码的处理函数,并且将其导出。 这个函数接收两个参数:

  1. 扫描的js代码字符串
  2. 一些核心的配置,其中最重要的就是插件配置。

在函数内部的核心处理流程就是:

  1. 调用 esCompile 来编译注入的代码字符串,从而拿到生成的抽象语法树。这也是一般编译器的词法分析和语法分析的阶段。
  2. 在抽象语法树树生成之后,会调用esTransform代码,遍历抽象语法树,并且在特定的节点调用指定的插件。如果需要对es代码进行一些转换处理,也会在这个过程中进行处理。

esCompile 编译器处理

import { parse } from 'espree'
import type { Program } from "acorn"

/**
 * 执行 es 语法编译
 * @param code
 */

export default function esCompile(code: string) {
   return parse(code, {
        ecmaVersion: 2015,
        sourceType: 'module',
        ecmaFeatures: {
            impliedStrict: true, // 启用严格模式
        }
    })
}

如果是自己处理其实会比较复杂,但是如果直接向js这种通用的语言,直接可以使用第三方编译器进行编译其实就比较轻松了。直接利用 espree 这个库就可以生成抽象语法树了,然后将抽象语法树返回,至此js的代码编译就完成了。

抽象语法树遍历

image.png

这一块没有完全处理掉,只是处理掉了一个最核心的模块。我在这里处理了 VariableDeclaration 这类变量声明的节点。当遇到变量声明的时候:

直接调用 disposeVariableDeclaration 这函数来针对变量声明节点进行自定义处理:

image.png

这里其实特别简单,我们只需要执行变量声明对应的勾子就可以了。

添加测试

image.png

image.png

image.png

抽象语法树被成功生成和遍历了,插件也被成功调用了。

怎么获取行列信息

要获取代码的行列信息,我们需要在 compile 编译器中注入以下属性:

image.png

有了这个属性之后我们在抽象语法树分析之后的结果中就可以看到:

image.png

代码的行列信息了。

我们将这个行列信息也作为 hook 的参数注入进去,提供给用户去进行处理:

image.png

将错误信息汇总上报:

我们首先定义一个错误数据类型:

image.png

包含了错误信息描述以及错误信息在代码中的行列位置。

基于这个类型,我们在esTransform函数中定义出 es 代码错误收集容器:

image.png

该容器主要就是将每一句代码中的代码错误汇总收集到一起:

image.png

因此,每一句抽象语法树的分析和处理的时候我们都会进行错误的收集和上报:

image.png

在抽象语法树分析完毕之后,我们就会将所有聚合的错误打印处理啊:

image.png

image.png

打包处理:

tsup 打包其实特别简单:

image.png

添加 ts 的配置文件,我们这里添加了3个入口,一个是 eslint 的入口文件,两个是插件的入口文件。 分别进行打包出 cjs 的模块:

image.png

image.png

我们在这里还是声明一下,每一个语言的处理器单独拆分一个子包,然后用monorepo管理是更好的实践。