Как выкатить собственный движок JavaScript

Введение
Большинство разработчиков, имеющих дело с JavaScript — кто на фронтенде, кто на бэкенде — очень слабо себе представляют, что именно происходит под капотом, когда выполняется их код. Я сейчас имею в виду не основы работы с переменными или функциями, а более глубокий процесс интерпретации и выполнения кода.
Когда вы пишете на JavaScript, код проходит через множество этапов: лексический анализ, парсинг, преобразование в абстрактное синтаксическое дерево (AST) и, наконец, выполнение внутри движка. Из подобных движков шире всего известен V8 от Google, лежащий в основе Node.js, Deno и всех Chromium-подобных браузеров. На основе движка JavaScriptCore от Apple работают Safari и Bun. Ради повышения производительности такие движки обычно реализуются на низкоуровневых языках, например, like C, C++, но сейчас появляются и более новые, основанные на Rust. Кроме типичных движков для браузеров и серверов также существуют специализированные движки, спроектированные для устройств IoT, бессерверных окружений и других нишевых вариантов применения.
В этой статье будет разобрано, как с нуля написать простой движок для JavaScript на языке Go. В данном случае Go выбран за простоту и удобочитаемость — следовательно, он удобен для демонстрационных примеров. Но движки, рассчитанные на использование в продакшене, часто пишутся на более низкоуровневых языках, например, C++ или Rust, при поддержке мощных библиотек для асинхронных операций. Примеры таких библиотек — libuv или tokio.
Как работают движки?
Важнее всего знать, как именно работают эти движки. Почти все современные языки, как интерпретируемые, так и компилируемые, обрабатываются в ходе схожих фундаментальных этапов. Они довольно просты:
Исходный код
Лексический анализ: каждый символ в исходном коде сканируется и приравнивается к токену
Парсинг: сканирование токенов и создание инструкций, образующих абстрактное синтаксическое дерево
Вычисление: Последовательный обход абстрактного синтаксического дерева узел за узлом и последующее выполнение кода

Поток выполнения
Лексический анализ (лексер)
Лексический анализ — это первый этап интерпретации исходного кода. В сущности, он заключается в посимвольном просмотре исходного кода, и в ходе этого процесса каждому символу присваивается токен. Рассмотрим простое объявление переменной: var x = 5; Лексер прочитает все эти символы и создаст для каждого токен. Список токенов, используемых в V8, приведён здесь. Поскольку у нас демонстрационный пример, мы будем пользоваться лишь ограниченным набором токенов.
func (l *Lexer) NextToken() token.Token { var tok token.Token l.skipWhitespace() switch l.ch { case 0: // конец ввода tok = token.Token{Type: token.EOF, Literal: ""} case '=': // Может быть '=' (присваивание) или '==' (проверка на равенство) if l.peekChar() == '=' { ch := l.ch l.readChar() tok = token.Token{Type: token.EQ, Literal: string(ch) + string(l.ch)} } else { tok = newToken(token.ASSIGN, l.ch) } // ... case ';': tok = newToken(token.SEMICOLON, l.ch // ... } l.readChar() // Переходим к следующему символу return tok }
В качестве вывода в нашем примере лексер выдаст лишь токены, получится что-то подобное:
VAR "var" IDENT "x" ASSIGN "=" NUMBER "5" SEMICOLON ";"
Синтаксический анализ (парсинг)
Теперь, имея токены, можно собрать из них AST (абстрактное синтаксическое дерево). В AST наша программа представлена в форме древовидной структуры. На данном этапе мы проходим примерно те же этапы, что и при работе с лексером: каждый токен сканируется, затем парсится, и на основе этих данных создаётся инструкция.
func (p *Parser) parseStatement() ast.Statement { switch p.currentToken.Type { case token.VAR, token.LET: // todo: пока var и let обрабатываются как одинаковые, а const не реализуется return p.parseVarStatement() case token.RETURN: return p.parseReturnStatement() case token.IF: return p.parseIfStatement() case token.WHILE: return p.parseWhileStatement() case token.LBRACE: return p.parseBlockStatement() default: return p.parseExpressionStatement() } }
Вот какой вывод получим от переменной, которую взяли в качестве примера:
Program └── VarStatement ├── Name: "x" └── Value: NumberLiteral(5)
Чтобы вы могли понять AST глубже, я хотел бы привести более интересный пример. Рассмотрим приведённую ниже функцию и изучим, как будет выглядеть её вывод в форме AST.
var add = function(a, b) { return a + b; }; var result = add(5, 3); print(result);
Program ├── Statement 1: VarStatement │ ├── Name: "add" │ └── Value: FunctionLiteral │ ├── Parameters: ["a", "b"] │ └── Body: BlockStatement │ └── ReturnStatement │ └── Value: InfixExpression │ ├── Left: Identifier("a") │ ├── Operator: "+" │ └── Right: Identifier("b") │ ├── Statement 2: VarStatement │ ├── Name: "result" │ └── Value: CallExpression │ ├── Function: Identifier("add") │ └── Arguments: [NumberLiteral(5), NumberLiteral(3)] │ └── Statement 3: ExpressionStatement └── CallExpression ├── Function: Identifier("print") └── Arguments: [Identifier("result")]

Ещё есть отличный сайт, на котором демонстрируется абстрактное синтаксическое дерево для любого приведённого кода. Его можно испробовать в действии: https://astexplorer.net/
Вычислитель
Это последний этап, на котором мы обходим AST узел за узлом и выполняем то, что там записано: создаём переменные, функции, поддерживаем окружения (области видимости) и делаем многое другое.
func Eval(node ast.Node, env *Environment) Value { switch node := node.(type) { case *ast.Program: return evalProgram(node, env) case *ast.VarStatement: return evalVarStatement(node, env) case *ast.ReturnStatement: return evalReturnStatement(node, env) case *ast.ExpressionStatement: return Eval(node.Expression, env) case *ast.BlockStatement: return evalBlockStatement(node, env) case *ast.IfStatement: return evalIfStatement(node, env) case *ast.WhileStatement: return evalWhileStatement(node, env) case *ast.NumberLiteral: return node.Value case *ast.StringLiteral: return node.Value case *ast.BooleanLiteral: return node.Value case *ast.Identifier: return evalIdentifier(node, env) case *ast.PrefixExpression: return evalPrefixExpression(node, env) case *ast.InfixExpression: return evalInfixExpression(node, env) case *ast.AssignExpression: return evalAssignExpression(node, env) case *ast.FunctionLiteral: return &Function{ Parameters: node.Parameters, Body: node.Body, Env: env, } case *ast.CallExpression: return evalCallExpression(node, env) case *ast.ObjectLiteral: return evalObjectLiteral(node, env) case *ast.PropertyAccess: return evalPropertyAccess(node, env) } return nil }
Что именно происходит в рамках каждой операции вычисления, зависит от типа обрабатываемого узла. Например, evalVarStatement просто сохраняет значение в окруженииEnv.set(node.Name, node.Value). Если узел представляет функцию, то движок создаёт новое окружение (область видимости функции), а затем выполняет тело функции в этом контексте.
func evalVarStatement(node *ast.VarStatement, env *Environment) Value { var val Value = nil if node.Value != nil { val = Eval(node.Value, env) } env.Set(node.Name, val) return val }
Как видите, в некоторых случаях мы вызываем Eval рекурсивно. Дело в том, что node.Value не всегда является простым значением, оно может быть и другим выражением. Рассмотрим, например, var x = 5 + 2. Здесь Value — это InfixExpression. При повторном вызове Eval делегирует выполнение к evalInfixExpression, которая обрабатывает узел, а затем соответствующим образом вычисляет выражение.
Реализация встроенных функций
К настоящему моменту, вероятно, общая структура работы уже понятна: читаем код символ за символом, токенизируем его, строим абстрактное синтаксическое дерево, а затем выполняем. Вероятно, вы уже заметили, что мы используем функцию print, а не console.log — причём, намеренно. Далее рассмотрим, как она реализована и расширим наш интерпретатор, добавив в него fetch, JSON и встроенные функции массивов.
При парсинге выражения вызова обнаруживается, что оно может приводить к 4 разным случаям:
Вызов встроенной функции, напр.
print, fetchВызов встроенной функции через доступ к свойству, напр.
JSON.parseОпределяемый пользователем вызов функции, напр.
sum, addОшибка, недопустимый вызов функции
func evalCallExpression(node *ast.CallExpression, env *Environment) Value { // Проверяем наличие встроенных функций if ident, ok := node.Function.(*ast.Identifier); ok { if builtin, ok := builtins.Get(ident.Name); ok { args := []interface{}{} for _, arg := range node.Arguments { args = append(args, Eval(arg, env)) } return builtin.Fn(args...) } } function := Eval(node.Function, env) // Проверяем, происходит ли обращение к встроенной функции через доступ к свойству (напр., JSON.stringify) if builtin, ok := function.(*builtins.Builtin); ok { args := []interface{}{} for _, arg := range node.Arguments { args = append(args, Eval(arg, env)) } return builtin.Fn(args...) } // ... return result }
Под evaluator/builtins у нас есть пакет, в котором определяются такие функции как print , fetch , JSON.* , встроенные функции для работы с массивами и главный файл пакета:
type Builtin struct { Name string Fn func(args ...interface{}) interface{} } var builtins = map[string]*Builtin{ "print": {Name: print.Print.Name, Fn: print.Print.Fn}, "fetch": {Name: fetch.Fetch.Name, Fn: fetch.Fetch.Fn}, } var jsonNamespace = make(map[string]*Builtin) func init() { for key, builtin := range json.JSON { jsonNamespace[key] = &Builtin{Name: builtin.Name, Fn: builtin.Fn} } } func Get(name string) (*Builtin, bool) { builtin, ok := builtins[name] return builtin, ok } func GetJSON() map[string]*Builtin { return jsonNamespace }
Как только интерпретатор видит выражение, соответствующее вызову функции, он пытается найти идентификатор встроенной функции. Если такой идентификатор существует, то он выполняет (осень простую) встроенную функцию, написанную на Go.
var Print = &Builtin{ Name: "print", Fn: func(args ...interface{}) interface{} { for i, arg := range args { if i > 0 { fmt.Print(" ") } fmt.Print(internal.ToString(arg)) } fmt.Println() return nil }, }
fetch устроена примерно так же. В ней мы парсим аргументы, такие как method , тело options, заголовки и т.д. среди опций, а затем на Go создаём HTTP-вызов. Встроенные функции для работы с массивами слегка отличаются от fetch или JSON. Здесь я не буду вдаваться в детали, но, если вы посмотрите код, то увидите, что там всё достаточно прямолинейно.
Реализовав эти встроенные функции, можно пользоваться в нашем JavaScript print , fetch , JSON.parse ,JSON.stringify и такими методами для работы с массивами как array.map , array.filter и т.д.
let response3 = fetch("https://jsonplaceholder.typicode.com/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({"title": "My Post", "body": "This is a test post", "userId": 1}) }) print("Status:", response3.status) print("OK:", response3.ok) print("Body:", response3.body)

Чем движок JavaScript отличается от среды выполнения JavaScript
Здесь важно отметить, что очень многие путаются, чем именно отличаются движок (V8, JavaScriptCore, SpiderMonkey) и среда выполнения (Node, Deno, Bun). Я добавил в сам движок функции print и fetch, но на практике console или fetch предоставляются не движком, а средой выполнения. Движок отвечает лишь за выполнение кода JavaScript как такового (в соответствии со спецификацией ECMAScript), и это выполнение состоит из этапов, которые мы обсуждали выше: парсинг исходного кода, создание абстрактного синтаксического дерева, оптимизации и т.д. Движок ничего не знает об API Fetch, Console, Timer. Именно среда выполнения отвечает за предоставление как этих API, так и многих других, например, fs , http в Node или document , window в браузере.
print (или console) и fetch не относятся к движку (здесь добавлены лишь в демонстрационных целях), но JSON.* — относится, равно как и многие другие встроенные функции, указанные в спецификации ECMAScript. Эти API предоставляются нам средами выполнения с использованием нативных биндингов в C++. Также можете посмотреть, как это делается в Deno , где показано создание среды выполнения с нуля с использованием Rusty V8. Это интерфейс для доступа из Rust к написанному на C++ API V8 при нулевых издержках.
Дальнейшие улучшения
Добавить дополнительную семантику JavaScript: поддержка таких фич как поднятие переменных, ключевое слово this, NaN, undefined и другие встроенные варианты поведения, обеспечивающие более полное соответствие движка реальному JavaScript.
Компиляция байт-кода: можно не интерпретировать AST напрямую, а компилировать его в байт-код, чтобы таким образом ускорить выполнение.
Оптимизации: реализовать улучшения производительности, примерно как в движках, используемых в реальных проектах — например, встраивание, удаление общих подвыражений (CSE), а также другие приёмы оптимизации.
Соответствие спецификации ECMAScript: постоянно сверяться со спецификацией ECMAScript, чтобы убедиться, что ваш движок работает именно так, как описано в официальном стандарте.
Система модулей: поддержка import/export и управление областями видимости модулей.
Заключение
Именно так работает под капотом простейший движок JavaScript. Мы рассмотрели, как в результате разбора кода выстраивается абстрактное синтаксическое дерево, узлы которого затем вычисляются, а код выполняется — все переменные, функции и выражения в нужном контексте.
Этот пример в демонстрационных целях был намеренно упрощён. Я об этом даже не говорил, но реальные движки, например,V8 или JavaScriptCore устроены гораздо сложнее, с оптимизациями, динамической компиляцией и продвинутыми возможностями управления памятью. Но, всё-таки, разработав такую упрощённую версию, мы лучше понимаем базовую механику JavaScript, как код в браузере выполняется на самом деле. Этот опыт помогает нам по достоинству оценить труд многих и многих инженеров, которые годами проектировали и совершенствовали эти невероятные движки.
Репозиторий на GitHub: https://github.com/dogukanakkaya/go-script
unreal_undead2
Самое интересное в JS движке - вывод типов и соответствующие оптимизации в JIT, без этого современные сайты не взлетят даже на нормальном железе.