Как выкатить собственный движок 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.

Как работают движки?

Важнее всего знать, как именно работают эти движки. Почти все современные языки, как интерпретируемые, так и компилируемые, обрабатываются в ходе схожих фундаментальных этапов. Они довольно просты:

  1. Исходный код

  2. Лексический анализ: каждый символ в исходном коде сканируется и приравнивается к токену

  3. Парсинг: сканирование токенов и создание инструкций, образующих абстрактное синтаксическое дерево

  4. Вычисление: Последовательный обход абстрактного синтаксического дерева узел за узлом и последующее выполнение кода

Основы работы интерпретатора
Основы работы интерпретатора

Поток выполнения

Лексический анализ (лексер)

Лексический анализ — это первый этап интерпретации исходного кода. В сущности, он заключается в посимвольном просмотре исходного кода, и в ходе этого процесса каждому символу присваивается токен. Рассмотрим простое объявление переменной: 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)


  1. unreal_undead2
    09.06.2026 06:25

    Самое интересное в JS движке - вывод типов и соответствующие оптимизации в JIT, без этого современные сайты не взлетят даже на нормальном железе.