Современный JavaScript: ES6+, модули, асинхронность

Настало время развернуть камеру на клиента и собрать серию про современный фронтенд без фреймворков — потому что под каждым React- или Vue-приложением лежат ровно те механизмы языка и платформы, которые здесь разберём. Это первая статья: ядро ES6+, система модулей и модель асинхронности. На ней держится всё остальное, и почти любой «магический» приём из библиотек оказывается честным стандартным кодом.

Дано по состоянию на октябрь 2026. Все конструкции, разобранные ниже, поддерживаются актуальными LTS Node.js и браузерами (Chromium/Firefox/Safari); там, где это важно, указан год выпуска фичи по редакции стандарта, чтобы вы понимали, можно ли её применять без полифилов и трансбиляции.


Часть I. ES6+: что реально используется каждый день1. let, const и временная мёртвая зона

Переход с var на let/const — это не «модный стиль», а устранение класса багов. var живёт в функции и всплывает (hoisting), let/const живут в блоке { } и до строки объявления недоступны — это и есть temporal dead zone (TDZ).

// var: «всплывает», но не инициализируется
console.log(a);      // undefined  (не ReferenceError!)
var a = 5;

// let: до объявления обратиться нельзя
console.log(b);      // ReferenceError: Cannot access 'b' before initialization
let b = 5;
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log('var:', i));   // var: 3 3 3 (один i на всех)
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log('let:', j));   // let: 0 1 2 (свой j на итерацию)
}

Практическое правило: const по умолчанию, let — если значение переприсваивается, var — никогда. const не делает объект неизменяемым, он запрещает переприсваивать привязку (имя), а поля менять можно — для реальной константности есть Object.freeze (поверхностно).

2. Стрелочные функции и «лексический this»

Стрелка => короче, но её главная роль — она не создаёт свой this, а берёт его из окружающего контекста. Отсюда два строгих следствия:

  • у стрелки нет своего this/arguments/super, и нельзя делать её конструктором (new → TypeError);
  • стрелка удобна как колбэк внутри метода, а метод прототипа объекта или сам объект-владелец — обычная function.
class Counter {
  count = 0;
  incLater() {
    setTimeout(() => { this.count++; }, 0);   // this → экземпляр (лексически) ✓
    // setTimeout(function(){ this.count++ }, 0); // this → window/undefined ✗
  }
}
const nums = [1, 2, 3, 4];
// Для map/filter/reduce стрелка идеальна:
const squares = nums.map(n => n * n);
// А вот объектные методы стрелкой писать нельзя — нужен this-объекта:
const user = {
  name: 'Оля',
  greet() { return `Привет, ${this.name}`; },   // function-метод: this = user
  bad: () => `Привет, ${this.name}`,            // this тут НЕ про user
};

3. Деструктуризация, rest/spread и значения по умолчанию

Работа с данными как со структурой — то, из чего выросла вся декларативная логика.

// Объекты
const { host, port = 8080, ...rest } = { host: 'api.example.test', region: 'eu' };
// host: 'api.example.test', port: 8080, rest: { region: 'eu' }

// Массивы (позиционно), пропуск элементов
const [first, , third] = ['a', 'b', 'c'];
const swap = () => { let x = 1, y = 2; [x, y] = [y, x]; return [x, y]; }; // [2, 1]

// Вложенность + переименование
const { server: { tls: isTls = false } } = { server: {} };   // isTls: false

// Spread: копирование, слияние, разворачивание в аргументы и в новый массив
const merged = { ...defaults, ...userOptions };
const copy = [...items];               // неглубокая копия массива

Ключевая ловушка: spread и деструктуризация неглубокие. { ...a } поделится ссылкой на вложенный объект. Глубокая копия без рекурсии — structuredClone (см. Часть II).

4. Опциональная цепочка ?. и ?? (ES2020)

Это самая частая пара, которую используют неправильно.

const city = user.address.city;                  // TypeError, если user/address нет
const city2 = user?.address?.city;               // undefined вместо падения
const fnResult = obj.method?.();                 // вызвать, только если метод есть
const item0 = arr?.[0];                          // индекс через ?. тоже можно

// Разница ?? vs || :
const qty = 0;
const a = qty || 10;   // 10  — || сработал на «ложном» 0 (баг!)
const b = qty ?? 10;   // 0   — ?? срабатывает только на null/undefined

?. возвращает undefined, если цепочка оборвалась, и не бросает ошибку — но если дальше вы напишете user?.admin.name, оборвётся только проверка user; .admin.name уже упадёт. Цепочку надо защищать на каждом сомнительном звене.

5. Шаблоны, логические присваивания и мелкие усилители языка

// Шаблонные литералы + тег (возвращаемый value — массив строк, через него строится безопасный HTML-escape)
const q = `SELECT * FROM users WHERE id = ${id}`;

// ES2021: логическое присваивание
cfg.url ??= 'https://api.example.test';   // если null/undefined — присвоить
cfg.retries ||= 1;                          // если falsy — присвоить (осторожно с 0!)
cfg.dirty ||= true;                          // «пометить грязным», если ещё не truthy
changed &&= computeFlag();                   // присвоить, только если слева уже truthy

// ES2022: удобно для «последний элемент» и «своё поле»
const last = list.at(-1);
const own = Object.hasOwn(obj, 'key');      // чище, чем hasOwnProperty.call

Часть II. Структуры данных и новые возможности платформы

6. Map / Set вместо «объекта как словаря»

У объекта ключи — только строки/symbol, и он несёт прототипные «призраки» (toString, __proto__). Для коллекции пар ключ→значение нужен Map:

const cache = new Map();
cache.set(userObject, { ttl: 300 });   // ключ — объект, это нормально для Map
cache.get(userObject);                  // ✓
cache.size;                             // размер без Object.keys().length
for (const [key, value] of cache) { /* порядок вставки гарантирован */ }

const uniq = new Set([1, 2, 2, 3]);     // {1, 2, 3}
uniq.has(2);                            // true — быстрее, чем includes у массива
// WeakMap/WeakSet: ключи — только объекты, записи не мешают сборке мусора.
// Идеальны для приватного кэша/метаданных по чужим объектам без утечек памяти.
const meta = new WeakMap();
meta.set(domNode, { seen: Date.now() });

WeakMap нельзя обойти (нет size, нельзя перечислить ключи) — и это цена за отсутствие утечек: когда объект-ключ умер, запись исчезла вместе с ним.

7. structuredClone — настоящая глубокая копия

До ES-эры глубокая копия была либо рекурсией, либо хаком JSON.parse(JSON.stringify()), который теряет Date, Map, Set, функции и циклические ссылки.

const src = { when: new Date(), tags: new Set(['a', 'b']), nested: { n: 1 } };
src.self = src;                                     // цикл
const copy = structuredClone(src);                  // Date, Set, Map и цикл — целы
console.log(copy.nested !== src.nested);            // true — реально независимая копия
// Функции и DOM-узлы не копируются (бросается DataCloneError) — это ограничение платформы.

8. Symbol, Proxy, Reflect — коротко и по делу

  • Symbol — уникальный ключ; применяется, чтобы не зарезервировать имя поля в чужом объекте (например Symbol.iterator для протокола итерации).
  • Proxy — перехват операций над объектом (get, set, deleteProperty …). Именно на нём построена «реактивность» Vue: вы присваиваете state.x = 1, а в ловушку set прилетает хук, который перерисовывает UI. В чистом JS это ваш инструмент валидации, ленивой загрузки и Observables.
const store = new Proxy({ count: 0 }, {
  set(target, prop, value) {
    if (typeof value !== 'number') throw new TypeError('только числа');
    console.log(`изменение ${String(prop)}: ${target[prop]} → ${value}`);
    return (target[prop] = value);   // true = операция применена
  },
});
store.count = 5;                      // лог + запись прошли
// store.count = 'x';                 // TypeError

Часть III. Классы: честный синтаксис поверх прототипов

Ключевое слово class (ES6) — это «обёртка» над прототипным наследованием, и понимать это полезно, когда отлаживаете this.

class Base {
  constructor(name) { this.name = name; }
  describe() { return `${this.name}`; }
}

class Server extends Base {
  #port;                    // приватное поле (ES2022) — снаружи не достать
  static DEFAULT_PORT = 443; // статическое поле
  constructor(name, port = Server.DEFAULT_PORT) {
    super(name);             // обязательный вызов в дочернем конструкторе
    this.#port = port;
  }
  get endpoint() {           // геттер выглядит как свойство
    return `https://${this.name}:${this.#port}`;
  }
  isSecure() { return this.#port === 443; }
}

Что стоит помнить:

  • Приватные поля # — не «соглашение про _», а настоящая инкапсуляция уровня движка; s.#port вне класса — SyntaxError.
  • Методы живут в прототипе, поэтому s.describe.bind и передача метода колбэком onClick: () => s.describe() меняют семантику this (см. Часть I про стрелки).
  • Композиция часто лучше глубоких иерархий: в JS нет private у методов (только у полей #), и «класс-на-всё» — антипаттерн.

Часть IV. Модули (ESM): граф зависимостей, а не «подключение скриптов»

9. Почему модули — это смена парадигмы

До ESM фронтенд склеивал <script src> в правильном порядке, всё жило в глобальной области и сталкивалось именами. У модулей:

  • своя область видимости — переменные не протекают в window;
  • строгий режим по умолчанию;
  • явные входы и выходы (import/export) — сборщик и человек видят реальный граф;
  • отложенное исполнение: модуль ведёт себя как defer: загрузка отложена, DOMContentLoaded его дождётся, порядок исполнения по графу зависимостей сохраняется;
  • загрузка с учётом CORS (в браузере модули подтягиваются как crossorigin).
// utils/math.js — именованные экспорты
export const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
export function sum(...ns) { return ns.reduce((a, b) => a + b, 0); }

// main.js
import { sum, clamp as bound } from './utils/math.js';   // расширение указывать нужно
import config from './config.js';                        // default-экспорт
import * as math from './utils/math.js';                 // пространство имён

export default class App {}                              // свой default у этого файла

Правило одной истины: import берёт не копию, а живую связь с тем же значением. Если модуль изменил export let-счётчик, читающий модуль увидит новое значение. Переприсваивать импортированную привязку извне — нельзя (Cannot assign to read only property).

10. Динамический import() и import.meta

Статический import обязан быть на верхнем уровне и с литералом-путём. Когда нужен отложенный/условный импорт — вызовите import() как функцию: он вернёт Promise, а это главный способ делить код на чанки и не грузить лишнее.

button.addEventListener('click', async () => {
  const { heavyReport } = await import('./reports.js');   // загрузится только по клику
  heavyReport();
});

// Условная загрузка модуля форматтера по локали:
const fmt = await import(`./locale/${lang}.js`).then(m => m.default);

// import.meta — метаданные модуля
const url = import.meta.url;               // свой URL (в Node — путь file://)
const here = new URL('./data/', import.meta.url);   // резолв рядом с файлом

Top-level await (ES2022, только в модулях) позволяет не прятать await в async-обёртку:

// config.js — модуль «дозревает», пока читается конфигурация
export const settings = await fetch('https://api.example.test/config').then(r => r.json());

Осторожно: такой модуль задержит загрузку всех, кто его импортирует, и превращает «просто импорт» в сетевое ожидание.

11. Import maps, CommonJS и реальность Node

Браузер понимает только относительные/абсолютные URL в import. Чтобы писать import lodash from 'lodash' без сборщика, в HTML вставляют import map (поддерживается всеми современными браузерами) — браузерную таблицу соответствий имени и URL:

<script type="importmap">
{ "imports": { "lodash": "https://cdn.example.test/lodash@4/module.js" } }
</script>
<script type="module">
  import { debounce } from 'lodash';   // имя разрешится через карту выше
</script>

В Node исторически два мира: CommonJS (require/module.exports) и ESM (import/export). Короткая карта совместимости:

ПризнакCommonJSESM
Синтаксисrequire(), module.exportsimport, export
Загрузкасинхроннаяасинхронная (граф)
this на верхнем уровнеmodule.exportsundefined
Ключевое для определения.cjs, type:"commonjs".mjs или "type":"module"
Interopimport CJS-модуля = default-экспортrequire(esm) — с оговорками

Практика 2026: новый код пишем на ESM ("type": "module" в package.json); CJS-пакеты Node «доезжают» через default-interop. На фронтенде ESM нативен, но для единого бандла и tree-shaking всё равно обычно стоит сборщик (Vite/esbuild) — он не отменяет знание стандартной семантики, о которой эта статья.


Часть V. Асинхронность: event loop, Promise и async/await

12. Модель в одну голову: один поток + очередь задач

JavaScript в рантайме исполняет одну задачу за раз. Пока выполняется ваш синхронный код, ни один таймер, сетевой колбэк или Promise не начнётся. Когда текущий кусок освобождает поток, рантайм берёт следующую задачу из очереди, и вот здесь два уровня приоритета:

  • микрозадачи (Promise.then/catch/finally, queueMicrotask, MutationObserver) — исполняются до возврата в цикл и друг за другом, пока очередь микрозадач не пуста;
  • макрозадачи (setTimeout, setInterval, сетевые события, клики) — по одной за итерацию цикла.

Отсюда классический порядок вывода:

console.log('1');
setTimeout(() => console.log('timeout'), 0);       // макро
Promise.resolve().then(() => console.log('promise')); // микро
queueMicrotask(() => console.log('microtask'));      // микро
console.log('2');
// Порядок: 1, 2, promise, microtask, timeout

Знать это нужно, чтобы не «чинить» странные задержки наивными вложенными setTimeout: если логика живёт в промисах, а вы ждёте setTimeout, вы всегда будете на такт позже.

13. Promise: состояние, цепочки и комбинаторы

Промис — это «значение, которое будет позже» с тремя состояниями: pending → fulfilled | rejected (последние два неизменяемы). Цепочка .then возвращает новый промис, поэтому ошибки «проваливаются» к первому .catch.

fetch('https://api.example.test/items')
  .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
  .then(data => render(data))
  .catch(err => showError(err))          // ловит и сеть, и !ok выше, и render()
  .finally(() => hideSpinner());         // сработает при любом исходе

Правило: всегда проверяйте response.ok — fetch отклоняет промис только на сетевой ошибке, а 404/500 приходят как успешный ответ.

Комбинаторы — то, чем заменяют ручные счётчики:

// all — все обязательны; первая же ошибка отклоняет результат
const [u, o] = await Promise.all([getUser(id), getOrders(id)]);

// allSettled — дождаться всех, проанализировать, у кого rejected
const results = await Promise.allSettled(tasks);
const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value);

// race — победитель один (кеши vs сеть, «первый отклик»)
const fastest = await Promise.race([fromCache(), fromNetwork()]);

// any (ES2021) — первый успешный, отклоняется AggregateError, если провалились все
const good = await Promise.any([replica1(), replica2(), replica3()]);

14. async/await — синтаксис над промисами, не новая модель

async-функция всегда возвращает промис; await приостанавливает только её, а не поток — остальной код рантайма в это время исполняется. await работает с любым промисом/значением, а в try/catch перехватывается reject естественным синхронным образом:

async function loadDashboard(id) {
  try {
    const [user, stats] = await Promise.all([        // параллельно
      fetchUser(id),
      fetchStats(id),
    ]);
    return { user, stats };
  } catch (err) {
    reportError(err);
    throw err;                       // не глотайте ошибку молча
  } finally {
    closeLoader();
  }
}

Главная грабель «последовательных await»:

// ✗ медленно: N запросов строго друг за другом
for (const id of ids) {
  const item = await fetch(`/api/items/${id}`);   // ждём каждого
}

// ✓ быстро: все запросы стартуют сразу, дождёмся всех
const items = await Promise.all(ids.map(id => fetch(`/api/items/${id}`).then(r => r.json())));

// но: Promise.all + map без лимита = шквал соединений. Для больших N нужен
// ограничитель параллелизма (например батчами по 5–10).

15. Отмена, таймауты и необработанные отклонения

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

const controller = new AbortController();
signalBtn.disabled = false;
signalBtn.onclick = () => controller.abort('пользователь отменил');

async function loadWithTimeout(url, ms = 8000) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort('timeout'), ms);
  try {
    const res = await fetch(url, { signal: ctrl.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

// Перехват необработанных reject (иначе «тихие» падения):
window.addEventListener('unhandledrejection', e => {
  console.error('не поймано:', e.reason);
  e.preventDefault();
});

Асинхронные итерируемые источники (стримы fetch, чтение тела по частям) читают через for await...of:

const res = await fetch('https://api.example.test/stream');
for await (const chunk of res.body) {         // chunk — Uint8Array
  appendToLog(new TextDecoder().decode(chunk));   // декодируем кусками
}

Часть VI. Практика без фреймворка: из чего состоит «компонент»

Вся философия серии: фреймворк не обязателен, потому что его базовые механизмы — это стандартный JS. Соберём минимальный переиспользуемый компонент на модуле + классе, с чистой обработкой состояния, без innerHTML с данными.

// search-box.js — модуль-компонент с приватным состоянием
export class SearchBox {
  #input; #output; #timer = 0; #ac = null;
  constructor(root, { delay = 300, onSearch } = {}) {
    this.#input = root.querySelector('input');
    this.#output = root.querySelector('output');
    this.#input.addEventListener('input', () => this.#debounce(delay, onSearch));
  }

  #debounce(ms, onSearch) {                 // дебаунс через setTimeout (макрозадача)
    clearTimeout(this.#timer);
    this.#timer = setTimeout(async () => {
      this.#ac?.abort();                     // отменить предыдущий ползающий запрос
      this.#ac = new AbortController();
      try {
        const term = this.#input.value.trim();
        if (!term) { this.#output.textContent = ''; return; }
        const r = await fetch(
          `https://api.example.test/search?q=${encodeURIComponent(term)}`,
          { signal: this.#ac.signal });
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        const { results } = await r.json();
        this.#renderResults(results);        // без innerHTML
      } catch (e) {
        if (e.name !== 'AbortError') this.#output.textContent = 'ошибка поиска';
      }
    }, ms);
  }

  #renderResults(list) {
    this.#output.replaceChildren();          // очистить, не трогая innerHTML строкой
    const frag = new DocumentFragment();
    for (const item of list) {
      const li = document.createElement('li');
      li.textContent = item.title;           // textContent = экранирование автоматически
      frag.append(li);
    }
    this.#output.append(frag);
  }
}

Что здесь чисто «стандартного» и почему так безопасно:

  • textContent/createElement вместо innerHTML — подстановка пользовательских данных в innerHTML открывает XSS. Если нужен разметочный HTML из доверенного источника, очищайте его (DOMPurify), но по умолчанию держитесь свойств textContent и createElement — это ровно та гигиена, к которой мы привыкли на сервере, только здесь цена ошибки видна каждому посетителю.
  • AbortController снимает гонку «ответ на старый запрос перетёр новый».
  • #private + модуль = компонент с инкапсулированным состоянием, без глобальных имён.
  • дебаунс на setTimeout — та самая очередь задач из Части V в реальной UX-функции.

Инструмент под задачу

Покажите кусок асинхронного кода с «плавающими» await/прометисами — отладчик разберёт, где теряется ошибка и где гонка, на ваших строках, а не на примере из статьи. Попробуйте инструмент прямо здесь, на своих данных:

AI Code Debugger
Гость
Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь, для пользователей лимит — 200 000 токенов.
0 / 15,000 символов

Результат проверки
Вставьте код слева, выберите язык и нажмите «Проверить код».
ИИ найдёт ошибки, уязвимости и предложит исправления.

Чек-лист: готовность и грабли

  • Можно ли без трансбиляции? Всё из Частей I–V (?., ??, ??=, Promise.any, класс-поля, top-level await, structuredClone) — в актуальных браузерах и Node LTS. Для старой поддержки (iOS/Android «на год-два назад») сверяйте конкретную фичу с caniuse и включайте трансбиляцию сборщика.
  • || там, где нужен ?? — потеряете 0/''/false.
  • ?. не ставится «через одно» звено — защищайте каждый потенциально отсутствующий уровень.
  • await в for — скрытая последовательность сетевых запросов; параллельте через Promise.all(map), но с лимитом.
  • fetch без проверки res.ok — 404/500 придут как «успех».
  • Забытый .catch — необработанное отклонение; глобально подстрахуйтесь unhandledrejection.
  • innerHTML пользовательскими строками — XSS. По умолчанию textContent.
  • const ≠ неизменяемый объект — для константной структуры нужен Object.freeze/structuredClone-аккуратно.
  • var в циклах с колбэками — TDZ-ловушки и общий this; используйте let/const.

Материал носит образовательно-практический характер. Синтаксис и семантика соответствуют редакции ECMAScript 2022–2024 и текущему поведению Chromium/Firefox/Safari и Node LTS на октябрь 2026; при сомнении по конкретной фиче проверяйте caniuse и MDN на дату внедрения в вашу аудиторию браузеров.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.