jiayisheji / blog

没事写写文章,喜欢的话请点star,想订阅点watch,千万别fork!
https://jiayisheji.github.io/blog/
505 stars 49 forks source link

抽象语法树 - 编译器背后的魔法 #47

Open jiayisheji opened 2 years ago

jiayisheji commented 2 years ago

你可能见过术语 抽象语法树AST(Abstract Syntax Trees),或者甚至在计算机科学课程中了解过它们。

表面上与我们前端工程师需要做的工作的内容无关,其实相反,抽象语法树在前端生态系统中无处不在。理解抽象语法树并不是成为一名高效或成功的前端工程师的必要条件,但它可以解锁一套在前端开发中具有许多实际应用的新技能。

什么是抽象语法树

在最简单的形式中,抽象语法树是一种表示代码以便计算机能够理解的方法。我们编写的代码是一个巨大的字符串,只有在计算机能够理解和解释这些代码的情况下,它们才能发挥作用。

抽象语法树是一种树状的数据结构。树数据结构从根值开始。然后,根可以指向其他值,这些值又指向其他值,以此类推。这就开始创建一个隐式的层次结构,而且这也是一种很好的方式来表示源代码,计算机可以很容易地解释它。

20211002181937846_tree

例如,假设我们有代码片段 2 +(4 * 10)。要计算此代码,首先执行乘法,然后执行加法。由于添加是这个层次结构中发生的最后一件事或最高的一件事,所以它将是根。然后它指向另外两个值,左边是数字 2,右边是另一个方程。这次是乘法,它也有两个值,左边是 4,右边是 10

20211002180608211_example-ast

使用抽象语法树的一个常见例子是在编译器中。编译器接受源代码作为输入,然后输出另一种语言。这通常是从高级编程语言到低级编程语言,比如机器代码。前端开发中的一个常见示例是转译,其中现代 JavaScript 被转译为旧版本的 JavaScript 语法。

作为一个前端工程师,为什么要关心抽象语法树

首先,我们可能每天都依赖于构建在抽象语法树之上的工具。一些常见的依赖抽象语法树的前端构建工具或编译器的例子有 webpack, babelswc,然而,它们并不是独立地构建工具。像 Pretier(代码格式化器)、ESLint(代码检查器)或 Jscodesshift(代码转换器)这样的工具有不同的目的,但它们都依赖抽象语法树,因为它们都需要直接理解和与源代码一起工作。

在不理解抽象语法树的情况下,可以使用这些工具中的大多数,但是有些工具希望我们理解 AST 以用于更高级的用途。这使得能够以可靠和自动的方式与代码交互,并且前面提到的许多工具在内部使用这些或类似的工具。这允许在静态分析/审核代码中创建完全自定义功能,使动态代码转换,或者我们可能在大代码库中解决任何问题。

虽然理解抽象语法树并不是成为一名高效和成功的前端工程师的必要条件,但具备基本的理解可以提升您维护持续发展的大型代码库的能力,并更容易地与依赖它们的常用工具进行交互。抽象语法树能够“大规模地”与代码库交互。

实用抽象语法树

JSX 编写的 React 应用程序。使用 SASS 编写的 style。使用 Pug 编写的 E-mail 模板。这类项目包含一个编译步骤,该步骤将使用浏览器无法理解的语言编写的源代码转换为浏览器可以解析和执行的 HTML/CSS/JavaScript 代码。

每当我们告诉编译器构建一个 React 应用程序时,我们都希望编译器使用 React 函数调用将 JSX 源代码处理并转换为纯 JavaScript 代码。通常,我们将编译器视为一个黑盒,很少查看它的内部,看看它究竟如何执行这种转换。编译器背后的魔力在于它用来传达源代码结构模式的数据结构:抽象语法树(AST)。

通过分析源代码的语法并将其分解为其组成的标记(例如关键字、字面量、操作符等),我们可以将代码表示为树状数据结构。能够使用抽象语法树泛化源代码的构造和规则,为编译器在转换代码时提供了一个高级模型。这取决于编译器来遍历抽象语法树(通过深度优先搜索遍历算法),从它中提取信息和等效功能的输出代码,但以不同的语言编写或针对性能进行优化。在某些方面,我们可能会说编译器的抽象语法树的作用类似于算法的伪代码。

抽象语法树并不局限于编译器。事实上,它们可以应用于各种用例中,例如:

下面,我将介绍实战案例:

将 JSX 代码转换为抽象语法树

React 中的 JSX 通过使用类似于 HTML 标记的语法来描述组件的 UI。与其他基于 JavaScript 的模板语言一样(Ejs,Handlerbars 和 Pug)。JSX 需要一个编译器将代码编译为 JavaScript,以便在浏览器上运行。

例如,浏览器无法识别这样的代码:

<ul>
  {props.items.map(({ id, name }) => (
    <li key={id}>{name}</li>
  ))}
</ul>

浏览器只支持JavaScript语言的语法(即ECMAScript规范的官方特性)。因此,JavaScript 代码中的标记语法是无效的,并将导致运行时错误。为了避免这个问题,我们必须将上面的代码编译成浏览器能够理解的纯 JavaScript 代码:

React.createElement("ul", {}, props.items.map(({ id, name }) => (
  React.createElement("li", { key: id }, name)
)));

JSX 充当 React.createElement 方法的语法糖。对于具有大量嵌套元素的较大组件,JSX 提供了更强的可读性和清晰度。编译器要将JSX转换为JavaScript,原始源代码必须是:

  1. 被编译器的前端扫描并解析。它对 JSX 源代码进行标记,对标记进行排序,并将每个标记分类为标识符、操作符等。一旦它完成了对源代码的分析,前端将标记序列表示为一个抽象语法树。语法的不相关部分被“抽象”掉了,因为这些细节已经可以从树结构(即层次结构)中暗示出来。
  2. 由编译器的后端以所需的语言输出。它接受抽象语法树作为输入,并基于抽象语法树生成 JavaScript 代码。

如果我们想预览由不同解析器生成的抽象语法树,那么我们可以在 AST Explorer 编辑器中输入上面的 JSX 代码。

大多数基于javascript的抽象语法树都遵循 ESTree 规范,它定义了属性的语法分类。下面是由JSX代码的解析器生成的抽象语法树(JSON格式)的精简版本。

[
  {
    "type": "ExpressionStatement",
    "expression": {
      "type": "JSXElement",
      "openingElement": {
        "type": "JSXOpeningElement",
        "name": {
          "type": "JSXIdentifier",
          "name": "ul"
        }
      },
      "closingElement": {
        "type": "JSXClosingElement",
        "name": {
          "type": "JSXIdentifier",
          "name": "ul"
        }
      },
      "children": [
        {
          "type": "JSXExpressionContainer",
          "expression": {
            "type": "CallExpression",
            "callee": {
              "type": "MemberExpression",
              "object": {
                "type": "MemberExpression",
                "object": {
                  "type": "Identifier",
                  "name": "props"
                },
                "property": {
                  "type": "Identifier",
                  "name": "items"
                }
              },
              "property": {
                "type": "Identifier",
                "name": "map"
              }
            },
            "arguments": [
              {
                "type": "ArrowFunctionExpression",
                "params": [
                  {
                    "type": "ObjectPattern",
                    "properties": [
                      {
                        "type": "ObjectProperty",
                        "key": {
                          "type": "Identifier",
                          "name": "id"
                        },
                        "value": {
                          "type": "Identifier",
                          "name": "id"
                        }
                      },
                      {
                        "type": "ObjectProperty",
                        "key": {
                          "type": "Identifier",
                          "name": "name"
                        },
                        "value": {
                          "type": "Identifier",
                          "name": "name"
                        }
                      }
                    ]
                  }
                ],
                "body": {
                  "type": "JSXElement",
                  "openingElement": {
                    "type": "JSXOpeningElement",
                    "name": {
                      "type": "JSXIdentifier",
                      "name": "li"
                    },
                    "attributes": [
                      {
                        "type": "JSXAttribute",
                        "name": {
                          "type": "JSXIdentifier",
                          "name": "key"
                        },
                        "value": {
                          "type": "JSXExpressionContainer",
                          "expression": {
                            "type": "Identifier",
                            "name": "id"
                          }
                        }
                      }
                    ]
                  },
                  "closingElement": {
                    "type": "JSXClosingElement",
                    "name": {
                      "type": "JSXIdentifier",
                      "name": "li"
                    }
                  },
                  "children": [
                    {
                      "type": "JSXExpressionContainer",
                      "expression": {
                        "type": "Identifier",
                        "name": "name"
                      }
                    }
                  ]
                }
              }
            ]
          }
        }
      ]
    }
  }
]

在这里查看完整的抽象语法树。

下面是这个抽象语法树的可视化:

gfds8g7e12g1297gsdfagf

注意:叶节点代表代码本身的实际标识符、关键字和文字。其余的父节点表示解析器发现的令牌类型。

下面的图表说明了编译器是如何工作的:

m1m0dfs8fa8fh3h2

负责为 JSX 代码输出上述抽象语法树的解析器是 Babel 解析器,它为 Babel 编译器解析源代码。Babel 使用了基于 ESTree 规范的抽象语法树。使用 JavaScript Next 和 JSX 编写的 React 应用程序依赖于 Babel 编译器将代码转换为与我们选择的目标浏览器兼容的 JavaScript。

用 Babel 插件转换 JSX 代码

通过插件,Babel 将特定的代码转换应用到源代码中。例如,如果要使用箭头函数语法,但需要我们的应用程序在 Internet Explorer 中运行,然后在配置文件中启用 Babel 插件 @Babe/Plugin-Transform-arrow-functions。每当 Babel 遇到箭头函数语法的任何示例时,它都会使用此插件转换它们。

对于 JSX 代码,使用 Babel 插件 @babel/plugin-transform-react-jsx 解析并将 JSX 代码转换为 React 函数调用。

为了理解插件是如何工作的,我们必须先看看 Babel 是如何解析 JSX 代码的。Babel 不仅仅是一个独立包。相反,它的核心功能分为几个不同的包:

Babel 语法分析器 @babel/parser 提供了一种 parse 方法,可读取源代码(作为字符串),并从中生成抽象语法树。

下面是一个简单的程序,用于打印 JSX 代码片段的抽象语法树(JSON格式):

const { parse } = require("@babel/parser");

const code = `
  <ul>
    {props.items.map(({ id, name }) => (
      <li key={id}>{name}</li>
    ))}
  </ul>
`;

const ast = parse(code, {
  plugins: ["jsx"]
});

console.log(JSON.stringify(ast, null, 2));

注意:需要 npm install @babel/parser -D

Screen Shot 2021-10-08 at 5 51 42 PM

解析器选项插件告诉 Babel 解析器("jsx," "flow" 或 "typescript")启用。Babel 解析器直接支持 JSX、Flow 和 TypeScript。

Babel 插件 @babel/plugin-transform-react-jsx 通过这种确切的方式解析 JSX 代码。它委托将 JSX 代码解析为 @babel/plugin-syntax-jsx

import { declare } from "@babel/helper-plugin-utils";

export default declare(api => {
  api.assertVersion(7);

  return {
    name: "syntax-jsx",

    manipulateOptions(opts, parserOpts) {
      if (
        parserOpts.plugins.some(
          p => (Array.isArray(p) ? p[0] : p) === "typescript",
        )
      ) {
        return;
      }

      parserOpts.plugins.push("jsx");
    },
  };
});

这个插件所做的就是修改解析器的选项。declare 辅助方法保持了与 Babel v6 的向后兼容性。该插件会检查 TypeScript 插件(@babel/plugin-transform-typescript)是否已经运行。如果是这样,那么插件什么也不做,因为代码(大概是用 TSX, JSX 的 TypeScript 变体) 已经被 TypeScript 插件解析和转换过了。如果没有 TypeScript插件,那么该插件就会像前面的例子一样,简单地把 jsx 插件添加到解析器的插件选项中。

@babel/plugin-transform-react-jsx@babel/plugin-syntax-jsx 插件包含在 @babel/preset-react 解析器中,这是最常用的 Babel 预设,用于编译 React 应用程序。

注意:preset 是插件的集合。你只需要列出一个包含这些插件的 preset ,而不是手动列出你想为 Babel 编译器启用的单个插件。

实际上,create-react-app 使用这个 preset 来编译React应用程序。

使用 babel 插件 @babel/plugin-transform-react-jsx 将 JSX 代码转换:

  1. 安装 Babel CLI 工具和 Babel 编译器核心。
npm install --save-dev @babel/core @babel/cli
  1. 安装 @babel/plugin-transform-react-jsx@babel/preset-react ,其中包括此插件:
npm install --save-dev @babel/plugin-transform-react-jsx # Or @babel/preset-react.
  1. 通过在 Project 目录的根目录处创建 .babelrc.json 文件,使用插件配置 Babel。
{
  // Uncomment the below if you use the preset.
  // "presets": [
  //   "@babel/preset-react"
  // ],
  "plugins": [
    "@babel/plugin-transform-react-jsx"
  ]
}
  1. package.json 中,添加一个 npm scripts 以编译 JSX 代码。
{
  "scripts": {
    "build": "babel <file_with_jsx_code.jsx> > build.js"
  }
}

就这样,完了。

当你为下面的 JSX 代码运行这个 npm run build 时:

/*#__PURE__*/
React.createElement("ul", null, props.items.map(({
  id,
  name
}) => /*#__PURE__*/React.createElement("li", {
  key: id
}, name)));

随着 JavaScript 语言的不断发展和新的规范和建议的引入,Babel 不断推出了新的插件来支持这些特性,即使目标浏览器中尚未存在,新插件也会添加支持这些功能。

写在最后

考虑我们可以使用抽象语法树来自动执行代码审核和语法检查的方法。

探索 Babel 的插件和预设的生态系统,以便在我们的项目中使用令人兴奋的新功能,无论浏览器支持如何,我们都可以在项目中的可选链操作符

今天就到这里吧,伙计们,玩得开心,祝你好运。