[Что вам следует знать] Абстрактное синтаксическое дерево AST

JavaScript

команда:skFeTeam Автор этой статьи: Ли Шивэй

Как фронтенд-программист, часто ли вы используете webpack, rollup, babel, eslint? Это инструменты упаковки, инструменты компиляции кода, инструменты проверки синтаксиса. Как они этого добились? Абстрактное синтаксическое дерево, представленное в этой статье, — это технология, которую они используют.

В этой статье нет непонятных теорий, нет больших участков кода, она начинается с нуля, и Xiaobai может прочитать ее без каких-либо препятствий. Прочитав эту статью, вы поймете основные принципы AST и способы ее использования.

предисловие

Что такое абстрактное синтаксическое дерево?

  • AST (Abstract Syntax Tree) — это абстрактная структура синтаксического дерева, формирующая исходный код. На рисунке ниже показана форма абстрактного синтаксического дерева фрагмента кода JavaScript.

0.什么是抽象语法树.png

В чем польза абстрактных синтаксических деревьев?

  • Подсказки об ошибках IDE, форматирование кода, подсветка кода, автодополнение кода и т. д.
  • JSLint, JSHint, ESLint проверяет код на ошибки или стиль и т.д.
  • Webpack, сборка для упаковки кода и т. д.
  • Babel преобразует синтаксис ES6 в ES5
  • Внедрить статистическое покрытие кода модульным тестом

содержание

  • 1. Парсер АСТ
  • 2.AST in Babel
  • 3.Demo with esprima
  • 4. Вопросы для размышления

1. Парсер АСТ

1.1 Парсер JS Parser

Как формируется АСТ?

  • Инструмент, который преобразует исходный код JavaScript в абстрактное синтаксическое дерево (AST), называется JS Parser.

Процесс разбора JS Parser состоит из двух частей.

  • Лексический анализ: разделите всю строку кода на наименьший массив синтаксических единиц.
  • Синтаксический анализ: установить и проанализировать взаимосвязь между грамматическими единицами на основе сегментации слов.

1.JS_Parser的解析过程.png

Общие парсеры AST

  • В первые дни были uglifyjs и esprima
  • Эспри, на основе эспримы, для эслинта
  • Желудь, который, как говорят, имеет лучшую производительность и меньший размер, чем эсприма.
  • Вавилон, из желудя, для вавилона
  • Babel-eslint, поддерживаемый командой babel, для использования с ESLint.

1.2 Лексический анализ

Грамматическая единица — это наименьшая единица с фактическим значением в проанализированной грамматике, которая представляет собой просто слово в естественном языке.

Синтаксические единицы в коде Javascript в основном включают следующее:

  • Ключевые слова: например, var, let, const и т. д.
  • Идентификатор: последовательные символы, не заключенные в кавычки, которые могут быть переменной, ключевыми словами, такими как if, else, или встроенными константами, такими как true и false.
  • Операторы: +, -, *, / и т.д.
  • Числа: такие как шестнадцатеричные, десятичные, восьмеричные и научные выражения и т. д.
  • Строка: потому что для компьютера содержимое строки будет участвовать в вычислении или отображении
  • пробелы: последовательные пробелы, разрывы строк, отступы и т. д.
  • Комментарий: строчный комментарий или блочный комментарий — это наименьшая неделимая синтаксическая единица.
  • Другие: фигурные скобки, круглые скобки, точки с запятой, двоеточия и т. д.

1.3 Синтаксический анализ

Объедините результаты сегментации слов, определите взаимосвязь между словами, определите окончательное значение выражения слов и создайте абстрактные синтаксические деревья.

1.4 Пример

  • Возьмите оператор присваивания в качестве примера, используйте esprima для разбора:
var a = 1;
  • Результат лексического анализа следующий: видно, что результатом сегментации слов является массив, каждый элемент которого является минимальной грамматической единицей:
[
    {
        "type": "Keyword",
        "value": "var"
    },
    {
        "type": "Identifier",
        "value": "a"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Numeric",
        "value": "1"
    },
    {
        "type": "Punctuator",
        "value": ";"
    }
]
  • Результаты синтаксического анализа следующие, а результаты сегментации слов формируются в древовидную структуру в соответствии с взаимосвязью:
{
    "type": "Program",
    "body": [
        {
            "type": "VariableDeclaration",
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "id": {
                        "type": "Identifier",
                        "name": "a"
                    },
                    "init": {
                        "type": "Literal",
                        "value": 1,
                        "raw": "1"
                    }
                }
            ],
            "kind": "var"
        }
    ],
    "sourceType": "script"
}

1.5 Веб-сайт инструментов

esprima/parser

  • Классический анализатор абстрактного синтаксического дерева JavaScript, веб-сайт предоставляет множество функций.
  • Причастия и абстрактные синтаксические деревья можно просмотреть онлайн.
  • Syntax показывает абстрактные синтаксические деревья, Tokens показывают сегментацию слов
    1.esprima-parser.png
  • Также приводятся сравнения производительности различных синтаксических анализаторов, и кажется, что производительность Acorn лучше.
    1.各种parse的性能比较.png

AST Explorer

  • Веб-сайт инструмента визуализации AST, который может использовать различные синтаксические анализы для выполнения преобразования AST в коде.
    1.AST-Explorer可视化工具.png

Спецификация разбора AST (The Estree Spec)

  • Один и тот же код JavaScript, результаты AST, проанализированные разными парсерами, одинаковы, потому что все они ссылаются на одну и ту же спецификацию синтаксического анализа AST.
  • The Estree SpecСпецификация — это документ спецификации вывода JavaScript AST движком SpiderMonkey, предоставленный инженерами Mozilla. Вы также можете обратиться к:SpiderMonkey in MDN

2.AST in Babel

Содержание AST было представлено ранее, давайте посмотрим, как Babel использует AST.

Как работает Вавилон

Рабочий процесс Babel проходит через три этапа: анализ, преобразование, генерация.

  • этап синтаксического анализа, который преобразует исходный код в AST
  • Этап преобразования с использованием различных плагинов для преобразования кода
  • На этапе генерации инструмент генерации кода используется для преобразования AST в код.

2.Babel的运行原理.png

Разбор - анализ

  • Babel использует @babel/parser для анализа кода, а входная строка кода js генерирует AST в соответствии со спецификацией ESTree.
  • Парсер, используемый Babel, называется babylon.

Трансформировать-трансформировать

  • Берет AST и проходит по нему, добавляя, обновляя и удаляя узлы по пути. Это также часть работы по интеграции надстройки Babel.
  • Babel предоставляет метод @babel/traverse (обход) для поддержания общего состояния дерева AST.Параметры метода — исходный AST и пользовательские правила преобразования, а возвращаемый результат — преобразованный AST.

Генератор - Генерировать

  • На этапе генерации кода окончательный (после ряда преобразований) AST преобразуется в код в строковой форме, а также создаются исходные карты.
  • Пройдите весь AST и создайте строки, которые могут представлять преобразованный код.
  • Babel использует @babel/generator для преобразования модифицированного AST в код Процесс генерации можно настроить на сжатие и удаление комментариев, а также поддерживает sourceMap.

3.Demo with esprima

Поняв принцип работы Babel, мы напишем демонстрацию в соответствии с тремя шагами Babel, чтобы углубить наше понимание AST.

Мы собираемся использовать esprima для имитации функциональности двух перекодировок:
  • Измените == на конгруэнтное ===
  • Изменить parseInt(a) на parseInt(a,10)

Код до конвертации, before.js:

function fun1(opt) {
  if (opt.status == 1) {
      console.log('1');
  }
}
function fun2(age) {
  if (parseInt(age) >= 18) {
      console.log('2');
  }
}

Ожидаемый преобразованный код, after.js:

function fun1(opt) {
    if (opt.status === 1) {//==变成===
        console.log('1');
    }
}
function fun2(age) {
    if (parseInt(age, 10) >= 18) {//parseInt(a)变成parseInt(a,10)
        console.log('2');
    }
}
  1. Приступайте к работе, сначала познакомьтесь с набором инструментов
//引入工具包
const esprima = require('esprima');//JS语法树模块
const estraverse = require('estraverse');//JS语法树遍历各节点
const escodegen = require('escodegen');//JS语法树反编译模块
const fs = require('fs');//读写文件
  1. Преобразование исходного кода в AST с помощью синтаксического анализа esprima. Как, это очень просто, всего одна строка кода сделает это.
const before = fs.readFileSync('./before.js', 'utf8');
const ast = esprima.parseScript(before);
  1. Пройдите AST и найдите код, который соответствует правилам преобразования для преобразования
estraverse.traverse(ast, {
  enter: (node) => {
    toEqual(node);//把 == 改为全等 ===
    setParseInt(node); //把 parseInt(a) 改为 parseInt(a,10)
  }
});
  1. Давайте посмотрим на реализацию функций toEqual и setParseInt.
function toEqual(node) {
  if (node.operator === '==') {
    node.operator = '===';
  }
}

function setParseInt(node) {
  //判断节点类型,方法名称,方法的参数的数量,数量为1就增加第二个参数
  if (node.type === 'CallExpression' && node.callee.name === 'parseInt' && node.arguments.length === 1) {
    node.arguments.push({//增加参数,其实就是数组操作
      "type": "Literal",
      "value": 10,
      "raw": "10"
    });
  }
}
  1. Наконец, преобразованный AST генерирует строковый код и записывает его в файл.
//生成目标代码
const code = escodegen.generate(ast);
//写入文件
fs.existsSync('./after.js') && fs.unlinkSync('./after.js');
fs.writeFileSync('./after.js', code, 'utf8');

Хорошо, откройте файл after.js, чтобы убедиться, что он был успешно преобразован? Это то, что мы ожидали? Есть ли вавилонское чувство? Да, на самом деле, Babel тоже это делает, но его функция правила преобразования довольно сложна, потому что ей нужно учитывать различные условия синтаксиса JavaScript, а рабочая нагрузка огромна, что является ядром Babel.

Оглядываясь назад на написанную нами демонстрацию, мы видим, что она полностью следовала трем шагам Babel. Первый шаг разбора и третий шаг генерации очень просты, о предложении и говорить нечего. Основное внимание уделяется Transform, реализации функции правила преобразования.Некоторые люди могут спросить, откуда вы знаете, что функции преобразования toEqual и setParseInt должны быть написаны таким образом?

Хорошо, чтобы ответить на этот вопрос, давайте взглянем на AST до и после преобразования кода этих двух правил.

  • Измените == на конгруэнтное ===

AST для a==b выглядит следующим образом:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "BinaryExpression",
                "operator": "==",
                "left": {
                    "type": "Identifier",
                    "name": "a"
                },
                "right": {
                    "type": "Identifier",
                    "name": "b"
                }
            }
        }
    ],
    "sourceType": "script"
}

AST для a===b выглядит следующим образом:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "BinaryExpression",
                "operator": "===",
                "left": {
                    "type": "Identifier",
                    "name": "a"
                },
                "right": {
                    "type": "Identifier",
                    "name": "b"
                }
            }
        }
    ],
    "sourceType": "script"
}

Сравните два вышеупомянутых AST, это не только поле "оператор" отличается, один ==, другой ===.

Давайте посмотрим на функцию toEqual, понятно? Просто измените значение node.operator, чтобы завершить преобразование.

function toEqual(node) {
  if (node.operator === '==') {
    node.operator = '===';
  }
}
  • Изменить parseInt(a) на parseInt(a,10)

AST для parseInt(a) выглядит следующим образом:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "CallExpression",
                "callee": {
                    "type": "Identifier",
                    "name": "parseInt"
                },
                "arguments": [
                    {
                        "type": "Identifier",
                        "name": "a"
                    }
                ]
            }
        }
    ],
    "sourceType": "script"
}

AST для parseInt(a, 10) выглядит следующим образом:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "CallExpression",
                "callee": {
                    "type": "Identifier",
                    "name": "parseInt"
                },
                "arguments": [
                    {
                        "type": "Identifier",
                        "name": "a"
                    },
                    {
                        "type": "Literal",
                        "value": 10,
                        "raw": "10"
                    }
                ]
            }
        }
    ],
    "sourceType": "script"
}

Сравните эти два AST, видите? Просто в массиве arguments есть следующий элемент.

{
    "type": "Literal",
    "value": 10,
    "raw": "10"
}

Таким образом, в функции правила преобразования мы добавляем этот элемент для реализации преобразования. Это очень просто?

function setParseInt(node) {
  //判断节点类型,方法名称,方法的参数的数量,数量为1就增加第二个参数
  if (node.type === 'CallExpression' && node.callee.name === 'parseInt' && node.arguments.length === 1) {
    node.arguments.push({//增加参数,其实就是数组操作
      "type": "Literal",
      "value": 10,
      "raw": "10"
    });
  }
}

Хорошо, пока эта демонстрация должна быть полностью понята.

4. Вопросы для размышления

Увидев это, вы поняли принцип и использование AST. Давайте рассмотрим задачу и проверим результаты обучения.

Предположим, что a является объектом, var a = {b : 1}, тогда a.b или a['b'], что более эффективно?

Методы записи a.b и a['b'] часто используются всеми, возможно, вы не заметили, что между этими двумя методами записи будут различия в производительности. Фактически, некоторые люди проводили тесты, и разница в производительности между ними невелика, и a.b будет работать немного лучше, чем a['b']. Так почему же a.b работает немного лучше, чем a['b']?

На мой взгляд, a.b может напрямую анализировать b как атрибут a, а a['b'] может иметь еще один процесс оценки, потому что содержимое в [] может быть переменной или константой.

Это утверждение может показаться разумным, но так ли это? Есть ли какие-либо доказательства в поддержку этого утверждения?

Ну, чтобы объяснить эту проблему, мы можем начать только с двигателя V8.

4.V8引擎.png

Код js может работать на процессоре, в основном благодаря движку js.Движок V8 разработан Google и применяется к браузеру Chrome и nodejs.Это классический движок js. Как видно из рисунка выше, в движке V8 есть три основных этапа трансляции js из исходного кода в машинный код: Parser (AST) -> Ignition (Bytecode) -> TurboFan (Machine Code)

  • Парсер: отвечает за преобразование исходного кода JavaScript в абстрактное синтаксическое дерево (AST).
  • Зажигание: интерпретатор, интерпретатор, отвечает за преобразование AST в байт-код, интерпретацию и выполнение байт-кода; в то же время сбор информации, необходимой для TurboFan для оптимизации компиляции, такой как тип параметров функции
  • TurboFan: компилятор, компилятор, использует информацию о типах, собранную Ignitio, для преобразования байт-кода в оптимизированный ассемблерный код.

Парсер-AST парсер

  • Вы должны быть знакомы с парсером AST, который мы представили сегодня.

Зажигание - Интерпретатор

  • AST преобразуется в язык, похожий на ассемблер, внешне похожий на язык ассемблера, называемый байт-кодом. Этот язык не имеет ничего общего с процессором, и байт-код, сгенерированный на машинах с разными процессорами, одинаков.

Турбофан - Компилятор

  • Как мы все знаем, архитектура и набор инструкций каждого процессора различны, и соответствующий язык ассемблера будет другим. На этом этапе V8 анализирует байт-код на языке ассемблера, подходящем для разных процессоров для разных процессоров. V8 может поддерживать более дюжины языков ассемблера процессора.

4.V8引擎代码解析.png

Теперь давайте сравним разницу между a.b и a['b'] при анализе V8

  • Код теста для a.b выглядит следующим образом:
function test001() {
    var a = { b: 1 };
    console.log(a.b)
}
test001();
  • Тестовый код для a['b'] выглядит следующим образом:
function test002() {
    var a = { b: 1 };
    console.log(a['b'])
}
test002();

Они смотрят на сгенерированный байт-код

  • Байт-код a.b выглядит следующим образом:
[generated bytecode for function: test001]
Parameter count 1
Frame size 32
   16 E> 000001F6C03D7192 @    0 : a0                StackCheck 
   33 S> 000001F6C03D7193 @    1 : 79 00 00 29 fa    CreateObjectLiteral [0], [0], #41, r1
         000001F6C03D7198 @    6 : 27 fa fb          Mov r1, r0
   46 S> 000001F6C03D719B @    9 : 13 01 01          LdaGlobal [1], [1]
         000001F6C03D719E @   12 : 26 f9             Star r2
   54 E> 000001F6C03D71A0 @   14 : 28 f9 02 03       LdaNamedProperty r2, [2], [3]
         000001F6C03D71A4 @   18 : 26 fa             Star r1
   60 E> 000001F6C03D71A6 @   20 : 28 fb 03 05       LdaNamedProperty r0, [3], [5]
         000001F6C03D71AA @   24 : 26 f8             Star r3
   54 E> 000001F6C03D71AC @   26 : 57 fa f9 f8 07    CallProperty1 r1, r2, r3, [7]
         000001F6C03D71B1 @   31 : 0d                LdaUndefined 
   63 S> 000001F6C03D71B2 @   32 : a4                Return 
Constant pool (size = 4)
Handler Table (size = 0)
  • Байт-код a['b'] выглядит следующим образом:
[generated bytecode for function: test002]
Parameter count 1
Frame size 32
   16 E> 0000022E1C7D6DC2 @    0 : a0                StackCheck 
   33 S> 0000022E1C7D6DC3 @    1 : 79 00 00 29 fa    CreateObjectLiteral [0], [0], #41, r1
         0000022E1C7D6DC8 @    6 : 27 fa fb          Mov r1, r0
   46 S> 0000022E1C7D6DCB @    9 : 13 01 01          LdaGlobal [1], [1]
         0000022E1C7D6DCE @   12 : 26 f9             Star r2
   54 E> 0000022E1C7D6DD0 @   14 : 28 f9 02 03       LdaNamedProperty r2, [2], [3]
         0000022E1C7D6DD4 @   18 : 26 fa             Star r1
   59 E> 0000022E1C7D6DD6 @   20 : 28 fb 03 05       LdaNamedProperty r0, [3], [5]
         0000022E1C7D6DDA @   24 : 26 f8             Star r3
   54 E> 0000022E1C7D6DDC @   26 : 57 fa f9 f8 07    CallProperty1 r1, r2, r3, [7]
         0000022E1C7D6DE1 @   31 : 0d                LdaUndefined 
   66 S> 0000022E1C7D6DE2 @   32 : a4                Return 
Constant pool (size = 4)
Handler Table (size = 0)

Сравнив байт-код двух, вы обнаружите, что они абсолютно одинаковы, а это означает, что нет никакой разницы в производительности между двумя методами записи при выполнении уровня байт-кода и ниже. На самом деле они разные, смотреть можно только вверх, а там только этап Parser. Давайте посмотрим на разницу между их AST.

  • Код AST элемента a.b выглядит следующим образом:
{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "MemberExpression",
                "computed": false,
                "object": {
                    "type": "Identifier",
                    "name": "a"
                },
                "property": {
                    "type": "Identifier",
                    "name": "b"
                }
            }
        }
    ],
    "sourceType": "script"
}
  • Код AST для a['b'] выглядит следующим образом:
{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "MemberExpression",
                "computed": true,
                "object": {
                    "type": "Identifier",
                    "name": "a"
                },
                "property": {
                    "type": "Literal",
                    "value": "b",
                    "raw": "'b'"
                }
            }
        }
    ],
    "sourceType": "script"
}

Единственное отличие, которое мы обнаружили, — это атрибут «вычисляемый», a.b — ложь, a['b'] — истина, что указывает на то, что при синтаксическом анализе в AST у a['b'] на один вычислительный процесс больше, чем у a.b. Из этого мы заключаем, что небольшая разница между ними должна быть здесь. Что ж, улики найдены, теперь сомнений быть не должно.

окончание

Увидев это, вы не только понимаете соответствующие знания AST, но и знаете, как движок V8 парсит js-код. Если вы считаете, что эта статья вам полезна, пожалуйста, тоже лайкните, кстати, большое спасибо (поклон на 90 градусов).

Если вы хотите узнать больше о публикациях skFeTeam, вы можете нажатьздесь, спасибо~