⤴Top⤴

AST

博客分类: 前端

AST

AST

什么是 AST

我们日常中用到的 babel、eslint 等插件,其实背后原理就涉及到 AST,即抽象语法树(Abstract Syntax Tree,AST),它是源代码语法结构的一种抽象表示。

我们直接看 AST Explorer 的默认栗子:

ast explorer

从图片右上角可以看出 AST 就是一个自上而下的树形结构,每一层有一个或多个节点组成,每个节点由一个 type 属性表示节点的类型如 FunctionDeclaration, BlockStatement, VariableDeclaration,以及其他属性组成,从而形成一种树级结构。

如何手写一个编译器

整节内容可以参考这里 the-super-tiny-compiler 👈

大多数编译器可以分成三个阶段:

parse

词法分析 / 语法分析

解析一般来说会分成两个阶段:

// 示例源码
(add 2 (subtract 4 2))

// 可能的 Tokens
[
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'add'      },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'subtract' },
  { type: 'number', value: '4'        },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: ')'        },
  { type: 'paren',  value: ')'        }
]

// 可能的 AST
// Program 代表的是根节点
{
  type: 'Program',
  body: [{
    type: 'CallExpression',
    name: 'add',
    params: [{
      type: 'NumberLiteral',
      value: '2'
    }, {
      type: 'CallExpression',
      name: 'subtract',
      params: [{
        type: 'NumberLiteral',
        value: '4'
      }, {
        type: 'NumberLiteral',
        value: '2'
      }]
    }]
  }]
}

如何手写一个 eslint 插件

有了 AST 就可以对它进行从上到下的递归遍历,过程中使用一种名为访问者模式的设计模式访问树里的节点。这种模式通过创建一个包含一些方法的对象 visitor,在遍历 AST 过程中匹配 visitor 里的方法名,匹配成功就调用此方法。通过访问 AST 节点可以对源码进行语法检查,ESLint 就是基于此工作的。

限制函数参数数量

export default function (context) {
  return {
    // 访问 FunctionDeclaration 节点
    FunctionDeclaration: (node) => {
      // 判断函数参数个数
      if (node.params.length > 3) {
        context.report({
          node,
          message: "参数最多不能超过3个"
        });
      }
    }
  }
}

稍微修饰下:

//------------------------------------------------------------------------------
// Rule Definition https://eslint.org/docs/developer-guide/working-with-rules
// createRule 是引用了 @typescript-eslint/experimental-utils 里的 ESLintUtils.RuleCreator 方法
//------------------------------------------------------------------------------
import createRule from '../misc/createRule'

interface IOptions {
  maxLen: number
}

type TMessageIds = 'tooLong'

const rule = createRule<[IOptions], TMessageIds>({
  name: 'parametersmaxlength',
  meta: {
    type: 'suggestion',
    docs: {
      description: 'parameters max length',
      recommended: 'error',
    },
    messages: {
      tooLong: '不要超过  个参数哦',
    },
    schema: [
      {
        type: 'object',
        properties: {
          maxLen: {
            type: 'number',
            default: 3,
          },
        },
        additionalProperties: false,
      },
    ],
  },
  defaultOptions: [
    {
      maxLen: 3,
    },
  ],
  create(context, [options]) {
    const { maxLen } = options
    /**
    * 获取函数的参数的开始、结束位置
    * @param {node} node AST Node
    */
    function getFunctionParamsLoc(node) {
      const paramsLength = node.params.length
      return {
        start: node.params[0].loc.start,
        end: node.params[paramsLength - 1].loc.end,
      }
    }
    return {
      FunctionDeclaration: (node) => {
        if (node.params.length > maxLen) {
          context.report({
            // loc is an object specifying the location of the problem.
            // If both loc and node are specified, then the location is used from loc instead of node.
            loc: getFunctionParamsLoc(node),
            node,
            data: {
              maxLen,
            },
            messageId: 'tooLong',
          })
        }
      },
    }
  },
})

export default rule

限制嵌套的条件语句

匹配类型为 IfStatement 的节点,如果它的第一个子节点还是 IfStatement 就进行提示:

export default function (context) {
  return {
    IfStatement(node) {
      const { consequent: { body = [] } } = node

      // 判断第一个子节点类型是否是 IfStatement
      if (body[0] && body[0].type === 'IfStatement') {
        context.report({
          node: body[0],
          message: '不允许嵌套的条件语句'
        })
      }
    }
  }
}

当然我们也可以实现中英文之间保留空格的规范等等,这里就不展开介绍了。

如何手写一个 babel 插件

Babel 就是一个通用的多功能的 JavaScript 编译器。其工作原理就是修改代码 AST 上的节点,从而到达修改代码的目的。一个 Babel 插件是一个接收 babel 对象作为参数的函数,返回一个带有 visitor 属性的对象。visitor 对象中的每个函数接受 path 和 state 参数。详情可以查阅 babel-handbook 👈

将 ** 语法转换为 Math.pow

// Before
const a = 10 ** 2 
// After
const a = Math.pow(10, 2)
  1. 找到 ** 语法所在位置
  2. 获取左右操作数
  3. 创建 Math.pow 语句,替换原节点
export default function (babel) {
  const { types: t } = babel
  
  return {
    visitor: {
      // 访问二元表达式
      BinaryExpression(path) {
        const { node } = path
        // 如果操作符不是 ** 就退出
        if (node.operator !== '**') return
        const { left, right } = node
        // 创建调用语句
        const newNode = t.callExpression(
          t.memberExpression(t.identifier('Math'), t.identifier('pow')),
          [left, right]
        )
        // 替换原节点
        path.replaceWith(newNode)
      },
    }
  }
}

修改工具函数引入方式

// Before
import { get, isFunction } from 'lodash'
// After
import get from "lodash/get"
import isFunction from "lodash/isFunction"
  1. 找到 lodash 的 import 节点
  2. 遍历所有的引入值,获取引用的 name 属性
  3. 插入新生成的 import 节点
  4. 删除原节点
export default function (babel) {
  const { types: t } = babel
  
  return {
    visitor: {
      // 访问导入声明
      ImportDeclaration(path) {
        let { node } = path
        if (node.source.value !== 'lodash') return
        const val = node.source.value

        node.specifiers.forEach((spec) => {
          if (t.isImportSpecifier(spec)) {
            const { local } = spec

            // 插入新的导入节点
            path.insertBefore(
              t.importDeclaration(
                [t.importDefaultSpecifier(local)],
                t.stringLiteral(`${val}/${local.name}`)
              )
            )
          }
        })
        // 删除原节点
        path.remove()
      },
    }
  }
}

AST 工具库介绍

jscodeshift

jscodeshift 是一个 Facebook 开源的基于 recast 封装的用来对 JavaScript 或者 TypeScript 文件运行转换的工具,它的目的是更方便的批量修改代码。它通过 transformer 对源码进行转换,一个 transformer 就是一个接受 fileInfo, api, options 参数并返回源码的函数。

MUI 批量修改代码的脚本就是基于 jscode 来实现的,即 codemod,举个例子:

// boxRenameGap
import renameProps from './misc/renameProps'

/**
 * @param {import('jscodeshift').FileInfo} file
 * @param {import('jscodeshift').API} api
 */
export default function transformer(file, api, options) {
  const j = api.jscodeshift
  const root = j(file.source)

  const { printOptions } = options

  return renameProps({
    root,
    componentName: 'Box',
    props: { gridGap: 'gap', gridColumnGap: 'columnGap', gridRowGap: 'rowGap' },
  }).toSource(printOptions)
}

具体代码可以参考 github mui-codemod 这里 👈

GoGoCode

GoGoCode 号称全网最简单,易上手,可读性最强,提供类似于 jQuery 的 API 和一套和正则表达式接近的语法用来匹配和替换代码。不过确实上手难度比 jscodeshift 低很多,比如我们可以通过它进行 vue2 到 vue3 的迁移。直接看个官方例子:

// before
const a = 1;
const b = 2;

// after
const a = 1;
const b = 1;
const $ = require('gogocode');
const script = $(source);
// 按照你的意图,用 $_$ 当通配符能匹配任意位置的 AST 节点
const aAssignment = script.find('const a = $_$');
// 获得我们匹配的 AST 节点的 value
const aValue = aAssignment.match?.[0]?.[0]?.value;
// 就像替换字符串一样去替换代码
// 但可以忽略空格、缩进或者换行的影响
script.replace('const b = $_$', `const b = ${aValue}`);
// 把 ast 节点输出成字符串
const outCode = script.generate();

更多关于 AST 工具可以参考 awesome ast 👈

参考链接

  1. Babel 插件手册
  2. 学习抽象语法树 AST By 西山居彭于晏