AST初探

316 阅读4分钟

一、前情提要

在不久前的一天,师兄跑来对我说: “天覇,你对国际化熟悉吗?”。 我心想,不就是context + hook吗?

image.png 但是不能骄傲,于是谦虚的跟师兄说用过。果然事情没那么简单,师兄接着说:“我们的ant-design-pro有国际化的部分,但是使用场景不多,现在要支持在不需要的时候可以处理掉国际化的代码”。 此时我脑袋极速运转,闪现两个方案:

  • 改写formatMessage
  • 通过AST干掉相关代码 但是改写formatMessage依然会将国际化代码打到包里,作为一个正经程序员怎么会忍受这样的事情?

image.png

于是乎方案还是落定为AST。而此时,师兄已经通过AST处理了一部分代码,现在需要的是彻底处理国际化出现的各种场景。诶?处理了一部分代码?不对劲

image.png

二、AST

AST是什么

AST是抽象语法树(Abstract Syntax Tree),它是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。通俗易懂的说话就是AST是通过JSON的方式来表示代码。

AST作用
  • 编辑器的错误提示、代码格式化、代码高亮、代码自动补全;
  • elint、pretiier 对代码错误或风格的检查;
  • webpack 通过 babel 转译 javascript 语法;
AST生成

目前主要是通过babel-parse将代码转换成AST,babel-parse主要通过词法解析和语法解析生成AST

const ast = parser.parse(code, {
    sourceType: 'module',
    plugins: ['jsx', 'typescript', 'dynamicImport', 'classProperties', 'decorators-legacy'],
  });
  • 词法解析 词法分析阶段把字符串形式的代码转换为 令牌(tokens)流。你可以把令牌看作是一个扁平的语法片段数组。
// 输入
n * n

// 输出
/** 
[
  { type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
  { type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
  { type: { ... }, value: "n", start: 4, end: 5, loc: { ... } },
]
*/

/** 
{
  type: {
    label: 'name',
    keyword: undefined,
    beforeExpr: false,
    startsExpr: true,
    rightAssociative: false,
    isLoop: false,
    isAssign: false,
    prefix: false,
    postfix: false,
    binop: null,
    updateContext: null
  },
  ...
}
*/
  • 语法解析 语法分析就是根据词法分析的结果,也就是令牌tokens,将其转换成AST。
{
  "type": "File",
  "start": 0,
  "end": 3,
  "loc": {
    "start": {
      "line": 1,
      "column": 0
    },
    "end": {
      "line": 1,
      "column": 3
    }
  },
  "errors": [],
  "program": {
    "type": "Program",
    "start": 0,
    "end": 3,
    "loc": {
      "start": {
        "line": 1,
        "column": 0
      },
      "end": {
        "line": 1,
        "column": 3
      }
    },
    "sourceType": "module",
    "interpreter": null,
    "body": [
      {
        "type": "ExpressionStatement",
        "start": 0,
        "end": 3,
        "loc": {
          "start": {
            "line": 1,
            "column": 0
          },
          "end": {
            "line": 1,
            "column": 3
          }
        },
        "expression": {
          "type": "BinaryExpression",
          "start": 0,
          "end": 3,
          "loc": {
            "start": {
              "line": 1,
              "column": 0
            },
            "end": {
              "line": 1,
              "column": 3
            }
          },
          "left": {
            "type": "Identifier",
            "start": 0,
            "end": 1,
            "loc": {
              "start": {
                "line": 1,
                "column": 0
              },
              "end": {
                "line": 1,
                "column": 1
              },
              "identifierName": "n"
            },
            "name": "n"
          },
          "operator": "*",
          "right": {
            "type": "Identifier",
            "start": 2,
            "end": 3,
            "loc": {
              "start": {
                "line": 1,
                "column": 2
              },
              "end": {
                "line": 1,
                "column": 3
              },
              "identifierName": "n"
            },
            "name": "n"
          }
        }
      }
    ],
    "directives": []
  },
  "comments": []
}


三、AST遍历

根据需求,我们需要把代码中的国际化代码都删掉,比如

import {useIntl, FormatedMessage} from 'umi'

function App() {
  
	const Intl = useIntl()
  alert(Intl.formatMessage({id: 'key'}))
  
  alert(useIntl().formatMessage({id: 'key'}))
  
	return (
    <>
      <FormatedMessage />
      <div>{Intl.formatMessage({id: 'key'})}</div>
      <div data-lang>{useIntl && Intl.formatMessage({id: 'key'})}</div>
    </>
  )
}

// Format相关的都要删除,情况非常复杂....

当然,babel这么高的下载量当然会提供一个方式让我们处理AST,说白了AST就是一个JSON,处理的本质还是遍历,babel提供了babel-traverse

traverse.default(ast, {
	enter(path) {
  	// 遍历入口
  },
  exit(path) {
  
  },
  CallExpression(path) {}
  // ...more
})

四、AST处理

AST节点本身具备一些api 遍历到目标节点后,需要对目标节点进行删除处理,babel-type提供了一整套能力 github.com/jamiebuilds… 例子:

traverse.default(ast, {
	CallExpression(p) {
    if (p.container?.property?.name === 'formatMessage') {
      const parent = p.parentPath
      const { arguments: formatMessageArguments } = parent.container;
      if (arguments && arguments.length) {
        const params = {};
        formatMessageArguments.forEach(node => {
          node.properties.forEach(property => {
            params[property.key.name] = property.value.value;
          });
        });
        const message = genMessage(params, localeMap);
        parent.parentPath.replaceWith(t.identifier(`'${message}'`));
      }
    }
  },
  enter(p) {
     // import {useIntl} form 'umi'
    if (path.isImportDeclaration()) {
      const { specifiers } = path.node;
      if (path.node.specifiers) {
        path.node.specifiers = specifiers.filter(({ imported }) => {
          if (imported) {
            return imported.name !== 'useIntl';
          }
          return true;
        });
      }
    }
    if (path.node.source?.value === 'umi' && !path.node.specifiers.length) {
        path.remove()
      return
    }
  }
})

五、踩坑

  • node.container 表示的是node所在的容器节点的内容
  • 无法直接删除节点的属性节点
  • 先执行enter,在执行其他callback,最后执行exit,不要尝试在父节点处理子节点
  • api的入参基本都是节点类型
t.JSXOpeningElement(t.JSXIdentifier('span'), [t.JSXAttribute(t.JSXIdentifier('data-lang-tag'))], true)
  • 无法处理动态国际化

六、最佳实践

虽然通过AST可以处理一些代码,但是都是针对某一些具体语法去做匹配,往往在开发过程中会出现n多种语法,无法保证能覆盖到所有需要处理的代码 可以通过给标签加上一个唯一标识,比如

<div data-lang>{formatMessage({id: key})}</div>
<FormatedMessage data-lang/>

所以针对删除国际化这个需求而言,可以对代码编程制定一些规范