Перейти к основному содержимому

5.01. Работа с JavaScript и его применение

Разработчику Архитектору

Работа и применение

JavaScript — единственный язык программирования, изначально предназначенный для непосредственного выполнения в среде веб-браузера и впоследствии адаптированный для серверной среды. Его уникальность заключается в тесной интеграции с моделью документа (DOM), событийной системой, асинхронной моделью выполнения и ограниченной, но гибкой средой исполнения. Чтобы понять, как работает JavaScript, необходимо рассматривать его не изолированно, а как часть целостного механизма взаимодействия между пользователем, разметкой, стилями и логикой.

1. Роль JavaScript в веб-архитектуре

В классической клиент-серверной модели веба HTML отвечает за структуру документа, CSS — за его визуальное оформление, а JavaScript — за динамику и поведение. Без JavaScript страница остаётся статичной: она может быть красивой, информативной, но не интерактивной. С внедрением JavaScript документ становится живым объектом, способным реагировать на действия пользователя, обновлять содержимое без перезагрузки, анимировать переходы, обмениваться данными с сервером и даже управлять устройствами (камера, геолокация, Bluetooth и др.).

С технической точки зрения, JavaScript — это скриптовый язык, выполняемый интерпретатором, встроенным в браузер. Этот интерпретатор называется движком JavaScript. Наиболее распространённые движки:

  • V8 — в Google Chrome и Chromium-производных (Edge, Brave, Opera); также лежит в основе Node.js.
  • SpiderMonkey — в Mozilla Firefox.
  • JavaScriptCore (Nitro) — в Safari (WebKit).
  • Chakra — использовался в старых версиях Microsoft Edge (до перехода на Chromium).

Эти движки реализуют стандарт ECMAScript — спецификацию, утверждаемую комитетом TC39. Фактически, «JavaScript» — это реализация ECMAScript в браузере (с расширениями в виде Web API: DOM, Fetch, Canvas и др.). На сервере, например, в Node.js, JavaScript также выполняется движком V8, но без Web API — вместо них доступны системные API (файловая система, сеть, потоки процессора и т.п.).

Важно: Сам по себе язык (ECMAScript) не содержит встроенных возможностей для работы с DOM, сетью или таймерами. Эти возможности предоставляются хост-средой — браузером или Node.js. Именно поэтому один и тот же синтаксис fetch('/api/data') работает в браузере, но не в «чистом» V8 без обёртки.

2. Как браузер выполняет JavaScript

Браузер не «запускает» JavaScript как отдельную программу. Вместо этого он встраивает его в общий цикл обработки страницы. Каждый раз, когда браузер встречает тег <script> при парсинге HTML, он приостанавливает построение DOM и выполняет содержимое скрипта (если не указан атрибут async или defer). Этот процесс неизбежно связан с другими этапами отрисовки — и именно здесь возникают типичные проблемы производительности.

Рассмотрим полную последовательность этапов обработки веб-страницы, включая роль JavaScript:

  1. Парсинг HTML
    Браузер читает HTML-код с верху вниз и строит дерево узлов — DOM (Document Object Model). DOM — это иерархическая структура, где каждый элемент (<div>, <p>, <button>) представлен как объект с атрибутами, дочерними элементами и поведением.

  2. Парсинг CSS и построение CSSOM
    Параллельно (или при обнаружении <style> и <link rel="stylesheet">) браузер обрабатывает стили и строит CSSOM (CSS Object Model) — дерево правил, применяемых к элементам DOM. CSSOM необходим для определения, как именно будет выглядеть каждый элемент.

  3. Выполнение JavaScript
    При встрече с <script src="..."> или встроенным <script>...</script> браузер:

    • (если скрипт не асинхронный) приостанавливает парсинг HTML;
    • загружает и выполняет JavaScript-код;
    • в процессе выполнения код может:
      • читать и изменять DOM и CSSOM;
      • добавлять новые элементы (document.createElement);
      • удалять или перестраивать существующие;
      • запрашивать ресурсы (fetch, XMLHttpRequest);
      • регистрировать обработчики событий (addEventListener).

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

  4. Построение Render Tree
    После завершения парсинга HTML и CSS, а также выполнения всех синхронных скриптов, браузер объединяет DOM и CSSOM в дерево отрисовки (Render Tree). В это дерево попадают только видимые элементы — например, элементы со стилем display: none или <script> исключаются.

  5. Layout (Reflow)
    Для каждого узла Render Tree вычисляются точные геометрические параметры: размеры, координаты, положение относительно родителей и окна. Этот процесс называется layout (в WebKit) или reflow. Он затратен: изменение геометрии одного элемента может повлечь перерасчёт всей подветки дерева.

  6. Paint (Rasterization)
    Браузер преобразует узлы Render Tree в пиксели — заполняет цвета, текстуры, тени, границы. Этот этап может происходить на GPU (в отдельных слоях) для ускорения.

  7. Composite
    Если элементы находятся на разных слоях (например, из-за transform или will-change), браузер собирает их в единое изображение — как художник накладывает прозрачные плёнки друг на друга.

JavaScript может влиять на любой из этих этапов:

  • Изменение element.style.widthreflow;
  • Изменение element.style.backgroundColorrepaint (но не reflow);
  • Изменение transform → только composite (оптимально).

Это объясняет, почему «браузеры кушают память»: каждый скрипт, особенно некорректно написанный (утечки памяти, бесконечные таймеры, замыкания с ссылками на DOM), увеличивает объём удерживаемых объектов. А поскольку DOM-узлы, обработчики событий и замыкания хранятся в куче JavaScript-движка, их накопление приводит к росту потребления памяти — вплоть до замедления или краха вкладки.

Стоит подчеркнуть: Память «съедает» некорректное использование возможностей в связке с DOM. На сервере (Node.js), где DOM отсутствует, аналогичные утечки происходят реже — если только разработчик не создаёт глобальные коллекции, не отписывается от событий или не хранит ссылки на большие буферы.

3. Архитектура выполнения

JavaScript — язык с динамической типизацией, однопоточной (на уровне события) и асинхронной моделью выполнения. Эти три свойства определяют, как движок обрабатывает код.

3.1. Лексический анализ и синтаксический парсинг

Перед выполнением движок проходит два этапа:

  • Лексический анализ (tokenization) — разбиение исходного кода на «токены»: ключевые слова (if, function), идентификаторы (x, getData), литералы (42, "hello"), операторы (+, ===) и т.д.
  • Синтаксический анализ (parsing) — построение абстрактного синтаксического дерева (AST) из токенов. AST — это структурированное представление программы, по которой движок может строить план выполнения.

Например, код:

const a = 1 + 2;

превращается в AST с узлами VariableDeclaration, Identifier, BinaryExpression, Literal.

Современные движки (особенно V8) используют Just-In-Time (JIT) компиляцию: сначала интерпретируют код (через Ignition — интерпретатор), затем по мере выполнения профилируют «горячие» функции и компилируют их в машинный код (TurboFan — оптимизирующий компилятор). Это позволяет сочетать гибкость интерпретации с производительностью компиляции.

3.2. Выполнение и вызовный стек

JavaScript использует стек вызовов (Call Stack) — LIFO-структуру, фиксирующую, какие функции в данный момент выполняются. Каждый вызов функции добавляет новый фрейм в стек; возврат из функции — удаляет его.

Пример:

function greet(name) {
return "Hello, " + name;
}
function main() {
console.log(greet("Тимур"));
}
main();

Последовательность:

  1. main() → фрейм main в стеке;
  2. внутри main вызывается greet("Тимур") → фрейм greet поверх main;
  3. greet возвращает строку → фрейм greet удаляется;
  4. console.log выполняется → новый фрейм (но быстро завершается);
  5. main завершается → стек пуст.

Если функция вызывает себя рекурсивно без условия выхода — стек переполняется (Stack Overflow).

3.3. Модель событий и цикл событий (Event Loop)

Поскольку JavaScript однопоточен, он не может одновременно выполнять функцию и ждать ответа от сервера. Решение — асинхронная модель с циклом событий.

Когда движок встречает асинхронную операцию (таймер setTimeout, сетевой запрос fetch, ввод-вывод в Node.js), он делегирует её внешней среде (браузеру или системе), продолжая выполнять синхронный код. По завершении операции соответствующий коллбэк помещается в очередь задач (task queue).

Цикл событий (Event Loop) — это бесконечный цикл, который:

  • проверяет, пуст ли Call Stack;
  • если да — извлекает первую задачу из очереди и помещает её в стек.

Таким образом, setTimeout(() => console.log(1), 0) не выполняется сразу — только после того, как текущий синхронный код (включая все вложенные вызовы) завершится.

Это объясняет поведение:

console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
// Вывод: A → C → B

Важно: очередь не одна. В спецификации различают:

  • Macrotasks (основная очередь): setTimeout, setInterval, requestAnimationFrame, ввод-вывод, события DOM;
  • Microtasks (очередь более высокого приоритета): Promise.then, queueMicrotask, MutationObserver.

Цикл событий отрабатывает все microtasks после каждого macrotask’а, но до отрисовки. Это критично для согласованности DOM и Promise-цепочек.

3.4. Управление памятью

JavaScript использует автоматическое управление памятью через сборку мусора (Garbage Collection). Основная стратегия — достижимость (reachability): объект остаётся в памяти, пока на него есть ссылка из «корня» (глобальный объект window, текущий стек вызовов, активные замыкания, DOM-узлы, зарегистрированные обработчики).

Пример утечки:

function setup() {
const largeData = new Array(1e6).fill(0);
document.getElementById("btn").addEventListener("click", () => {
console.log(largeData.length); // замыкание захватывает largeData
});
}
setup();
// largeData не будет удалён, даже после завершения setup()

Здесь largeData остаётся достижимым через замыкание, привязанное к обработчику события. Чтобы избежать утечки, нужно отписаться от события или явно обнулить ссылку.

Современные сборщики мусора работают инкрементально и консервативно, но всё равно вызывают паузы — особенно при больших объёмах данных. Хорошая практика — минимизировать время жизни временных объектов и избегать глобальных переменных.


Лексическая структура JavaScript

Прежде чем писать работающий код, необходимо понимать, из каких «кирпичиков» он состоит. Лексическая структура (lexical structure) определяет, какие символы и последовательности считаются допустимыми в языке. Это не просто формальность — нарушение этих правил приводит к синтаксическим ошибкам ещё до начала выполнения.

1. Чувствительность к регистру

JavaScript чувствителен к регистру (case-sensitive). Это означает, что идентификаторы name, Name, NAME — три разных переменных. То же касается ключевых слов: function допустимо, Function — нет (это встроенный конструктор, но не ключевое слово).

Практическое следствие:

  • getElementById — корректный метод;
  • getelementbyid — ошибка (метод не существует).

Чувствительность к регистру повышает точность и выразительность, но требует дисциплины. Особенно важно при работе с API, где имена часто следуют соглашениям:

  • camelCase — стандарт для переменных и методов (userProfile, calculateTotal);
  • PascalCase — для конструкторов и классов (HttpRequest, DatabaseConnection);
  • UPPER_SNAKE_CASE — для констант (MAX_RETRY_ATTEMPTS).

Автоматическая проверка стиля (через ESLint, Prettier) помогает избежать случайных ошибок.

2. Юникод и кодировка

JavaScript поддерживает Юникод (Unicode) как основную кодировку символов. Это позволяет использовать латинские символы, кириллицу, иероглифы, эмодзи и специальные математические символы в идентификаторах — при соблюдении правил.

Согласно спецификации ECMA-262, идентификатор может состоять из:

  • буквы (включая Unicode-буквы, например, α, я, );
  • цифры (но не в начале);
  • символа подчёркивания _;
  • символа доллара $;
  • специальных Unicode-символов, разрешённых в категориях ID_Start и ID_Continue.

Примеры допустимых имён:

let привет = "мир";
let ε = 0.001;
let _temp = 42;
let $elem = document.body;
let user_name = "Тимур"; // но по стилю лучше user_name → userName

Однако не рекомендуется использовать не-ASCII символы в production-коде:

  • снижается переносимость (проблемы с некоторыми инструментами сборки);
  • усложняется чтение в международных командах;
  • повышается риск ошибок при копировании (например, неразрывный пробел U+00A0 выглядит как обычный, но вызывает ошибку).

Кодировка исходных файлов должна быть UTF-8 (без BOM). Это стандарт де-факто для веба и Node.js. Сервер должен отправлять заголовок Content-Type: text/javascript; charset=utf-8, а HTML — содержать <meta charset="utf-8">, чтобы избежать искажений.

3. Комментарии, пробелы и табуляция

Комментарии — единственный способ оставить пояснения для человека, игнорируемые движком. В JavaScript два типа:

  • Однострочные: // это комментарий до конца строки
  • Многострочные: /* ... */ — может охватывать несколько строк, но не вкладывается.

Пример:

// Получаем список пользователей с сервера
// Кэшируем результат на 5 минут
function fetchUsers() {
/*
* Временная заглушка:
* пока API не готово, возвращаем тестовые данные
*/
return [
{ id: 1, name: "Тимур" }
];
}

Важно: Комментарии не должны описывать что делает код — это видно из кода. Они объясняют почему принято решение, почему реализовано именно так. «Плохой» комментарий: i++; // увеличиваем i на 1. «Хороший»: i++; // пропускаем заголовок CSV (первая строка).

Пробелы, табуляции и переносы строк — так называемые пробельные символы (whitespace). Они игнорируются движком, за исключением случаев:

  • внутри строковых литералов ("пробел ""пробел");
  • при разделении токенов (letx = 1 — ошибка; let x = 1 — корректно);
  • в регулярных выражениях (флаг x позволяет игнорировать пробелы, но по умолчанию они значимы).

Табуляция (\t) и пробелы ( ) функционально равнозначны в плане синтаксиса — разница только в стиле. Однако единообразие критично для командной работы. В современных проектах почти повсеместно используется 2 или 4 пробела вместо табуляции, чтобы избежать расхождений при отображении (разные редакторы показывают таб как 2, 4 или 8 пробелов).

Автоматические форматировщики (Prettier) решают эту проблему раз и навсегда.

4. Ключевые слова и зарезервированные идентификаторы

Ключевые слова — это слова, имеющие специальное значение в синтаксисе JavaScript и нельзя использовать как имена переменных, функций или параметров. Их список фиксирован спецификацией и включает:

break, case, catch, class, const, continue, debugger, default, delete, do, else, export, extends, finally, for, function, if, import, in, instanceof, new, return, super, switch, this, throw, try, typeof, var, void, while, with, yield.

Пример ошибки:

let const = 5; // SyntaxError: Unexpected token 'const'

Зарезервированные слова (reserved words) — это идентификаторы, не являющиеся ключевыми сейчас, но зарезервированные для будущего использования (в строгом режиме или в определённых контекстах). К ним относятся:

enum, await, implements, interface, let, package, private, protected, public, static.

Например, let сейчас — ключевое слово, но в ES3 оно было просто зарезервированным; await — ключевое только внутри async-функций.

Также существуют строго зарезервированные слова в строгом режиме ("use strict"): implements, interface, package, private, protected, public, static, yield. Использование их как имён в таком режиме вызовет ошибку.

Почему это важно? Потому что инструменты сборки (Babel, TypeScript) и минификаторы полагаются на точное знание этих списков. Неправильное использование может привести к неочевидным ошибкам после транспиляции.


Как работать с JavaScript

Знание синтаксиса — лишь фундамент. Качественная разработка требует системного подхода к проектированию. Ниже — проверенный алгоритм, применимый как к маленьким скриптам, так и к сложным SPA.

1. Анализ задачи

Первый шаг — чётко ответить: что должен делать скрипт, для кого, и в каком контексте? Примеры формулировок:

  • «Пользователь должен мгновенно видеть результат фильтрации списка сотрудников по отделу, без перезагрузки страницы»;
  • «После ввода номера телефона система проверяет его корректность и подсвечивает поле зелёным/красным»;
  • «При наведении на карточку товара появляется увеличенное изображение в виде всплывающей подсказки».

На этом этапе не пишем код. Мы фиксируем:

  • входные данные (откуда берутся?);
  • ожидаемое поведение (как реагирует интерфейс?);
  • граничные условия (что, если данных нет? если сеть недоступна?).

2. Декомпозиция

Большая задача — источник ошибок и прокрастинации. Её нужно разбить на независимые, тестируемые шаги. Возьмём пример: «реализовать форму обратной связи с валидацией, отправкой и отображением статуса».

Подзадачи:

  1. Отобразить форму (HTML + CSS);
  2. Подключить обработчик отправки (submit);
  3. Валидация полей (имя — не пусто, email — корректный формат);
  4. Блокировка кнопки на время отправки;
  5. Отправка данных через fetch на /api/contact;
  6. Обработка ответа:
    • 2xx → показать «Спасибо!»;
    • 4xx/5xx → показать ошибку;
  7. Очистка полей после успешной отправки.

Каждая подзадача — кандидат на отдельную функцию. Это обеспечивает модульность: можно тестировать валидацию независимо от отправки.

3. Реализация

Функция в JavaScript — не просто «кусок кода». Это:

  • единица инкапсуляции (скрывает детали реализации);
  • единица повторного использования;
  • единица тестирования.

Хорошая функция:

  • имеет одну ответственность (Single Responsibility Principle);
  • получает входные данные через параметры (а не из глобальной области);
  • возвращает результат (а не меняет внешнее состояние без необходимости);
  • имеет понятное имя (глагол + объект: validateEmail, renderUserList).

Пример:

function validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}

function showValidationMessage(field, isValid) {
const message = field.nextElementSibling;
message.textContent = isValid ? "" : "Неверный email";
field.classList.toggle("invalid", !isValid);
}

Обратите внимание:

  • validateEmailчистая функция: одни и те же входные данные всегда дают один и тот же результат, нет побочных эффектов;
  • showValidationMessageимперативная функция: изменяет DOM, но делает это локально, не затрагивая другие части страницы.

4. Тестирование в консоли

Консоль разработчика — не только для отладки ошибок. Это интерактивная лаборатория, где можно:

  • вызывать функции с разными аргументами:
    validateEmail("test@example.com"); // true
    validateEmail("invalid"); // false
  • проверять состояние DOM:
    document.querySelector("#email").value;
    document.querySelector(".error-message").textContent;
  • эмулировать события:
    document.querySelector("form").dispatchEvent(new Event("submit"));

Это позволяет верифицировать каждую подзадачу до интеграции. Если validateEmail работает в консоли, но не в форме — проблема в привязке обработчика.

5. Рефакторинг

Рефакторинг — не «когда будет время». Это обязательный этап, выполняемый сразу после подтверждения работоспособности. Его цель — повысить:

  • читаемость (имена, структура);
  • сопровождаемость (разделение ответственностей);
  • тестируемость (чистые функции, слабая связность).

Типичные действия:

  • Выделение повторяющейся логики в функцию:
    // Было:
    if (name.trim() === "") { showError(nameField); }
    if (email.trim() === "") { showError(emailField); }

    // Стало:
    function isEmpty(value) { return value.trim() === ""; }
    if (isEmpty(name)) { showError(nameField); }
    if (isEmpty(email)) { showError(emailField); }
  • Замена «магических значений» на константы:
    const MIN_AGE = 18;
    if (user.age >= MIN_AGE) { ... }
  • Упрощение условий:
    // Было:
    if (isValid === true) { ... }

    // Стало:
    if (isValid) { ... }

Пренебрежение рефакторингом приводит к «техническому долгу» — код, который работает, но который страшно трогать.


Работа с DOM

Хотя JavaScript — язык общего назначения, его основное применение — манипуляция DOM. Здесь важно различать:

  • Императивный подход — явное указание, как изменить DOM (создать узел, вставить, обновить атрибут);
  • Декларативный подход — описание желаемого состояния (например, в React: «вот данные — отрисуйте компонент»).

Рассмотрим императивный стиль — основу понимания.

1. Получение элементов

Современные методы (предпочтительны):

  • document.querySelector(selector) — первый элемент по CSS-селектору;
  • document.querySelectorAll(selector) — NodeList всех совпадений;
  • element.closest(selector) — ближайший родитель, соответствующий селектору;
  • element.matches(selector) — проверяет, подходит ли элемент под селектор.

Устаревшие (избегайте):

  • getElementById, getElementsByClassName, getElementsByTagName — менее гибкие, возвращают «живые» коллекции (могут вызывать reflow при итерации).

2. Изменение содержимого

  • element.textContent = "Текст" — безопасно, экранирует HTML;
  • element.innerHTML = "<b>Жирный</b>" — интерпретирует как HTML (опасно при вставке пользовательских данных — XSS-уязвимость);
  • element.insertAdjacentHTML(position, html) — более гибкая вставка без пересоздания всего содержимого.

3. Создание и вставка элементов

Лучше не строить DOM через конкатенацию строк. Используйте:

const li = document.createElement("li");
li.textContent = "Новый элемент";
li.className = "list-item";
li.dataset.id = "123";

list.appendChild(li);

Для массовой вставки — DocumentFragment или append()/prepend():

const fragment = document.createDocumentFragment();
items.forEach(item => {
const li = document.createElement("li");
li.textContent = item;
fragment.appendChild(li);
});
list.appendChild(fragment); // одна операция вставки

Это минимизирует количество reflow/paint.

4. Кэширование ссылок и делегирование

Частая ошибка — многократный вызов querySelector:

// Плохо:
function handleClick() {
const btn = document.querySelector("#submit-btn");
btn.disabled = true;
// ...логика
btn.disabled = false;
}

Лучше: кэшировать один раз при инициализации:

const submitBtn = document.querySelector("#submit-btn");

function handleClick() {
submitBtn.disabled = true;
// ...
submitBtn.disabled = false;
}

А для динамических элементов — делегирование, как описано ранее.