MakeFirstStep· Frontend + ИИ

Урок 17 · Инструменты разработчика

Модули, npm и сборка Vite

Пока ваше приложение живёт в одном файле на 300 строк, всё хорошо. Но настоящий сайт — это десятки файлов, чужие библиотеки и сборка перед публикацией. Сегодня вы научитесь разбивать код на модули, подключать пакеты через npm, запускать проект на Vite — и перенесёте на него приложение из урока 16.

  • Писать export и import: именованные, по умолчанию, с переименованием, и «витринный» файл index.js
  • Правильно указывать пути к модулям и раскладывать проект по папкам
  • Читать и править package.json: поля, dependencies и devDependencies, версии ^ и ~
  • Запускать команды через npm-скрипты: npm run dev, npm run build
  • Создать проект командой npm create vite@latest и объяснить разницу между dev-сервером и сборкой
  • Перенести приложение в Vite, разбить его на модули и залить в репозиторий
Как работать с этой страницей

Это урок про инструменты, поэтому часть примеров — терминал и файлы проекта: они на тёмном фоне, но без кнопки «Запустить». Команды вы повторяете у себя на компьютере (заголовок блока подскажет: «Терминал» или имя файла). Всё, что можно честно проверить в браузере, — живое: модули, импорты и разбор package.json запускаются кнопкой ▶ Запустить. В браузере нет файлов, поэтому «файл» модуля мы храним в строке и загружаем настоящим import() — это тот же механизм, который браузер использует для файлов. Строка setTimeout(() => {}, 300) в этих примерах — служебная: она просит песочницу немного подождать, пока модуль загрузится. В настоящем проекте она не нужна.

01 Зачем нужны модули. export и import

Откройте любой серьёзный проект — вы не найдёте там одного огромного script.js. Код разложен по файлам: один отвечает за запросы к API, другой — за отрисовку, третий — за вспомогательные функции. Такой файл называется модулем.

Почему один файл — плохо? Во-первых, в нём трудно ориентироваться. Во-вторых, если подключать несколько обычных скриптов подряд, все их переменные попадают в одно общее пространство: две функции с именем format затрут друг друга. Модуль решает обе проблемы: у него своя область видимости, и наружу попадает только то, что вы явно разрешили.

Аналогия из жизни

Кухня большой чойхоны. Один повар делает плов, другой — шашлык, третий — лепёшки. У каждого свой стол и свои продукты — это модуль со своей областью видимости. Но готовое блюдо повар отдаёт через окно выдачи — это export. Официант, который берёт блюдо и несёт его в зал, — это import. Всё, что осталось на столе повара, — его «личное дело», в зал оно не попадёт.

Именованный экспорт

Чтобы открыть что-то наружу, перед объявлением пишут слово export. Чтобы взять это в другом файле — import с именами в фигурных скобках:

src/currency.js
const RATE = 11; // без export — виден только внутри этого файла

export function toSomoni(usd) {
  return usd * RATE;
}

export function toUsd(somoni) {
  return somoni / RATE;
}
src/main.js
import { toSomoni, toUsd } from "./currency.js";

console.log(toSomoni(100)); // → 1100
console.log(toUsd(550));    // → 50

Имена в фигурных скобках должны точно совпадать с экспортированными, включая регистр. Порядок не важен. Если вы импортируете то, чего нет, — ошибка появится ещё до запуска кода.

Запускаем настоящий import прямо здесь

На этой странице нет файлов на диске, но есть функция import(), которая умеет загружать модуль по адресу. Мы упаковываем «файл» в строку, превращаем её в адрес (Blob и URL.createObjectURL) и загружаем. Для вас это просто строка в обратных кавычках — представляйте, что это содержимое файла.

Модуль в строке: настоящий import() · Ctrl + Enter
// «файл» → адрес → модуль
const load = (code) => {
  setTimeout(() => {}, 300); // нужен только этой странице: песочница ждёт, пока модуль загрузится
  return import(URL.createObjectURL(new Blob([code], { type: "text/javascript" })));
};

const currency = await load(`
  const RATE = 11;
  export function toSomoni(usd) { return usd * RATE; }
  export function toUsd(somoni) { return somoni / RATE; }
`);

console.log(currency.toSomoni(100)); // 1100
console.log(currency.toUsd(550));    // 50
console.log(currency.RATE);          // undefined — без export снаружи не видно

Результат загрузки — объект со всем, что модуль экспортировал. Константа RATE не экспортирована — снаружи её нет. Это и есть «приватность» модуля.

Модуль, который импортирует другой модуль

Теперь два «файла»: второй подключает первый обычной строкой import ... from. Вместо пути "./currency.js" мы подставляем адрес первого модуля — на настоящем диске там стоял бы путь.

Два модуля: main импортирует currency · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

// файл currency.js
const currencyUrl = url(`
  const RATE = 11;
  export const toSomoni = (usd) => usd * RATE;
  export const toUsd = (somoni) => somoni / RATE;
`);

// файл main.js (на диске: import { toSomoni } from "./currency.js")
await import(url(`
  import { toSomoni, toUsd } from "${currencyUrl}";
  console.log("100 USD =", toSomoni(100), "сомони");
  console.log("550 сомони =", toUsd(550), "USD");
`));

Что ещё умеет import

Переименование: as
Если имена конфликтуют: import { format as formatDate } from "./dates.js". Теперь внутри файла функция называется formatDate.
Всё сразу: * as
import * as currency from "./currency.js" — один объект, к функциям обращаемся через точку: currency.toSomoni(5). Удобно, когда нужно много и хочется видеть, откуда что.
Экспорт списком
Не обязательно ставить export перед каждой строкой — можно собрать всё внизу файла: export { toSomoni, toUsd };
Только запуск
import "./style.css" — без имён: файл просто подключается. В Vite так подключают стили.
as и * as · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const dates = url(`export function format(d) { return d.day + "." + d.month; }`);
const money = url(`export function format(n) { return n + " сомони"; }`);

await import(url(`
  import { format as formatDate } from "${dates}";
  import * as moneyUtils from "${money}";

  console.log(formatDate({ day: 15, month: 10 }));
  console.log(moneyUtils.format(250));
`));
Частые ошибки

1) Импортировать имя, которого модуль не экспортирует (опечатка, регистр, забытый export). 2) Забыть ./ в пути: "currency.js" без точки — это не ваш файл, а пакет из npm (об этом ниже). 3) Написать import в обычном скрипте без type="module".

Этот код выдаст ошибку · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const shop = url(`
  export function totalPrice(a, b) { return a + b; }
`);

// ✖ модуль экспортирует totalPrice, а мы просим calcTotal
await import(url(`
  import { calcTotal } from "${shop}";
  console.log(calcTotal(10, 20));
`));

Читать подробнее: learn.javascript.ru — Экспорт и импорт · MDN — Модули JavaScript

Практика: закрепляем

Во всех задачах «файл» лежит в строке-шаблоне. Менять нужно только то, что подсказано в комментариях и условии.

ЗАДАЧА 1.1

Что видно снаружи

лёгкая

Предскажите вывод, не запуская: какие имена модуль отдаёт наружу (они выводятся через запятую; ключи модуля идут по алфавиту, заглавные буквы раньше строчных) и что будет с secret? Затем запустите и сверьтесь.

Можно редактировать · Ctrl + Enter
const load = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return import(URL.createObjectURL(new Blob([code], { type: "text/javascript" })));
};

const city = await load(`
  export const CITY = "Душанбе";
  export function hello() { return "Салом, " + CITY; }
  const secret = "не для чужих";
  export const year = 2026;
`);

console.log(Object.keys(city).join(", "));
console.log(typeof city.secret);
Ожидаемый результат
CITY, hello, year
undefined
Подсказка 1

Наружу попадает только то, что помечено словом export. Посчитайте такие строки.

Подсказка 2

Сравнение имён идёт по коду символа: C (заглавная) раньше h, а h раньше y.

ЗАДАЧА 1.2

Допишите export

лёгкая

Модуль currency.js написан, но в нём забыли открыть функции наружу — main.js падает с ошибкой. Добавьте export там, где нужно, и добейтесь правильного вывода.

Можно редактировать · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const currencyUrl = url(`
  const RATE = 11;
  function toSomoni(usd) { return usd * RATE; }
  function toUsd(somoni) { return somoni / RATE; }
`);

await import(url(`
  import { toSomoni, toUsd } from "${currencyUrl}";
  console.log(toSomoni(100));
  console.log(toUsd(550));
`));
Ожидаемый результат
1100
50
Подсказка 1

Прочитайте ошибку: в ней сказано, что модуль не предоставляет экспорт с таким именем. Какие два имени запрашивает main.js?

Подсказка 2

Поставьте слово export перед function toSomoni и function toUsd. Константе RATE оно не нужно.

ЗАДАЧА 1.3

Две функции format

средняя

Модули dates.js и money.js оба экспортируют функцию с именем format. Импортируйте обе в main.js так, чтобы они не конфликтовали: даты — как formatDate, деньги — как formatMoney.

Можно редактировать · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const datesUrl = url(`export function format(d) { return d.day + "." + d.month + "." + d.year; }`);
const moneyUrl = url(`export function format(n) { return n + " сомони"; }`);

await import(url(`
  // импортируйте обе функции под разными именами
  import { format } from "${datesUrl}";

  console.log(formatDate({ day: 15, month: 10, year: 2026 }));
  console.log(formatMoney(250));
`));
Ожидаемый результат
15.10.2026
250 сомони
Подсказка 1

Конструкция { имя as новоеИмя } меняет имя внутри текущего файла. Нужны две строки import — по одной на каждый модуль.

Подсказка 2

import { format as formatDate } from "${datesUrl}"; — и такая же строка для moneyUrl с именем formatMoney.

ЗАДАЧА 1.4

Модуль корзины с нуля

средняя

Напишите модуль cart.js: он экспортирует массив items (три цены: 15, 40, 5) и функцию total(list), возвращающую сумму. Затем в main.js импортируйте обе вещи и выведите итог.

Можно редактировать · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const cartUrl = url(`
  // напишите: export const items = [...]; и export function total(list) { ... }
`);

await import(url(`
  // импортируйте items и total из "${cartUrl}"

  console.log("Товаров:", items.length);
  console.log("Итого:", total(items));
`));
Ожидаемый результат
Товаров: 3
Итого: 60
Подсказка 1

В модуле: export const items = [15, 40, 5]; и export function total(list) { ... }.

Подсказка 2

Сумма: return list.reduce((sum, p) => sum + p, 0);. В main.js: import { items, total } from "${cartUrl}";

02 Экспорт по умолчанию, ре-экспорт, пути и папки

export default

У модуля может быть один «главный» экспорт — export default. При импорте он берётся без фигурных скобок и под любым именем, которое вы придумаете:

src/ui/greet.js
export default function greet(name) {
  return `Салом, ${name}!`;
}

export const LANG = "tg"; // именованный экспорт тоже можно держать рядом
src/main.js
import greet, { LANG } from "./ui/greet.js";   // default — без скобок, остальное — в скобках
import hello from "./ui/greet.js";              // то же самое, имя выбираем сами

console.log(greet("Мадина"), LANG);
Именованный экспортЭкспорт по умолчанию
Записьexport function f() {}export default function f() {}
Импортimport { f } from "./a.js"import f from "./a.js"
Сколько на модульСколько угодноНе больше одного
Имя при импортеДолжно совпасть (или as)Любое
Когда выбиратьПо умолчанию — удобнее поиск и автодополнениеМодуль «про одну вещь»: компонент, класс, конфиг
Совет

В своём коде отдавайте предпочтение именованным экспортам: имя одинаково во всех файлах, и редактор сам подскажет импорт. default вы всё равно встретите — в ответах ИИ, в документации библиотек и в React (компоненты чаще экспортируют по умолчанию).

default и named в одном модуле · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const greetUrl = url(`
  export default function greet(name) { return "Салом, " + name + "!"; }
  export const LANG = "tg";
`);

await import(url(`
  import hello, { LANG } from "${greetUrl}";   // hello — наше имя для default
  console.log(hello("Мадина"), LANG);
`));

// посмотрим, что лежит в модуле: default — это просто ключ с именем "default"
const mod = await import(greetUrl);
console.log(Object.keys(mod).join(", "));

Ре-экспорт: файл-витрина index.js

Когда в папке много модулей, неудобно писать пять строк import из пяти разных файлов. Принято класть в папку файл index.js, который собирает всё нужное и отдаёт одним окном — как витрина в магазине. Для этого есть «пересылающий» экспорт:

src/api/index.js
export { getUsers, getPosts } from "./users.js";   // отдельные имена
export * from "./products.js";                      // всё именованное сразу
export { default as http } from "./http.js";        // default → именованный
src/main.js
// снаружи не важно, в каком файле что лежит: путь ведёт к папке
import { getUsers, searchProducts, http } from "./api/index.js";
Аналогия из жизни

Рынок Корвон. Вы не ходите по каждому складу — вы идёте к нужному ряду, где продавец принимает заказ и сам достаёт товар из разных складов. index.js — этот продавец: покупатель (другой модуль) знает только ряд, а не склады.

Правила, которые нужно знать

  1. Модуль выполняется один раз. Сколько бы файлов его ни импортировало, код внутри запускается один раз, а все получают одно и то же состояние (счётчик, кэш, настройки).
  2. Своя область видимости. Переменные модуля не попадают в window. Поэтому в Vite-проекте вы не сможете случайно перезаписать чужую переменную.
  3. Строгий режим включён сам. Не нужно писать "use strict".
  4. import — только наверху файла и не внутри if или функции. Для загрузки «по требованию» есть import() — именно его мы используем на этой странице.
  5. Циклы запрещать не надо, но избегать. Если a.js импортирует b.js, а b.js — a.js, получится путаница с порядком выполнения. Решение: вынести общее в третий файл.
Модуль выполняется один раз · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const counterUrl = url(`
  console.log("counter.js загружен");   // сколько раз увидим эту строку?
  export let count = 0;
  export function inc() { count++; }
`);

await import(url(`import { inc } from "${counterUrl}"; inc(); inc();`));
await import(url(`import { count } from "${counterUrl}"; console.log("count в b:", count);`));

Пути в import

"./api.js"
Файл рядом с текущим (в той же папке).
"../api.js"
На одну папку выше. Две папки выше — "../../api.js".
"./api/index.js"
Файл в подпапке. Слэш всегда прямой /, даже на Windows.
"dayjs"
Без ./ и ../ — это пакет из npm. Vite найдёт его в папке node_modules.
Расширение .js
В браузере и в Vite пишем его всегда: "./api.js", а не "./api".
Регистр букв и Windows

На Windows и macOS файл Api.js можно по ошибке импортировать как "./api.js" — и локально всё заработает. Но сервер, на который вы зальёте сайт (GitHub Pages, Netlify), чувствителен к регистру: там будет ошибка 404. Называйте файлы одним стилем — kebab-case или camelCase с маленькой буквы — и пишите путь буква в букву.

Как подключить модуль на странице

index.html
<!-- type="module" — и main.js может использовать import -->
<script type="module" src="./src/main.js"></script>

Если просто открыть такой index.html двойным щелчком (адрес начинается с file://), браузер откажется загружать модули. Им нужен сервер, адрес вида http://localhost:5173. Для разработки сервер даёт Vite — ниже вы его запустите.

Как раскладывать проект по папкам

Единого закона нет, но для приложений из урока 15–16 хорошо работает такая схема:

my-shop/ ├─ index.html страница, подключает src/main.js ├─ package.json паспорт проекта и команды ├─ .gitignore что не отправлять в Git ├─ public/ файлы «как есть»: favicon, картинки └─ src/ ├─ main.js точка входа: только «собрать» приложение ├─ api/ │ └─ products.js запросы к API (fetch) ├─ ui/ │ └─ render.js отрисовка: список, статусы loading / error ├─ utils/ │ └─ debounce.js мелкие помощники без привязки к странице └─ styles/ └─ style.css
  • Один модуль — одна задача. Если в файле и fetch, и работа с DOM, и подсчёты — пора делить.
  • main.js — «дирижёр». Он подключает остальные и связывает: «получили данные из api → отдали в ui». Своей логики в нём почти нет.
  • Зависимости идут в одну сторону: main знает про api и ui, а они про main — нет.
  • utils не знают про приложение. debounce подойдёт любому проекту — значит, ему место в utils.

Читать подробнее: learn.javascript.ru — Модули, введение · MDN — export

Практика: закрепляем

ЗАДАЧА 2.1

Приветствие не найдено

лёгкая

Модуль экспортирует greet «по умолчанию», а main.js просит его как именованный — получается ошибка. Исправьте только импорт в main.js.

Можно редактировать · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const greetUrl = url(`
  export default function greet(name) { return "Салом, " + name + "!"; }
  export const LANG = "tg";
`);

await import(url(`
  import { greet, LANG } from "${greetUrl}";
  console.log(greet("Мадина"));
  console.log(LANG);
`));
Ожидаемый результат
Салом, Мадина!
tg
Подсказка 1

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

Подсказка 2

import greet, { LANG } from "${greetUrl}";

ЗАДАЧА 2.2

Один модуль — одно состояние

средняя

Сначала предскажите, что выведет код (сколько раз появится «counter.js загружен» и чему равен count во втором импортёре). Затем доработайте: добавьте в counter.js функцию reset(), обнуляющую счётчик, и допишите третий импортёр, который вызовет reset() и выведет count после reset: 0.

Можно редактировать · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const counterUrl = url(`
  console.log("counter.js загружен");
  export let count = 0;
  export function inc() { count++; }
  // добавьте export function reset() ...
`);

await import(url(`import { inc } from "${counterUrl}"; inc(); inc();`));
await import(url(`import { count } from "${counterUrl}"; console.log("count в b:", count);`));
// третий импортёр — здесь
Ожидаемый результат
counter.js загружен
count в b: 2
count после reset: 0
Подсказка 1

Адрес counterUrl один и тот же, поэтому модуль загружается и выполняется один раз, а состояние общее для всех импортёров.

Подсказка 2

Третий импортёр: await import(url(`import { count, reset } from "${counterUrl}"; reset(); console.log("count после reset:", count);`)); Значение count «живое»: после reset() оно уже нулевое.

ЗАДАЧА 2.3

Витрина index.js

сложная

Собран маленький проект из трёх модулей. Напишите содержимое файла index.js (indexUrl) так, чтобы он пересылал: функцию getUsers из api.js, все именованные экспорты из format.js и default из store.js под именем store. main.js менять не нужно.

Можно редактировать · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const apiUrl = url(`export function getUsers() { return ["Рустам", "Зарина"]; }`);
const formatUrl = url(`
  export const upper = (s) => s.toUpperCase();
  export const exclaim = (s) => s + "!";
`);
const storeUrl = url(`
  const items = [];
  export default { add(x) { items.push(x); }, size() { return items.length; } };
`);

const indexUrl = url(`
  // ре-экспортируйте всё нужное из apiUrl, formatUrl и storeUrl
`);

await import(url(`
  import { getUsers, upper, exclaim, store } from "${indexUrl}";
  for (const user of getUsers()) {
    console.log(exclaim(upper(user)));
    store.add(user);
  }
  console.log(store.size());
`));
Ожидаемый результат
РУСТАМ!
ЗАРИНА!
2
Подсказка 1

Три строки в стиле export ... from "...": одна с фигурными скобками, одна со звёздочкой, одна с default as. Адреса подставляются как "${apiUrl}".

Подсказка 2

export { getUsers } from "${apiUrl}";
export * from "${formatUrl}";
export { default as store } from "${storeUrl}";

ЗАДАЧА 2.4

Куда ведёт путь

средняя

Напишите функцию resolveImport(from, spec): она получает путь файла-импортёра и строку из import и возвращает путь к нужному файлу от корня проекта, без начального слэша. Пригодится встроенный URL: new URL(spec, base).pathname, где base — условный адрес сайта плюс путь файла.

Можно редактировать · Ctrl + Enter
function resolveImport(from, spec) {
  // подсказка: base = "http://app/" + from
}

console.log(resolveImport("src/ui/render.js", "./card.js"));
console.log(resolveImport("src/ui/render.js", "../api.js"));
console.log(resolveImport("src/ui/render.js", "../../package.json"));
Ожидаемый результат
src/ui/card.js
src/api.js
package.json
Подсказка 1

new URL("../api.js", "http://app/src/ui/render.js").pathname вернёт /src/api.js. Относительный путь считается от папки файла.

Подсказка 2

У результата pathname есть начальный слэш — уберите его: .slice(1).

03 Node.js, npm и package.json

Node.js — это JavaScript вне браузера: он запускает ваш код прямо на компьютере. Нам он нужен не для сайтов, а для инструментов: Vite, форматтеры и проверки кода сами написаны на JavaScript и работают на Node. Вместе с Node устанавливается npm — менеджер пакетов: программа, которая скачивает чужой код (пакеты) из огромного каталога npmjs.com и подключает его к вашему проекту.

Аналогия из жизни

Строительный рынок. Вы строите дом и не льёте кирпичи сами — берёте готовые. npm — это рынок и одновременно доставка: говорите «нужен такой-то пакет», и вам привозят его в папку node_modules. А package.json — список закупок проекта: что именно и какой версии вам нужно. Дайте этот список другому разработчику — и он одной командой соберёт у себя точно такие же материалы.

Установка и проверка (Windows и macOS)

  1. Скачайте Node.js LTS с nodejs.org (кнопка «LTS» — версия с долгой поддержкой). Windows — установщик .msi, macOS — установщик .pkg. Везде соглашайтесь с настройками по умолчанию.
  2. Закройте и снова откройте терминал. Это важно: старые окна не видят новые программы. Проще всего — терминал прямо в VS Code: меню Terminal → New Terminal (на клавиатуре Ctrl+`).
  3. Проверьте версии. Для современного Vite нужна Node.js 20.19+ или 22.12+.
Терминал
node -v
# v22.14.0   ← у вас может быть другая, главное не ниже 20.19

npm -v
# 10.9.2
Windows: «выполнение скриптов отключено»

Если в PowerShell команда npm отвечает ошибкой про npm.ps1 и «execution policy» — разрешите запуск скриптов для своего пользователя одной командой: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned (и подтвердите Y). Другой вариант — открыть обычную командную строку cmd или Git Bash. На macOS такой проблемы нет.

Первый package.json

В пустой папке проекта выполните npm init -y — создастся package.json. Потом установите пакет:

Терминал
mkdir demo-npm
cd demo-npm
npm init -y            # создать package.json с ответами по умолчанию
npm install dayjs      # библиотека для дат → попадёт в "dependencies"
npm install -D vite    # инструмент разработки → попадёт в "devDependencies"

Что появилось в папке:

demo-npm/ ├─ package.json список закупок: коммитим в Git ├─ package-lock.json точные версии всего, что установлено: тоже коммитим └─ node_modules/ скачанный код: тысячи файлов, в Git НЕ кладём

Из чего состоит package.json

package.json
{
  "name": "mfs-shop",
  "version": "1.2.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "dayjs": "^1.11.13"
  },
  "devDependencies": {
    "vite": "^8.3.0"
  }
}
name, version
Имя и версия проекта. Имя — маленькими латинскими буквами, без пробелов.
private: true
«Это приложение, а не библиотека для публикации». Защищает от случайной отправки в npm.
type: "module"
Файлы .js проекта считаются ES-модулями — то есть import/export работают и в инструментах на Node.
scripts
Свои команды: имя → что выполнить. Подробнее чуть ниже.
dependencies
Пакеты, которые использует код вашего приложения: например, dayjs, который вы импортируете в main.js.
devDependencies
Пакеты для разработчика: сборщик, проверка кода, форматтер. В коде приложения их не импортируют.
Совет

В проекте на Vite всё, что вы импортируете, при сборке упаковывается в итоговые файлы, так что на сайте граница между двумя списками не видна. Но привычка нужна: когда вы откроете чужой проект, по этим спискам сразу поймёте, «что нужно приложению» и «чем его делают». Правило простое: нужно в import внутри src/ — это dependencies (npm install имя), нужно только для работы над проектом — devDependencies (npm install -D имя).

Версии: что значит ^ и ~

Версия пакета состоит из трёх чисел: MAJOR.MINOR.PATCH, например 1.11.13. Это соглашение называется semver:

  • PATCH (1.11.13) — исправили ошибку, ничего не сломалось;
  • MINOR (1.11.13) — добавили возможности, старый код работает;
  • MAJOR (1.11.13) — изменения, которые могут сломать ваш код.
ЗаписьЧто разрешеноПример для 1.4.2
"1.4.2"Только эта версиятолько 1.4.2
"~1.4.2"Обновления PATCHот 1.4.2 до 1.4.x (1.5.0 уже нельзя)
"^1.4.2"Обновления MINOR и PATCH (npm install ставит так по умолчанию)от 1.4.2 до 1.x.x (2.0.0 уже нельзя)

Важно: числа сравниваются как числа, а не как текст. Версия 1.10.0 новее, чем 1.9.0 — хотя как строка "1.10.0" меньше "1.9.0". Проверим:

Версии — это числа, а не строки · Ctrl + Enter
console.log("1.10.0" > "1.9.0");   // false — сравнение как текста ошибочно!

const parse = (v) => v.split(".").map(Number);
console.log(parse("1.10.0"));      // [1, 10, 0]
console.log(parse("1.10.0")[1] > parse("1.9.0")[1]); // true — 10 больше 9

А package-lock.json фиксирует, какие версии реально установились. Поэтому у вас и у коллеги после npm install окажется один и тот же код, даже если в каталоге уже вышла новая версия. Править этот файл руками не нужно.

Основные команды npm

КомандаЧто делает
npm install (или npm i)Установить всё, что записано в package.json. Выполняем после git clone.
npm install имяДобавить пакет в dependencies.
npm install -D имяДобавить пакет в devDependencies.
npm uninstall имяУдалить пакет.
npm run имяЗапустить скрипт из scripts.
npm outdatedПоказать пакеты, для которых вышли новые версии.

npm-скрипты: свои команды проекта

В блоке scripts вы даёте короткие имена длинным командам. Вместо того чтобы помнить, как запускается сборщик, все пишут одно и то же: npm run dev. Кто бы ни открыл проект — через год или другой разработчик — он видит в package.json, какие команды доступны.

package.json (фрагмент)
"scripts": {
  "dev": "vite",                // npm run dev      → запустить сервер разработки
  "build": "vite build",        // npm run build    → собрать сайт в папку dist
  "preview": "vite preview"     // npm run preview  → посмотреть собранный сайт
}

Комментарии // в настоящем JSON писать нельзя — здесь они только для объяснения.

  • Для слов start и test слово run можно не писать: npm start, npm test. Для остальных (dev, build) — обязательно: npm run dev.
  • Команды можно объединять через &&: "check": "npm run build && npm run preview" — вторая запустится, только если первая успешна.
  • Скрипты работают на всех системах, пока вы используете кроссплатформенные программы. Команды вроде rm -rf dist не работают в обычном Windows — для таких случаев берут пакеты (например, rimraf).
  • npx имя — разово скачать и запустить программу, не устанавливая её в проект. Именно так работает npm create vite@latest (по сути, это npx create-vite@latest).
Ставим пакеты осторожно

Установка пакета — это запуск чужого кода на вашем компьютере. Прежде чем выполнять npm install по совету из интернета или ИИ, проверьте на npmjs.com: правильно ли написано имя, сколько у пакета загрузок за неделю, кто автор, когда было последнее обновление. ИИ иногда выдумывает пакеты, которых нет, а мошенники регистрируют пакеты с похожими именами (reacct вместо react). Если имя вам незнакомо — спросите у преподавателя.

Читать подробнее: npm Docs — package.json · npm Docs — Semantic versioning

Практика: закрепляем

Здесь мы работаем с package.json как с обычными данными: это JSON-объект, и вы уже умеете их разбирать.

ЗАДАЧА 3.1

Сломанный package.json

лёгкая

Текст package.json содержит ошибки формата JSON, и JSON.parse падает. Исправьте текст (не меняя код ниже него): в JSON только двойные кавычки, а после последнего элемента запятой быть не должно.

Можно редактировать · Ctrl + Enter
const text = `{
  "name": 'mfs-shop',
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
  }
}`;

const pkg = JSON.parse(text);
console.log(pkg.name, pkg.version);
console.log(Object.keys(pkg.scripts).join(", "));
Ожидаемый результат
mfs-shop 1.0.0
dev, build
Подсказка 1

Ошибок две. Первая — одинарные кавычки вокруг mfs-shop: в JSON разрешены только двойные.

Подсказка 2

Вторая — лишняя запятая после "build": "vite build". Удалите её.

ЗАДАЧА 3.2

Паспорт проекта

лёгкая

Дан объект pkg — разобранный package.json. Выведите три строки по образцу из ожидаемого результата, используя данные из объекта.

Можно редактировать · Ctrl + Enter
const pkg = {
  name: "mfs-shop",
  version: "1.2.0",
  scripts: { dev: "vite", build: "vite build", preview: "vite preview" },
  dependencies: { dayjs: "^1.11.13", nanoid: "^5.0.7" },
  devDependencies: { vite: "^8.3.0", eslint: "^9.0.0", prettier: "^3.3.0" },
};

// 1) Проект: имя@версия
// 2) Скриптов: сколько
// 3) Библиотек в приложении: имена через запятую
// 4) Инструментов разработки: сколько
Ожидаемый результат
Проект: mfs-shop@1.2.0
Скриптов: 3
Библиотек в приложении: dayjs, nanoid
Инструментов разработки: 3
Подсказка 1

Object.keys(obj) возвращает массив имён свойств. У него есть length и метод join(", ").

Подсказка 2

Первая строка: console.log(`Проект: ${pkg.name}@${pkg.version}`);. Библиотеки приложения — это ключи pkg.dependencies, инструменты — pkg.devDependencies.

ЗАДАЧА 3.3

Какая версия новее

средняя

Напишите функцию compareVersions(a, b): возвращает -1, если версия a старше b, 1, если новее, и 0, если они равны. Версии — строки вида "1.10.0"; сравнивать нужно числа, а не строки.

Можно редактировать · Ctrl + Enter
function compareVersions(a, b) {
  // разберите обе версии на числа и сравните по очереди: major, minor, patch
}

console.log(compareVersions("1.4.2", "1.4.10"));
console.log(compareVersions("2.0.0", "1.99.99"));
console.log(compareVersions("1.2.3", "1.2.3"));
console.log(compareVersions("1.10.0", "1.9.0"));
Ожидаемый результат
-1
1
0
1
Подсказка 1

a.split(".").map(Number) превращает "1.10.0" в массив [1, 10, 0]. Затем пройдитесь циклом по трём позициям.

Подсказка 2

В цикле: если pa[i] < pb[i] — сразу return -1; если больше — return 1; если равны — идём к следующему числу. После цикла — return 0.

ЗАДАЧА 3.4

Подходит ли версия

сложная

Напишите satisfies(version, range). Диапазон может быть: точная версия "1.2.3"; "^1.2.3" — та же major-версия и не ниже указанной; "~1.2.3" — те же major и minor и не ниже указанной. Функция compareVersions из прошлой задачи уже готова. Считаем, что major не меньше 1.

Можно редактировать · Ctrl + Enter
function compareVersions(a, b) {
  const pa = a.split(".").map(Number);
  const pb = b.split(".").map(Number);
  for (let i = 0; i < 3; i++) {
    if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
  }
  return 0;
}

function satisfies(version, range) {
  // 1) определите тип диапазона по первому символу
  // 2) отрежьте символ ^ или ~ и сравните
}

const checks = [
  ["1.5.0", "^1.2.3"],
  ["2.0.0", "^1.2.3"],
  ["1.2.2", "^1.2.3"],
  ["1.2.9", "~1.2.3"],
  ["1.3.0", "~1.2.3"],
  ["1.2.3", "1.2.3"],
  ["1.2.4", "1.2.3"],
];
for (const [v, r] of checks) console.log(v, r, satisfies(v, r));
Ожидаемый результат
1.5.0 ^1.2.3 true
2.0.0 ^1.2.3 false
1.2.2 ^1.2.3 false
1.2.9 ~1.2.3 true
1.3.0 ~1.2.3 false
1.2.3 1.2.3 true
1.2.4 1.2.3 false
Подсказка 1

Три случая: диапазон начинается с ^, с ~ или без символа (точная версия — достаточно compareVersions(...) === 0). Для первых двух нужны части версии как числа.

Подсказка 2

Для ^: major версии равен major диапазона и compareVersions(version, base) >= 0, где base = range.slice(1). Для ~ — то же, плюс равны minor.

ЗАДАЧА 3.5

Мини-npm run

средняя

Реализуйте runScript(name), который делает вид, что это npm run. Если скрипт есть — выводит две строки: > имя@версия скрипт и > команда. Если нет — одну строку npm error Missing script: "имя".

Можно редактировать · Ctrl + Enter
const pkg = {
  name: "mfs-shop",
  version: "1.2.0",
  scripts: { dev: "vite", build: "vite build" },
};

function runScript(name) {
  // ...
}

runScript("dev");
runScript("build");
runScript("deploy");
Ожидаемый результат
> mfs-shop@1.2.0 dev
> vite
> mfs-shop@1.2.0 build
> vite build
npm error Missing script: "deploy"
Подсказка 1

Достаньте команду: const command = pkg.scripts[name];. Если скрипта нет, там будет undefined.

Подсказка 2

Проверка: if (!command) { console.log(`npm error Missing script: "${name}"`); return; } — а дальше два console.log с шаблонными строками.

ЗАДАЧА 3.6

Что куда записать

лёгкая

Для каждого пакета известно, где он нужен: "app" (импортируется в коде приложения) или "tools" (инструмент разработки). Распределите их по двум спискам, отсортируйте по алфавиту и выведите как в примере.

Можно редактировать · Ctrl + Enter
const packages = [
  { name: "dayjs", usedIn: "app" },
  { name: "vite", usedIn: "tools" },
  { name: "nanoid", usedIn: "app" },
  { name: "prettier", usedIn: "tools" },
  { name: "eslint", usedIn: "tools" },
];

// dependencies — usedIn "app", devDependencies — usedIn "tools"
Ожидаемый результат
dependencies: dayjs, nanoid
devDependencies: eslint, prettier, vite
Подсказка 1

Используйте filter, затем map(p => p.name) и sort().

Подсказка 2

packages.filter(p => p.usedIn === "app").map(p => p.name).sort().join(", ") — и то же для "tools".

04 Vite: сервер разработки и сборка

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

Аналогия из жизни

Кухня и доставка. Пока вы готовите (разрабатываете), все продукты лежат на столе в удобных коробочках: взял, попробовал, поправил — это dev-сервер. Но клиенту вы отправляете не стол с коробочками, а аккуратно упакованный заказ: всё в одном пакете, без лишнего — это сборка (build). Пакет можно положить на любой хостинг.

Dev-сервер и сборка: в чём разница

Dev-сервер (npm run dev)Сборка (npm run build)
ЗачемПисать код и сразу видеть результатПодготовить сайт к публикации
ФайлыВаши модули отдаются почти «как есть», по запросу браузераСклеены и сжаты в несколько файлов с «хэшем» в имени
РезультатАдрес http://localhost:5173, на диск ничего не пишетсяПапка dist/ с готовым сайтом
РаботаетПока открыт терминал; остановка — Ctrl+CОдин раз, за секунду-две, и завершается
ОбновлениеСтраница меняется сама после сохранения (HMR)Нужно собрать заново

Пошаговая инструкция: создаём проект

Выполняйте у себя на компьютере вместе с преподавателем. Папку для проектов выбирайте без кириллицы и пробелов в пути, например C:\dev на Windows или ~/dev на macOS. Не создавайте проект внутри «синхронизируемых» папок (OneDrive, iCloud Drive): тысячи файлов node_modules будут тормозить синхронизацию.

  1. Откройте терминал в нужной папке. macOS: приложение «Терминал» или терминал VS Code. Windows: PowerShell или терминал VS Code (в проводнике можно ввести powershell в адресной строке папки).
  2. Создайте проект. Команда задаст несколько вопросов — выбирайте стрелками, подтверждайте Enter.
  3. Перейдите в папку проекта и установите зависимости.
  4. Запустите сервер разработки и откройте адрес из терминала в браузере.
  5. Остановите сервер — Ctrl+C в терминале (и на Windows, и на macOS).
Терминал
npm create vite@latest
# Project name: → my-shop
# Select a framework: → Vanilla
# Select a variant: → JavaScript
# если спросят про дополнительные опции — оставьте значения по умолчанию

cd my-shop
npm install
npm run dev

То же одной строкой, без вопросов (двойное -- нужно, чтобы npm передал опции дальше в create-vite):

Терминал
npm create vite@latest my-shop -- --template vanilla

После npm run dev вы увидите примерно такое:

Терминал — вывод
> my-shop@0.0.0 dev
> vite

  VITE v8.3.4  ready in 95 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

Откройте http://localhost:5173/ в браузере. Номер версии у вас может быть другим. Если порт 5173 занят (вы уже запустили Vite в другом окне), Vite сам возьмёт следующий — 5174, и напишет об этом. Измените в проекте любой текст в src/main.js и сохраните: страница обновится без перезагрузки. Это называется HMR (Hot Module Replacement).

Если что-то пошло не так

«npm: command not found» / «не является внутренней или внешней командой» — Node.js не установлен или терминал открыт до установки: закройте его и откройте снова. Ошибка про версию Node — обновите Node.js до LTS. Windows, окно «Брандмауэр» — разрешите доступ для Node.js в частной сети: иначе страница не откроется. Белая страница — откройте DevTools (F12) → вкладка Console: там написано, какой файл не найден.

Что создал Vite

my-shop/ ├─ index.html главная страница — точка входа всего сайта ├─ package.json скрипты dev / build / preview, vite в devDependencies ├─ .gitignore уже содержит node_modules и dist ├─ public/ файлы, которые отдаются «как есть» (favicon) └─ src/ ├─ main.js первый JS-файл: сюда пишем свой код ├─ counter.js пример модуля — его можно удалить ├─ style.css стили, подключаются через import └─ assets/ картинки, которые импортируются из JS

Главное в этой структуре — index.html. В отличие от старых сборщиков, здесь он лежит в корне и сам подключает ваш код:

index.html (фрагмент)
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>

А в src/main.js видны знакомые import. Стили и картинки тоже подключаются через import — Vite сам разберётся, что с ними делать:

src/main.js (фрагмент)
import './style.css'                          // стили — просто import без имён
import viteLogo from './assets/vite.svg'      // картинка → её итоговый адрес
import { setupCounter } from './counter.js'   // наш модуль

Сборка и просмотр результата

Терминал
npm run build
npm run preview     # локальный просмотр собранного сайта
Терминал — вывод build (фрагмент)
vite v8.3.4 building client environment for production...
✓ 9 modules transformed.
dist/index.html                  0.45 kB │ gzip: 0.29 kB
dist/assets/vite-BF8QNONU.svg    8.70 kB │ gzip: 1.60 kB
dist/assets/hero-CLDdwZDr.png   13.05 kB
dist/assets/index-CsUDhMuy.css   4.10 kB │ gzip: 1.46 kB
dist/assets/index-CAoPt-vL.js    4.05 kB │ gzip: 1.77 kB
✓ built in 273ms
  • Девять модулей превратились в один JS-файл (index-….js) и один CSS. Меньше файлов — быстрее загрузка.
  • В именах файлов появился хэш (CAoPt-vL). Изменили код — хэш поменялся, браузер скачает свежий файл, а не возьмёт старый из кэша.
  • Папку dist можно выкладывать на любой статический хостинг. Её не коммитят в Git — она получается командой build из исходников.
  • Если сайт будет жить не в корне домена, а по адресу вида username.github.io/my-shop/ (GitHub Pages), нужно создать vite.config.js и указать base: "/my-shop/". Это мы разберём, когда дойдём до публикации.

Переменные окружения: что видно в браузере

Адрес API и другие настройки удобно вынести в файл .env в корне проекта:

.env
VITE_API_URL=https://dummyjson.com
VITE_APP_TITLE=MakeFirstStep Shop
DB_PASSWORD=12345
src/api/products.js (фрагмент)
const API_URL = import.meta.env.VITE_API_URL;   // Vite подставит значение при сборке
console.log(import.meta.env.DB_PASSWORD);       // undefined — переменная без префикса VITE_ скрыта
Секретов в браузерном коде не бывает

В код сайта попадают только переменные с префиксом VITE_ — и после сборки любой посетитель увидит их в DevTools. Поэтому нельзя хранить в них пароли и приватные ключи: «спрятать» ключ во фронтенде невозможно. В проект с общедоступным API без ключа (как в наших уроках) это не страшно, но привычку нужно выработать сразу. Файл .env.local Vite уже исключил из Git через .gitignore; файл .env с общими настройками можно коммитить.

Читать подробнее: Vite — Getting Started · Vite — Env Variables and Modes · learn.javascript.ru — Модули

Практика: закрепляем

ЗАДАЧА 4.1

Первый запуск Vite

лёгкая

Это задание выполняется на вашем компьютере, автоматически его проверить нельзя — отметьте вручную, когда сделаете. Создайте проект vite-test (шаблон vanilla), установите зависимости и запустите npm run dev. Измените заголовок h1 в src/main.js на своё имя и убедитесь, что страница обновилась сама. Затем выполните npm run build и найдите в терминале размер самого большого файла в dist. Запишите себе: версию Vite, адрес dev-сервера и этот размер.

Подсказка 1

Последовательность: npm create vite@latest vite-test -- --template vanilla, затем cd vite-test, npm install, npm run dev.

Подсказка 2

Версия Vite и адрес написаны в выводе npm run dev. Размеры файлов — в выводе npm run build, колонка слева от «gzip».

ЗАДАЧА 4.2

Разбор вывода build

средняя

Дан текст, который вывел npm run build. Разберите его в коде: найдите все строки с файлами из dist, определите самый тяжёлый (по размеру до gzip) и посчитайте общий размер. Выведите три строки как в примере. Сумму округлите до двух знаков через toFixed(2).

Можно редактировать · Ctrl + Enter
const output = `
vite v8.3.4 building client environment for production...
✓ 9 modules transformed.
dist/index.html                  0.45 kB │ gzip: 0.29 kB
dist/assets/vite-BF8QNONU.svg    8.70 kB │ gzip: 1.60 kB
dist/assets/hero-CLDdwZDr.png   13.05 kB
dist/assets/index-CsUDhMuy.css   4.10 kB │ gzip: 1.46 kB
dist/assets/index-CAoPt-vL.js    4.05 kB │ gzip: 1.77 kB
✓ built in 273ms
`;

// 1) Файлов: сколько
// 2) Самый тяжёлый: имя (размер kB)
// 3) Всего: сумма kB
Ожидаемый результат
Файлов: 5
Самый тяжёлый: dist/assets/hero-CLDdwZDr.png (13.05 kB)
Всего: 30.35 kB
Подсказка 1

Файлы — строки, которые начинаются с dist/. Разбейте текст по \n, отфильтруйте startsWith("dist/").

Подсказка 2

Из каждой строки возьмите имя и первое число перед «kB»: line.match(/^(\S+)\s+([\d.]+) kB/) даёт имя в m[1], размер в m[2] (не забудьте Number).

ЗАДАЧА 4.3

Что попадёт в браузер

средняя

Напишите функцию publicEnv(env): она возвращает новый объект, в котором остались только переменные, начинающиеся с VITE_ — именно такие Vite отдаёт в код сайта. Затем выведите их по одной в формате ИМЯ=значение.

Можно редактировать · Ctrl + Enter
const env = {
  VITE_API_URL: "https://dummyjson.com",
  DB_PASSWORD: "12345",
  VITE_APP_TITLE: "MakeFirstStep Shop",
  SECRET_KEY: "abc",
};

function publicEnv(env) {
  // ...
}

const result = publicEnv(env);
// выведите каждую пару в формате ИМЯ=значение
Ожидаемый результат
VITE_API_URL=https://dummyjson.com
VITE_APP_TITLE=MakeFirstStep Shop
Подсказка 1

Object.entries(env) даёт массив пар [ключ, значение], его можно отфильтровать методом startsWith.

Подсказка 2

Object.fromEntries(Object.entries(env).filter(([key]) => key.startsWith("VITE_"))). Печать: цикл for (const [k, v] of Object.entries(result)).

05 Практика на уроке: переносим приложение в Vite

Берём приложение из урока 16 — поиск по API с состояниями загрузки, пустого результата и ошибки и с debounce — и переезжаем на Vite. Ниже — эталонный пример на каталоге товаров dummyjson.com. У вас названия функций и файлов могут отличаться — это нормально: важна идея разделения, а не буква в букву.

План переезда

1) Сохраняем старую версию в Git. 2) Создаём проект на Vite. 3) Переносим разметку в index.html, стили — в src/styles/. 4) Делим JS на модули: api, ui, utils, main. 5) Запускаем npm run dev и проверяем, что всё работает как раньше. 6) Собираем npm run build. 7) Коммит и пуш на GitHub. Правило переезда: поведение приложения не меняется — только структура кода.

Шаг 1. Новый проект

Терминал
# в папке рядом со старым проектом, не внутри него
npm create vite@latest products-vite -- --template vanilla
cd products-vite
npm install
npm run dev

Шаг 2. Чистим шаблон

  • Удалите src/counter.js, папку src/assets/ и всё содержимое public/, кроме иконки, если она вам нужна. В index.html уберите ссылку на favicon.svg, если удалили файл.
  • Замените в index.html содержимое <body> на разметку вашего приложения. Строку <script type="module" src="/src/main.js"> оставьте.
  • Создайте папки src/api, src/ui, src/utils, src/styles. CSS из старого проекта положите в src/styles/style.css (файл src/style.css от шаблона удалите).
index.html
<!doctype html>
<html lang="ru">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Каталог товаров</title>
  </head>
  <body>
    <main class="app">
      <h1>Каталог товаров</h1>
      <input id="search" type="search" placeholder="Например, phone" />
      <p id="status">Введите запрос</p>
      <ul id="list"></ul>
    </main>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

Шаг 3. Делим код на модули

Каждый файл — одна ответственность. Сначала api: только запрос и проверка ответа, никакого DOM.

src/api/products.js
const API_URL = "https://dummyjson.com";

export async function searchProducts(query, signal) {
  const url = `${API_URL}/products/search?q=${encodeURIComponent(query)}&limit=10`;
  const response = await fetch(url, { signal });

  if (!response.ok) {
    throw new Error(`Ошибка сервера: ${response.status}`);
  }

  const data = await response.json();
  return data.products;
}

Дальше utils: debounce ничего не знает про наше приложение, поэтому может жить в любом проекте.

src/utils/debounce.js
export function debounce(fn, delay = 300) {
  let timerId;
  return (...args) => {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn(...args), delay);
  };
}

ui отвечает только за то, что видит пользователь: показывает статус и рисует список.

src/ui/render.js
const statusEl = document.querySelector("#status");
const listEl = document.querySelector("#list");

export function showStatus(text) {
  statusEl.textContent = text;
}

export function renderProducts(products) {
  const items = products.map((product) => {
    const li = document.createElement("li");
    li.textContent = `${product.title} — ${product.price} $`;
    return li;
  });
  listEl.replaceChildren(...items);
}

И, наконец, main.js — дирижёр: он ничего не умеет сам, но знает, кого и в каком порядке позвать.

src/main.js
import "./styles/style.css";
import { searchProducts } from "./api/products.js";
import { renderProducts, showStatus } from "./ui/render.js";
import { debounce } from "./utils/debounce.js";

let controller;

async function search(query) {
  controller?.abort();                 // отменяем предыдущий запрос
  controller = new AbortController();

  if (!query.trim()) {
    showStatus("Введите запрос");
    renderProducts([]);
    return;
  }

  showStatus("Загрузка...");
  try {
    const products = await searchProducts(query, controller.signal);
    renderProducts(products);
    showStatus(products.length ? `Найдено: ${products.length}` : "Ничего не найдено");
  } catch (error) {
    if (error.name === "AbortError") return;   // это мы сами отменили — не ошибка
    showStatus(`Ошибка: ${error.message}`);
  }
}

document
  .querySelector("#search")
  .addEventListener("input", debounce((event) => search(event.target.value), 300));
Аналогия из жизни

Управляющий чойхоной (main.js) сам не готовит и не моет посуду. Он принимает заказ, говорит повару (api) «приготовь», официанту (ui) — «подай», а помощнику (utils) — «подожди, пока гость договорит». Заменить официанта можно, не трогая кухню — и наоборот. Это и есть цель модулей.

Шаг 4. Проверяем

  1. Запустите npm run dev и повторите все сценарии из урока 16: пустой запрос, нормальный поиск, запрос без результатов, отключение сети (DevTools → Network → Offline). Всё должно работать как раньше.
  2. Если белая страница — откройте DevTools (F12) → Console. Чаще всего это ошибка пути: «Failed to resolve import», опечатка в имени файла или забытое .js.
  3. Выполните npm run build, затем npm run preview — приложение должно работать и из собранной версии.

Шаг 5. Git и GitHub

Шаблон Vite уже создал .gitignore с node_modules и dist. Проверьте это: после git status в списке не должно быть этих папок.

Терминал
git init
git status                       # node_modules и dist в списке быть не должно
git add .
git commit -m "init vite project"
# на github.com создайте пустой репозиторий (без README и .gitignore) и скопируйте его адрес
git remote add origin https://github.com/ВАШ_ЛОГИН/products-vite.git
git branch -M main
git push -u origin main

Если Git ещё не установлен: на Windows скачайте Git for Windows с git-scm.com, на macOS команда git при первом запуске сама предложит установить инструменты разработчика. Дальше делайте небольшие коммиты по ходу переноса: move html to vite, split code into modules — историю будет видно в репозитории.

Типичные ловушки переезда

1) В import нет ./ или нет .js. 2) Скрипт в index.html подключён без type="module" — тогда import в main.js не сработает. 3) В render.js элементы ищутся по id, которого нет в новом index.html — при переносе разметки не потеряйте id. 4) В Git попала папка node_modules: удалите её из индекса командой git rm -r --cached node_modules и добавьте строку в .gitignore.

Читать подробнее: Vite — Features · MDN — динамический import()

Практика: собираем модули в песочнице

В браузере нет файлов, но мы можем потренировать ту же самую сборку из модулей. В задачах — те же роли: api, ui, utils, main. Внутри «файлов» этих задач используйте обычные строки с + (без шаблонных строк с ${…}), иначе они перепутаются с шаблоном, в котором лежит «файл».

ЗАДАЧА 5.1

Дирижёр main.js

средняя

Модули api.js (поиск товара, ответ приходит через 30 мс) и render.js готовы. Напишите функцию search(query) в main.js: покажите статус «Загрузка...», получите товары, затем либо «Ничего не найдено», либо «Найдено: N» и список. Для выбранных запросов вызовите search по очереди.

Можно редактировать · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const apiUrl = url(`
  const DB = [
    { title: "Телефон", price: 2500 },
    { title: "Чехол для телефона", price: 80 },
    { title: "Наушники", price: 300 },
  ];
  export async function searchProducts(query) {
    await new Promise((resolve) => setTimeout(resolve, 30));
    return DB.filter((p) => p.title.toLowerCase().includes(query.toLowerCase()));
  }
`);

const renderUrl = url(`
  export function showStatus(text) { console.log("[статус] " + text); }
  export function renderProducts(list) {
    for (const p of list) console.log("- " + p.title + " — " + p.price + " сомони");
  }
`);

await import(url(`
  import { searchProducts } from "${apiUrl}";
  import { showStatus, renderProducts } from "${renderUrl}";

  async function search(query) {
    // 1) showStatus("Загрузка...")
    // 2) получить товары: await searchProducts(query)
    // 3) пусто → "Ничего не найдено", иначе "Найдено: N" и renderProducts
  }

  await search("телефон");
  await search("xyz");
`));
Ожидаемый результат
[статус] Загрузка...
[статус] Найдено: 2
- Телефон — 2500 сомони
- Чехол для телефона — 80 сомони
[статус] Загрузка...
[статус] Ничего не найдено
Подсказка 1

Функция search асинхронная — внутри можно писать await. Сначала статус, потом запрос, потом ветвление if.

Подсказка 2

const products = await searchProducts(query); — дальше: if (products.length === 0) { showStatus("Ничего не найдено"); return; }, а после него showStatus("Найдено: " + products.length); renderProducts(products);

ЗАДАЧА 5.2

Модуль debounce

сложная

Напишите модуль debounce.js: экспортируйте функцию debounce(fn, delay), которая откладывает вызов fn на delay мс и сбрасывает таймер, если её вызвали снова. В main.js «пользователь» печатает быстро (буквы приходят через 10 мс) и после паузы допечатывает ещё. Должно получиться ровно два вызова — по одному на каждую «серию» ввода.

Можно редактировать · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const debounceUrl = url(`
  // export function debounce(fn, delay) { ... }
`);

await import(url(`
  import { debounce } from "${debounceUrl}";

  const search = debounce((q) => console.log("поиск: " + q), 50);

  search("к");
  setTimeout(() => search("ку"), 10);
  setTimeout(() => search("кур"), 20);
  setTimeout(() => search("курут"), 150);   // после паузы — новая серия
`));
Ожидаемый результат
поиск: кур
поиск: курут
Подсказка 1

Таймер нужно где-то хранить между вызовами: объявите переменную timerId в функции debounce, а возвращайте новую функцию, которая её использует (замыкание).

Подсказка 2

export function debounce(fn, delay) { let id; return (...args) => { clearTimeout(id); id = setTimeout(() => fn(...args), delay); }; }

ЗАДАЧА 5.3

Из простыни в функции

средняя

Код считает и печатает счёт одного стола. Теперь нужно напечатать счёт и второго стола — копировать код было бы ошибкой. Перепишите его в маленькие функции (например, lineTotal, orderTotal, printReport(title, orders)) и вызовите для обоих столов. Это тот же приём, что и при делении на модули: один блок — одна задача.

Можно редактировать · Ctrl + Enter
const table1 = [
  { name: "Самбуса", price: 5, qty: 10 },
  { name: "Курутоб", price: 35, qty: 2 },
  { name: "Чой", price: 3, qty: 4 },
];
const table2 = [
  { name: "Шашлык", price: 25, qty: 3 },
  { name: "Лепёшка", price: 4, qty: 5 },
];

// --- старый код (только для первого стола) ---
console.log("== Стол 1 ==");
let sum = 0;
for (const o of table1) {
  const line = o.price * o.qty;
  console.log(o.name + " x" + o.qty + " = " + line + " сомони");
  sum += line;
}
console.log("Итого: " + sum + " сомони");
Ожидаемый результат
== Стол 1 ==
Самбуса x10 = 50 сомони
Курутоб x2 = 70 сомони
Чой x4 = 12 сомони
Итого: 132 сомони
== Стол 2 ==
Шашлык x3 = 75 сомони
Лепёшка x5 = 20 сомони
Итого: 95 сомони
Подсказка 1

Заверните весь «старый код» в функцию printReport(title, orders): заголовок и массив становятся параметрами, а внутри table1 заменяется на orders.

Подсказка 2

Дальше вынесите подсчёт в lineTotal(order) и orderTotal(orders), а вызовы сделайте две строки: printReport("== Стол 1 ==", table1); и то же для table2.

06 ИИ-блок: просим разбить код на модули

Разбить файл на модули — типичная задача для ИИ: она механическая, а результат можно проверить запуском. Но именно здесь ИИ чаще всего «ломает» мелочи: путает default и именованный экспорт, забывает .js в пути, выдумывает пакеты, которых нет в проекте. Поэтому сегодня тренируем навык: просить точно и проверять до последней строчки.

Правила курса, которые здесь особенно важны

1. Сначала 15 минут пробуете сами: набросайте на бумаге, какие файлы и папки вам нужны и что в каждом лежит. 2. Только потом идёте к ИИ и сравниваете со своим вариантом. 3. Не вставляйте в проект код, который не можете объяснить строчка за строчкой — особенно import и export: для каждого скажите, откуда и что берётся. 4. Не ставьте пакеты, которые предложил ИИ, не проверив их на npmjs.com. 5. Проверяйте запуском: npm run dev и npm run build.

Как просить: плохо и хорошо

Формула промпта курса: контекст → задача → ограничения → формат ответа → проверка.

разбей код на модули
Я учу JavaScript, делаю учебный проект на Vite (шаблон vanilla, без фреймворков). Сейчас весь код в одном файле src/main.js — вставляю его ниже: [код]. Разбей его на ES-модули и предложи структуру папок: api (запросы к dummyjson.com), ui (отрисовка), utils (мелкие помощники), main.js только подключает остальное. Ограничения: поведение приложения не менять, новые npm-пакеты не добавлять, использовать именованные экспорты, во всех путях писать расширение .js. Формат ответа: сначала дерево папок, затем по одному блоку кода на каждый файл с заголовком-путём, затем список «что откуда импортируется» в виде таблицы. Проверка: в конце перечисли 3 места, где я могу ошибиться при переносе (пути, id в разметке, порядок импортов).

Скопируйте хороший промпт, подставьте свой код вместо [код]. Дальше — проверяем ответ. Чек-лист проверки ответа ИИ по модулям:

  • ☐ Каждому import соответствует настоящий export с таким же именем (и тем же способом: default или именованный).
  • ☐ Все пути начинаются с ./ или ../, на конце — .js, регистр букв совпадает с именем файла.
  • ☐ Нет импортов пакетов, которых нет в package.json (lodash, axios и т. п.). Если нужен — вы решаете это сознательно.
  • ☐ Нет циклов: api и ui не импортируют main.js.
  • ☐ Поведение то же: сравните с версией до разбиения во всех сценариях.
  • ☐ npm run build завершается без ошибок.
Совет

Если ИИ вернул результат, который не работает, не просите «исправь». Скопируйте текст ошибки из консоли, добавьте файл, где она случилась, и спросите: «Что означает эта ошибка и в какой строке причина?» Сначала поймите — потом правьте.

Практика: закрепляем

ЗАДАЧА 6.1

Проверь ответ ИИ

средняя

ИИ предложил два модуля и main.js, но запускать его код нельзя: в нём две ошибки, типичные для ответов ИИ. Найдите и исправьте их только в main.js, не меняя utils.js: товары должны выводиться от дешёвых к дорогим, цена — со скидкой 10%. Пакет lodash в проекте не установлен.

Можно редактировать · Ctrl + Enter
const url = (code) => {
  setTimeout(() => {}, 300); // песочница ждёт, пока модуль загрузится
  return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
};

const utilsUrl = url(`
  export default function formatPrice(p) { return p + " сомони"; }
  export function discount(price, percent) { return price - price * percent / 100; }
`);

await import(url(`
  import { formatPrice, discount } from "${utilsUrl}";
  import { sortBy } from "lodash";

  const products = [
    { name: "Курутоб", price: 35 },
    { name: "Самбуса", price: 5 },
    { name: "Чой", price: 3 },
  ];

  for (const p of sortBy(products, "price")) {
    console.log(p.name + ": " + formatPrice(discount(p.price, 10)));
  }
`));
Ожидаемый результат
Чой: 2.7 сомони
Самбуса: 4.5 сомони
Курутоб: 31.5 сомони
Подсказка 1

Ошибка первая: formatPrice экспортируется «по умолчанию», а импортируется в фигурных скобках. Вторая — пакет lodash, которого нет: замените sortBy на встроенную сортировку.

Подсказка 2

import formatPrice, { discount } from "${utilsUrl}"; и вместо sortBy(products, "price") — [...products].sort((a, b) => a.price - b.price).

07 Что покажет преподаватель: Claude Code

Claude Code — ИИ-помощник, который работает в терминале, прямо в папке вашего проекта. Он, в отличие от чата, видит файлы проекта, может предложить правки сразу в нескольких файлах и запускать команды (npm run build, git diff). Но каждое действие — правка файла или команда — он делает только после вашего разрешения. Преподаватель покажет работу на проекте из этого урока; устанавливать Claude Code вам сегодня не нужно. Ваша задача — смотреть и записывать, как ставится задача и как проверяется результат.

Аналогия из жизни

Это помощник на кухне чойханы: он умеет быстро нарезать и подготовить, но блюдо за него «пробуете» вы. Сказали «нарежь лук кольцами» — получили кольца. Сказали «приготовь что-нибудь» — получили сюрприз. Чем точнее поручение и чем внимательнее проверка перед подачей, тем лучше результат.

Ход демонстрации

  1. Точка отката. Перед началом проект закоммичен: git status показывает «чисто». Если ИИ испортит код — вернёмся одной командой.
  2. Запуск в папке проекта. Преподаватель открывает терминал в корне проекта и запускает claude.
  3. Постановка задачи. Задача пишется по формуле курса: контекст, задача, ограничения, формат, проверка (пример ниже).
  4. План до правок. Просим сначала показать план: какие файлы будут созданы и изменены. Поменять план проще, чем исправлять готовые правки. В Claude Code для этого есть режим плана (переключается по Shift+Tab).
  5. Разрешения. На каждую правку и команду Claude Code спрашивает «можно?». Мы читаем, что именно он собирается сделать, и только потом соглашаемся.
  6. Разбор результата. Смотрим git status, git diff, запускаем npm run dev и npm run build. Проверяем по чек-листу ниже.
  7. Решение. Хорошее — коммитим с понятным сообщением. Плохое — откатываем и уточняем задачу.

Как формулируется задача

Это учебный проект на Vite (vanilla JS): поиск товаров по dummyjson.com. Сейчас весь код лежит в src/main.js. Раздели его на модули: src/api/products.js (запросы), src/ui/render.js (отрисовка), src/utils/debounce.js; main.js только подключает и связывает. Ограничения: поведение не менять, index.html и package.json не трогать, новых пакетов не ставить, именованные экспорты, расширение .js в путях. Сначала покажи план по файлам, жди моего подтверждения, потом вноси правки. В конце запусти npm run build и сообщи результат.

Обратите внимание на три приёма: что нельзя трогать (index.html, package.json, пакеты), «сначала план, потом правки» и проверка в конце (npm run build).

Как смотреть результат: команды

Терминал
git status          # какие файлы изменены и какие появились
git diff --stat     # сколько строк изменено в каждом файле
git diff            # сами изменения построчно (q — выйти из просмотра)
npm run build       # собирается ли проект
npm run dev         # работает ли приложение в браузере
Терминал — если результат не подошёл
git restore .       # вернуть изменённые файлы к последнему коммиту
git clean -n        # показать, какие НОВЫЕ файлы Git удалил бы (только показать!)
git clean -fd       # удалить новые файлы — только если список выше вас устроил

На что обращать внимание

  • ☐ Те ли файлы тронуты. Попросили про src/ — а в диффе появился package.json или index.html? Спросите, зачем.
  • ☐ Новые зависимости. Строка в dependencies — повод остановиться и проверить пакет на npmjs.com.
  • ☐ Удалённый код. Красные строки (-) читайте так же внимательно, как зелёные: не пропала ли проверка ошибки или отмена запроса.
  • ☐ Секреты. В диффе не должно быть ключей, паролей, содержимого .env.
  • ☐ Мусор. Оставленные console.log, закомментированный код, лишние файлы.
  • ☐ Слишком большой дифф. Если изменений на сотни строк в десятках файлов — задача поставлена слишком широко. Откатите и разбейте на шаги.
  • ☐ Вы можете объяснить каждую строку. Если нет — попросите объяснить, а потом перепишите своими словами. Это правило курса.
  • ☐ Работает так же, как раньше. Агент говорит «готово» — это не доказательство. Доказательство — ваш запуск.
Агент не заменяет понимание

Claude Code ускоряет рутину — например, перенос 5 файлов по папкам. Но выбрать структуру проекта, заметить пропавшую обработку ошибки и отвечать за итог — ваша работа. На контрольных ИИ выключен: понимание, которое вы не приобрели сами, там не поможет.

Читать подробнее: Документация Claude Code · Git — git diff

Практика: закрепляем

ЗАДАЧА 7.1

Ревью диффа

средняя

Напишите функцию reviewDiff(lines), которая просматривает дифф и выводит предупреждения. Строки с + — добавленные, с - — удалённые, остальные пропускаем. Номер строки — её номер в массиве, начиная с 1. Правила (проверяются по порядку, на строку — не больше одного предупреждения):

  • добавлена строка с console.log → строка N: оставлен console.log;
  • добавлена строка, где есть API_KEY = (или password =), → строка N: похоже на секрет;
  • добавлена строка вида "имя": "^число…" (запись зависимости) → строка N: новая зависимость;
  • удалена строка с export → строка N: удалён export.

Если ничего не найдено — выведите Замечаний нет.

Можно редактировать · Ctrl + Enter
const diff = [
  "diff --git a/src/api/products.js b/src/api/products.js",
  "@@ -1,5 +1,8 @@",
  '+const API_KEY = "sk-12345";',
  " export async function searchProducts(query) {",
  "-  const response = await fetch(url);",
  '+  const response = await fetch(url + "?key=" + API_KEY);',
  "+  console.log(response);",
  "diff --git a/package.json b/package.json",
  '+    "lodash": "^4.17.21"',
  "-export function debounce(fn, delay) {",
];

function reviewDiff(lines) {
  // ...
}

reviewDiff(diff);
Ожидаемый результат
строка 3: похоже на секрет
строка 7: оставлен console.log
строка 9: новая зависимость
строка 10: удалён export
Подсказка 1

Проходите циклом for (let i = 0; i < lines.length; i++). Первая буква строки: line[0] — это "+" или "-". Строки diff --git и @@ начинаются с других символов.

Подсказка 2

Для добавленной строки используйте line.includes("console.log"), /(API_KEY|password) =/.test(line) и /"[^"]+": "\^\d/.test(line). Для удалённой — line.includes("export"). Номер: i + 1. Нужен счётчик найденных замечаний, чтобы в конце вывести «Замечаний нет».

08 Домашнее задание

Переведите своё приложение из урока 16 на Vite, разделите код на модули и залейте результат в репозиторий на GitHub.

Что нужно сделать

  • ☐ Создать проект командой npm create vite@latest (шаблон vanilla), перенести разметку, стили и код приложения. Удалить из шаблона всё лишнее (counter.js, картинки).
  • ☐ Разделить JS минимум на три модуля в папках api, ui, utils (или своей логичной структуре); main.js только подключает и связывает.
  • ☐ Использовать import/export с путями вида ./…js. Файл-витрину index.js или import … as используйте, если это оправдано (по желанию).
  • ☐ Приложение работает так же, как в уроке 16: состояния загрузки, пустого результата и ошибки, debounce, поиск.
  • ☐ В package.json есть скрипты dev, build, preview; npm run build проходит без ошибок.
  • ☐ В репозитории нет node_modules и dist (проверьте .gitignore), но есть package-lock.json.
  • ☐ Не менее 3 осмысленных коммитов (init vite project, move app to vite, split code into modules — по вашей истории).
  • ☐ В README.md: что за приложение, как запустить (npm install, npm run dev), структура папок и 2–3 предложения своими словами: какой модуль что экспортирует и кто его импортирует.
  • ☐ Если использовали ИИ — отметьте в README, на каком шаге и что пришлось исправить после проверки.

Что сдать

Ссылку на репозиторий в чат группы (Telegram). В сообщении напишите: что за приложение, из скольких модулей оно состоит и какая ошибка при переносе была самой неожиданной.

Как оценивается ДЗ (0–10)

Что проверяемБаллы
Проект на Vite, npm install и npm run dev запускают приложение, npm run build проходит2
Не меньше 3 модулей с понятными ролями, main.js без «лишней» логики, пути и экспорты корректны3
Приложение работает как в уроке 16 (состояния, debounce, поиск)2
.gitignore без node_modules и dist, не менее 3 осмысленных коммитов1
README: запуск, структура, объяснение экспортов своими словами2

✓ Шпаргалка урока

  • Модуль — файл со своей областью видимости. Наружу попадает только то, что помечено export.
  • Именованный экспорт: export function f() {} → import { f } from "./a.js". Имена совпадают; переименование: { f as g }; всё сразу: import * as a.
  • По умолчанию: export default … → import любоеИмя from "./a.js" (без скобок). В модуле он один.
  • Витрина: export { f } from "./a.js", export * from "./b.js" в index.js — одно окно для папки.
  • Пути: ./ — рядом, ../ — выше, имя без точки — пакет из npm. Расширение .js пишем, слэш всегда прямой, регистр букв важен.
  • Модуль выполняется один раз, у всех импортёров общее состояние. Циклических импортов избегаем.
  • Структура: src/api, src/ui, src/utils, src/main.js. Один модуль — одна задача, main.js — дирижёр.
  • package.json: scripts, dependencies (нужны коду приложения), devDependencies (инструменты). node_modules в Git не кладём, package-lock.json — кладём.
  • semver: MAJOR.MINOR.PATCH. ^1.4.2 — до 2.0.0, ~1.4.2 — до 1.5.0. Числа сравниваем как числа.
  • Команды: npm install, npm install -D имя, npm run dev | build | preview.
  • Vite: npm create vite@latest → cd → npm install → npm run dev. Dev-сервер — для работы (HMR), build — готовый сайт в dist/, preview — посмотреть сборку.
  • Переменные .env: в браузер попадают только VITE_…, и их видит любой — секретов там нет.
  • ИИ и Claude Code: точная задача по формуле, «сначала план», разрешения читаем, результат проверяем через git diff и запуск; пакеты проверяем на npmjs.com.