Как выкатить собственный движок JavaScript
Привет, Хаброжители! Мы подготовили для вас перевод статьи, в которой автор делится опытом создания собственного движка JavaScript с нуля. Автор на языке Go с нуля создаёт простой движок JavaScript. В статье пошагово разобраны ключевые этапы: от лексического анализа и построения AST до реализации вычислителя и встроенных функций (print, fetch, JSON). Отличный материал, чтобы понять, как код на JS превращается в работающие инструкции.
Читать далее
Как выкатить собственный движок 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
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | [Перевод] Возвращение аспектно-ориентированного программирования | 0 | 8.4 | 03-07-2026 |
| 2 | Стоит ли начинать программировать на Python или Java в 2026 году — и сколько получают джуны | 0 | 7.92 | 22-07-2026 |
| 3 | Анти‑кейс. Как создать технически идеальный сайт на Next.js про ИИ и нейросети и остаться без поискового трафика | 0 | 5 | 28-06-2026 |
| 4 | Учимся писать свой Prometheus exporter на Go с нуля. Часть 1 | 5 | 8 | 08-07-2026 |
| 5 | Нейросеть-автопилот вместо 400 Playwright-тестов | 0 | 7 | 07-07-2026 |
| 6 | Почему JSON на практике — быстрый формат для баз данных (если выкинуть лишние абстракции) | 5 | 7 | 07-07-2026 |
| 7 | Как я искал себе читалку и придумал новый формат электронных книг для изучающих язык | 0 | 5 | 08-07-2026 |
| 8 | Худший язык программирования всех времён /s | -2 | 6 | 01-07-2026 |
| 9 | Все там будем… | -2 | 6 | 16-07-2026 |