Узнайте, как работает движок JS, создав собственный интерпретатор JS - на JS.

В этом посте мы узнаем, как работает JS-движок, создав собственный JS-интерпретатор на JS! Как-то странно создать языковой интерпретатор с использованием этого языка, не правда ли? Тем не менее, я сделал это потому, что вы больше знаком с JS. Вы можете перевести код JS, который мы здесь напишем, на другой язык по вашему выбору.

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

Совет: легко создавайте и распространяйте пуленепробиваемые компоненты пользовательского интерфейса, которые будут приняты всеми.

Позвольте Bit помочь вам легко создавать и распространять компоненты в ваших проектах, чтобы ваша команда могла адаптировать и использовать их для более быстрого создания ваших приложений.

  • Упакуйте, обновляйте и публикуйте компоненты из любого репо с нулевыми накладными расходами.
  • Создавайте, тестируйте и визуализируйте каждый компонент по отдельности.
  • Легко находите и используйте компоненты как пакеты или как исходный код.
  • Совместная работа над отдельными компонентами из разных проектов.


Что такое переводчик?

Интерпретатор - это оценщик языка, который запускается во время выполнения, выполняя исходный код программы на лету. Он отличается от компилятора. Компиляторы переводят исходный код языка в машинный код.

Интерпретатор анализирует исходный код, генерирует AST (абстрактное синтаксическое дерево) из источника, выполняет итерацию по AST и оценивает их один за другим.

Переводчик состоит из трех этапов:

  • Токенизация
  • Парсинг
  • Оценка

Токенизация

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

Obi is a boy

Мы подсознательно разбиваем предложение на слова:

+--------------------+
| Obi | is | a | boy |
+--------------------+

Это первый этап анализа и понимания предложения. Итак, в программировании мы сломаем следующий источник:

let a = 90;
const b = "nnamdi";
const c = a*5;

в жетоны

+-------------------------------------------------------------+
| let | a | = | 90 | ; | const | b | = | "nnamdi" | ; | const |
+-------------------------------------------------------------+
+-----------------------+
| c | = | a | * | 5 | ; |
+-----------------------+

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

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

Это преобразование токенов, сгенерированных лексером, в абстрактное синтаксическое дерево. AST - это представление потока нашего кода в древовидной форме.

В нашем предложении на английском языке Obi is a boy было разбито на слова:

+--------------------+
| Obi | is | a | boy |
+--------------------+

С его помощью мы можем выбрать слова и сформировать грамматическую структуру:

"Obi": Subject
"is a boy": Predicate
"boy": Object

Obi - это существительное в грамматике, остальное - менее значимое предложение, которое называется предикатом, boy является получателем действия, которое является объектом. Итак, структура выглядит так:

Subject(Noun) -> Predicate -> Object

Итак, в нашем исходном тексте мы можем преобразовать его в структуру под названием AST:

let a = 90;
const b = "nnamdi";
const c = a*5;
|
    |
    v
+-------------------------------------------------------------+
| let | a | = | 90 | ; | const | b | = | "nnamdi" | ; | const |
+-------------------------------------------------------------+
+-----------------------+
| c | = | a | * | 5 | ; |
+-----------------------+
|
    |
    v
{
    Program: {
        body: [
                {
                    type: 'VariableDeclaration',
                    declarations:[ 
                        {
                            type: 'VariableDeclarator',
                            id: { 
                                type: 'Identifier', 
                                name: 'a' 
                            },
                            init: { 
                                type: 'Literal', 
                                value: 90, 
                                raw: '90' 
                            }
                        } 
                    ],
                    kind: 'let' 
                },
                {
                    type: 'VariableDeclarator',
                    id: { 
                        type: 'Identifier',
                        name: 'c' 
                    },
                    init: {
                        type: 'BinaryExpression',
                        left: [Node],
                        operator: '*',
                        right: [Node] 
                    } 
                } 
                // ...
        ]
    }
}

Выше приведено структурное представление исходного текста:

let a = 90;
const b = "nnamdi";
const c = a*5;

оценка

На этом этапе интерпретатор проходит через AST и оценивает каждый узел. Если, например, у нас есть это:

5 + 7
|
    |
    v
+---+  +---+
| 5 |  | 7 |
+---+  +---+
  \     /
   \   /
    \ /
   +---+
   | + |
   +---+
{
    rhs: 5,
    op: '+'.
    lhs: 7
}

Интерпретатор проанализирует ASt, здесь он возьмет узел LHS, затем он собирает узел op, там он видит, что ему нужно сделать добавление, затем должен быть второй узел, он берет узел RHS, он собирает там значения и выполняет сложение на обоих узлах и выдает результат 14.

Мы видим, что каждый узел AST обозначает конструкцию, встречающуюся в исходном коде. Как мы обозначили добавление в узле с тремя ветвями, мы можем использовать выражение if-else:

if(2) {
 //...
} else {
    //...
}
|
    |
    v
+----+
            | if |
            +----+
              |
              |
            +----+
            |cond|
            +----+
              /\
             /  \
            /    \
           /      \
          /        \
         /          \
      +----+       +----+
      |then|       |else|
      +----+       +----+
{
    IfStmnt: {
        condition: 2,
        then: {
            //...
        },
        else: {
            //...
        }
    }
}

Теперь, когда мы узнали этапы работы интерпретатора, в следующем разделе мы увидим практическое применение. Хотя мы не будем создавать парсер, мы будем использовать библиотеку acorn. acorn - это крошечный быстрый парсер JavaScript, полностью написанный на JavaScript. Кроме желудя, есть эсприма, тоже быстрая, но не как желудь.

Настройка проекта

Сначала мы создадим новый проект Node:

mkdir js-interpreter
cd js-interpreter
npm init -y

Устанавливаем библиотеку acorn:

npm i acorn

Создаем index.js файл:

touch index.js

Мы импортируем acorn lib, чтобы мы могли вызвать его функцию синтаксического анализа, но перед этим давайте создадим папку test, которая будет содержать test.js файл:

mkdir test
touch test/test.js

Здесь мы добавим JS-код для тестирования нашего интерпретатора. Давайте просто добавим образец кода в test.js файл

// test/test.js
let a = 90;
const b = "nnamdi";
const c = a*5;

Теперь мы дополняем наш index.js файл:

// src/index.js
const l = console.log
const acorn = require('acorn')
const fs = require('fs')
// pull in the cmd line args
const args = process.argv[2]
const buffer = fs.readFileSync(args).toString()
const body = acorn.parse(buffer).body
l(body)

Мы импортировали acorn lib и файловую систему lib fs. Затем мы получили команду пользователя, затем прочитали файл, переданный в команду args, и преобразовали его в строковый буфер. Затем мы вызвали функцию acorn#parse, передав ее в буфер строки файла.

синтаксический анализ используется для синтаксического анализа программы JavaScript. Параметр input - это строка, options может быть неопределенным или объект, задающий некоторые из параметров, перечисленных ниже. Возвращаемое значение будет объектом абстрактного синтаксического дерева, как указано в спецификации ESTree.

Возвращенный объект AST содержится в переменной тела. Объект AST соответствует спецификации ESTree, организации, занимающейся стандартизацией API-интерфейсов JS.

Узлы ESTree

Это дает нам рекомендации о том, как парсеры JS должны создавать AST. Он указывает, что все узлы являются объектом узла:

Node {
    type,
    loc
}

Свойство type обозначает тип узла, который представляет объект:

  1. Буквальный
  2. Идентификатор
  3. Функция
  4. ClassDeclaration
  5. VariableDeclaration
  6. и так далее

loc содержит позицию объекта узла в исходном тексте.

Объект Literal будет таким:

Node {
    type: "Literal",
    value: string | number | boolean,
    loc
}

Свойство типа указывает на его литерал, свойство значения - это значение литерала в виде строки, числа или логического значения.

Строковый литерал, подобный "nnamdi", будет в AST таким:

Node {
    type: "Literal",
    value: "nnamdi",
    loc
}

Числовой литерал типа 100 будет таким:

Node {
    type: "Literal",
    value: 100,
    loc
}

Идентификатор содержит имена переменных, функций и классов, которые не являются ключевыми словами.

let a = 89

a - идентификатор, содержащий имя декларатора переменной, приведенный выше - VariableDeclarator, id - это идентификатор, а init - это литерал. Это было бы так, как показано ниже в AST:

Node {
    type: 'VariableDeclaration',
    declarations:[ 
        {
            type: 'VariableDeclarator',
            id: { 
                type: 'Identifier', 
                name: 'a' 
            },
            init: { 
                type: 'Literal', 
                value: 89, 
                raw: '89' 
            }
        } 
    ],
    kind: 'let' 
}

Начальный источник исходного текста начинается с типа узла Program.

Node {
    type: "Program",
    declarations: [
        [Node]
    ]
}

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

for(const node of body.declarations) {
    // ...
}

Интерпретация утверждений и выражений

Что такое заявления? Что такое выражения? Оба отличаются?

Да, оба отличаются. Оператор - это инструкция, которая выполняется интерпретатором, а выражение возвращает значение.

Заявления:

  • if заявление
  • while петля
  • for петля
  • за-в
  • для-из
  • do-while петля

Выражения представляют собой двоичные выражения, например:

  • унарное выражение
  • выражение сравнения
  • выражение сложения / умножения / деления / вычитания

В нашем test.js файле есть это:

// test/test.js
let a = 90;
const b = "nnamdi";
const c = a*5;

Глядя на исходный код, у нас есть VaraibelDeclarations, Literals и BinaryExpression. Мы построим наш интерпретатор для поддержки этих узлов.

Наш index.js файл:

// src/index.js
const l = console.log
const acorn = require('acorn')
const fs = require('fs')
// pull in the cmd line args
const args = process.argv[2]
const buffer = fs.readFileSync(args).toString()
const body = acorn.parse(buffer).body
l(body)

Переменная body будет содержать дерево узлов в массиве. Вышеупомянутое будет записывать:

$ node . test/test.js
[ Node {
    type: 'VariableDeclaration',
    start: 0,
    end: 11,
    declarations: [ Node {
    type: 'VariableDeclarator',
    start: 4,
    end: 10,
    id: Node { 
        type: 'Identifier', 
        start: 4, 
        end: 5, 
        name: 'a' },
    init: Node { 
        type: 'Literal', 
        start: 8, 
        end: 10, 
        value: 90, 
        raw: '90' } 
    } ],
    kind: 'let' 
  },
  Node {
    type: 'VariableDeclaration',
    start: 13,
    end: 32,
    declarations: [ Node {
    type: 'VariableDeclarator',
    start: 19,
    end: 31,
    id: Node { 
        type: 'Identifier', 
        start: 19, 
        end: 20, 
        name: 'b' },
    init:
     Node {
       type: 'Literal',
       start: 23,
       end: 31,
       value: 'nnamdi',
       raw: '"nnamdi"' } } ],
    kind: 'const' },
  Node {
    type: 'VariableDeclaration',
    start: 34,
    end: 48,
    declarations: [ Node {
    type: 'VariableDeclarator',
    start: 40,
    end: 47,
    id: Node { 
        type: 'Identifier', 
        start: 40, 
        end: 41, 
        name: 'c' },
    init:
     Node {
       type: 'BinaryExpression',
       start: 44,
       end: 47,
       left: Node { 
           type: 'Identifier', 
           start: 44, 
           end: 45, 
           name: 'a' },
       operator: '*',
       right: right: Node { 
           type: 'Literal', 
           start: 46, 
           end: 47, 
           value: 5, 
           raw: '5' } } 
    } ],
    kind: 'const' } ]

Видишь, у нас есть

  • Переменные
  • Литералы
  • Двоичное выражение

Для интерпретации AST воспользуемся шаблоном Visitor.

Представляют операцию, выполняемую над элементами структуры объекта. Visitor позволяет вам определить новую операцию, не изменяя классы элементов, с которыми он работает.

Мы создадим класс Visitor, у него будет метод, работающий на узле ES:

touch visitor.js

Теперь мы создадим интерпретатор, который будет запускать дерево узлов ES.

touch interpreter.js

Мы добавим метод interpret в класс Interpreter

class Interpreter {
    interpret(nodes) {
    }
}

Теперь у класса Interpreter должен быть экземпляр Visitor, он будет использовать этот экземпляр для запуска ESTree nodes

class Interpreter {
    constructor(visitor) {
        this.visitor = visitor
    }
    interpret(nodes) {
        return this.visitor.run(nodes)
    }
}

Наш класс переводчика завершен. Теперь мы переходим к нашему классу Visitor. Сначала мы добавим метод запуска,

class Visitor {
    visitNodes(nodes) {
        for (const node of nodes) {
            this.visitNode(node)
        }
    }
    run(nodes) {
        return this.visitNodes(body)
    }
}

это зациклит дерево узлов и посетит каждый из них. VisitNodes будет проходить через узлы и вызывать visitNode для каждого из них.

В методе visitNode:

class Visitor {
    ...
    visitNode(node) {
        switch (node.type) {
        }
    }
}

В этом методе используется случай переключения, чтобы узнать тип узла node.type и вызвать метод, выделенный для этого узла. Например, если тип узла - Literal, у нас будет visitNode, подобный этому

class Visitor {
    ...
    visitNode(node) {
        switch (node.type) {
            case "Literal":
                return this.visitLiteral(node)
        }
    }
}

Смотрите, case test для «Literal», там метод visitLiteral вызывается при передаче узла, этот метод является специальным методом, который обрабатывает только литералы. Если мы добавим кейсы для других деревьев узлов, мы создадим специальные методы visitXXX, которые обрабатывают только узел.

Вспомогательные литералы

Реализуем visitLiteral:

class Visitor {
    ...
    visitLiteral(node) {
        return node.value
    }
    visitNode(node) {
        switch (node.type) {
            case "Literal":
                return this.visitLiteral(node)
        }
    }
}

Мы просто возвращаем значение свойства Node value, в котором хранится значение узла Literal.

Вспомогательные идентификаторы

Поддержим Identifier. Теперь мы знаем, что идентификатор представляет имена переменных, имена классов, имена методов и имена функций.

В этом посте мы будем обрабатывать только идентификаторы переменных. Имена классов, методов и функций будут поддерживаться в следующем посте этой серии.

Поэтому мы добавляем регистр «Идентификатор» в оператор переключения visitNode.

case 'Identifier':
                return this.visitIdentifier(node)

Затем мы создаем метод visitIdentifier,

let globalScope = new Map()
class Visitor {
    visitIdentifier(node) {
        const name = node.name
        if (globalScope.get(name))
            return globalScope.get(name)
        else
            return name
    }
}

Мы создали экземпляр Map globalScope, здесь будут храниться все переменные с их именем в качестве ключа. Итак, в visitIdentifier мы извлекаем имя идентификатора, затем проверяем, есть ли он уже в globalScope, если он определен там, мы возвращаем значение, если нет, мы возвращаем идентификатор.

Поддержка объявлений переменных

Подобные объявления переменных

const v, b = 90
let f = 88

хранятся в узле VariableDeclaration:

VariableDeclaration {
    type: "VariableDeclaration",
    declarations: [VariableDeclarator],
    kind: "var"
}

VariableDelarations содержит информацию о типе переменной, затем declarations содержит массив VariableDeclarators

VariableDeclarator имеет узел:

VariableDeclarator {
    type: "VariableDeclarator",
    id: Pattern,
    init: Expression
}

Давайте добавим регистр для «VariableDeclaration» и «VariableDeclarator»:

visitNode(node) {
        switch (node.type) {
            case 'VariableDeclaration':
                return this.visitVariableDeclaration(node)
            case 'VariableDeclarator':
                return this.visitVariableDeclarator(node)

Затем добавляем методы visitVariableDeclaration и visitVariableDeclarator:

visitVariableDeclaration(node) {
        const nodeKind = node.kind
        return this.visitNodes(node.declarations)
    }
    visitVariableDeclarator(node) {
        const id = this.visitNode(node.id)
        const init = this.visitNode(node.init)
        globalScope.set(id, init)
        return init
    }

На данный момент мы ничего не делаем со свойством kind, позже мы добавим поддержку для let, var и const. Мы вызвали visitNodes, проходящие в declarations.

В visitVariableDeclarator мы знаем, что id содержит имя переменной, а init содержит значение или выражение id. После посещения узлов мы используем id в качестве ключа для установки значения init в экземпляре globalScope Map.

Поддерживающие звонки

Теперь мы будем поддерживать звонки. Единственный звонок, который будет поддерживать, - print. Этот вызов будет выводить свои аргументы на терминал.

вызовы представлены в узле CallExpression:

CallExpression {
    type: "CallExpression",
    callee: Expression,
    arguments: [Expression]
}

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

So

print(90)

будет:

CallExpression {
    type: "CallExpression",
    callee: {
        name: "print"
    },
    arguments: [
        {
            raw: 90
        }
    ]
}

в EStree.

Чтобы интерпретировать это: мы добавим кейс CallExpression в кейс переключателя visitNode:

case "CallExpression":
                return this.visitCallExpression(node)

Затем visitCallExpression.

evalArgs(nodeArgs) {
        let g = []
        for (const nodeArg of nodeArgs) {
            g.push(this.visitNode(nodeArg))
        }
        return g
    }
    visitCallExpression(node) {
        const callee = this.visitIdentifier(node.callee)
        const _arguments = this.evalArgs(node.arguments)
        if (callee == "print")
            console.log(..._arguments)
    }

Мы получили имя функции, затем собрали значение аргумента в массиве _arguments, это сделала функция evalArguments. На данный момент мы только что проверили, что имя вызываемой функции - print, если да, мы используем console.log для печати _agruments. На данный момент это все для поддержки призывов.

Поддержка двоичных выражений

Бинарные выражения - это такие операции, как сложение, вычитание, деление, умножение и т. Д.

ESNode для двоичных выражений:

BinaryExpression {
    type: "BinaryExpression",
    operator: BinaryOperator,
    left: Expression,
    right: Expression
}

so

9 * 8

будет представлен в узле BinaryExpression следующим образом:

BinaryExpression {
    type: "BinaryExpression",
    operator: "*",
    left: {
        raw: 9
    },
    right: {
        raw: 8
    }
}

Мы добавим тип дела для «BinaryExpression» в visitNode:

case 'BinaryExpression':
                return this.visitBinaryExpression(node)

Затем мы добавляем visitBinaryExpression:

visitBinaryExpression(node) {
        const leftNode = this.visitNode(node.left)
        const operator = node.operator
        const rightNode = this.visitNode(node.right)
        switch (operator) {
            case ops.ADD:
                return leftNode + rightNode
            case ops.SUB:
                return leftNode - rightNode
            case ops.DIV:
                return leftNode / rightNode
            case ops.MUL:
                return leftNode * rightNode
        }
    }

Левое и правое свойства являются выражениями, поэтому мы назвали их visitNode. Мы использовали случай переключения для типа оператора, см. Случаи для сложения, вычитания, деления и умножения.

Для дополнения мы применили + к левому узлу и правому узлу. То же самое с другими типами операторов, тогда возвращается результат операции.

Тестирование

Теперь мы собираемся протестировать наш интерпретатор. Нам нужно добавить вызовы печати в наш test.js файл, чтобы мы могли увидеть, как идет выполнение:

// test/test.js
let a = 90;
const b = "nnamdi";
const c = a*5;
print(a)
print(b)
print(c)

Затем в файле index.js:

// src/index.js
const l = console.log
const acorn = require('acorn')
const Interpreter = require("./interpreter.js")
const Visitor = require("./visitor.js")
const fs = require('fs')
// pull in the cmd line args
const args = process.argv[2]
const buffer = fs.readFileSync(args).toString()
const jsInterpreter = new Interpreter(new Visitor())
const body = acorn.parse(buffer).body
jsInterpreter.interpret(body)

interpreter.js:

class Interpreter {
    constructor(visitor) {
        this.visitor = visitor
    }
    interpret(nodes) {
        return this.visitor.run(nodes)
    }
}
module.exports = Interpreter

visitor.js:

const l = console.log
const ops = {
    ADD: '+',
    SUB: '-',
    MUL: '*',
    DIV: '/'
}
let globalScope = new Map()
class Visitor {
    visitVariableDeclaration(node) {
        const nodeKind = node.kind
        return this.visitNodes(node.declarations)
    }
    visitVariableDeclarator(node) {
        const id = this.visitNode(node.id)
        const init = this.visitNode(node.init)
        globalScope.set(id, init)
        return init
    }
    visitIdentifier(node) {
        const name = node.name
        if (globalScope.get(name))
            return globalScope.get(name)
        else
            return name
    }
    visitLiteral(node) {
        return node.raw
    }
    visitBinaryExpression(node) {
        const leftNode = this.visitNode(node.left)
        const operator = node.operator
        const rightNode = this.visitNode(node.right)
        switch (operator) {
            case ops.ADD:
                return leftNode + rightNode
            case ops.SUB:
                return leftNode - rightNode
            case ops.DIV:
                return leftNode / rightNode
            case ops.MUL:
                return leftNode * rightNode
        }
    }
    evalArgs(nodeArgs) {
        let g = []
        for (const nodeArg of nodeArgs) {
            g.push(this.visitNode(nodeArg))
        }
        return g
    }
    visitCallExpression(node) {
        const callee = this.visitIdentifier(node.callee)
        const _arguments = this.evalArgs(node.arguments)
        if (callee == "print")
            l(..._arguments)
    }
    visitNodes(nodes) {
        for (const node of nodes) {
            this.visitNode(node)
        }
    }
    visitNode(node) {
        switch (node.type) {
            case 'VariableDeclaration':
                return this.visitVariableDeclaration(node)
            case 'VariableDeclarator':
                return this.visitVariableDeclarator(node)
            case 'Literal':
                return this.visitLiteral(node)
            case 'Identifier':
                return this.visitIdentifier(node)
            case 'BinaryExpression':
                return this.visitBinaryExpression(node)
            case "CallExpression":
                return this.visitCallExpression(node)
        }
    }
    run(nodes) {
        return this.visitNodes(nodes)
    }
}
module.exports = Visitor

Теперь запустим интерпретатор:

$ node . test/test.js
90
"nnamdi"
450

Бум !! мы только что интерпретировали JS-код !!

Заключение

Это только верхушка айсберга. В JS так много функций, которые нужно интерпретировать, мы только что сделали основы здесь.

Мы рассмотрим эти темы в следующем посте серии,

  • мы будем поддерживать остальные операторы: - =, + =, ==, ===,! =,! ==, ‹,›, ‹=,› =, ‹
  • Добавьте поддержку функций, классов, встроенных функций и объектов.
  • Мы добавим поддержку операторов for-of, for-in, for.

До следующего раза.

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

Спасибо !!!

Учить больше