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:
-
Парсинг HTML
Браузер читает HTML-код с верху вниз и строит дерево узлов — DOM (Document Object Model). DOM — это иерархическая структура, где каждый элемент (<div>,<p>,<button>) представлен как объект с атрибутами, дочерними элементами и поведением. -
Парсинг CSS и построение CSSOM
Параллельно (или при обнаружении<style>и<link rel="stylesheet">) браузер обрабатывает стили и строит CSSOM (CSS Object Model) — дерево правил, применяемых к элементам DOM. CSSOM необходим для определения, как именно будет выглядеть каждый элемент. -
Выполнение JavaScript
При встрече с<script src="...">или встроенным<script>...</script>браузер:- (если скрипт не асинхронный) приостанавливает парсинг HTML;
- загружает и выполняет JavaScript-код;
- в процессе выполнения код может:
- читать и изменять DOM и CSSOM;
- добавлять новые элементы (
document.createElement); - удалять или перестраивать существующие;
- запрашивать ресурсы (
fetch,XMLHttpRequest); - регистрировать обработчики событий (
addEventListener).
Именно на этом этапе возникает блокировка рендеринга. Скрипт может изменить DOM, который уже частично построен — и тогда браузер вынужден перестраивать дерево с того места, где произошло изменение.
-
Построение Render Tree
После завершения парсинга HTML и CSS, а также выполнения всех синхронных скриптов, браузер объединяет DOM и CSSOM в дерево отрисовки (Render Tree). В это дерево попадают только видимые элементы — например, элементы со стилемdisplay: noneили<script>исключаются. -
Layout (Reflow)
Для каждого узла Render Tree вычисляются точные геометрические параметры: размеры, координаты, положение относительно родителей и окна. Этот процесс называется layout (в WebKit) или reflow. Он затратен: изменение геометрии одного элемента может повлечь перерасчёт всей подветки дерева. -
Paint (Rasterization)
Браузер преобразует узлы Render Tree в пиксели — заполняет цвета, текстуры, тени, границы. Этот этап может происходить на GPU (в отдельных слоях) для ускорения. -
Composite
Если элементы находятся на разных слоях (например, из-заtransformилиwill-change), браузер собирает их в единое изображение — как художник накладывает прозрачные плёнки друг на друга.
JavaScript может влиять на любой из этих этапов:
- Изменение
element.style.width→ reflow; - Изменение
element.style.backgroundColor→ repaint (но не 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();
Последовательность:
main()→ фреймmainв стеке;- внутри
mainвызываетсяgreet("Тимур")→ фреймgreetповерхmain; greetвозвращает строку → фреймgreetудаляется;console.logвыполняется → новый фрейм (но быстро завершается);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. Декомпозиция
Большая задача — источник ошибок и прокрастинации. Её нужно разбить на независимые, тестируемые шаги. Возьмём пример: «реализовать форму обратной связи с валидацией, отправкой и отображением статуса».
Подзадачи:
- Отобразить форму (HTML + CSS);
- Подключить обработчик отправки (
submit); - Валидация полей (имя — не пусто, email — корректный формат);
- Блокировка кнопки на время отправки;
- Отправка данных через
fetchна/api/contact; - Обработка ответа:
- 2xx → показать «Спасибо!»;
- 4xx/5xx → показать ошибку;
- Очистка полей после успешной отправки.
Каждая подзадача — кандидат на отдельную функцию. Это обеспечивает модульность: можно тестировать валидацию независимо от отправки.
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;
}
А для динамических элементов — делегирование, как описано ранее.