Skip to content

Latest commit

 

History

History
490 lines (361 loc) · 33 KB

lesson15.md

File metadata and controls

490 lines (361 loc) · 33 KB

Урок 15. Формы целиком: разметка, стилизация, method/action, как данные уходят на сервер

Собираем всё воедино

В Уроке 13 мы стилизовали отдельные поля input. В Уроке 14 — кнопки. Сегодня — последний урок HTML/CSS-блока курса, и в нём мы, наконец, соберём <form> целиком и разберём то, что до сих пор оставалось за кадром: что физически происходит, когда пользователь нажимает кнопку отправки. Куда уходят данные, в каком виде, и почему браузер вдруг "перезагружает" страницу.

Это принципиально важный урок с прицелом на будущее: сейчас мы разберём нативную отправку формы — без единой строчки JavaScript, только силами самого браузера. А в модуле JS, в теме про асинхронность, мы возьмём ровно эту же форму и научимся перехватывать её отправку, чтобы отправлять данные через fetch — без перезагрузки страницы, с полным контролем над запросом и ответом. Всё, что мы сделаем сегодня, не будет выброшено — это фундамент, на котором будет строиться работа с fetch.


Анатомия <form>: action и method

<form action="/search" method="GET">
  <input type="text" name="query" />
  <button type="submit">Найти</button>
</form>

Два атрибута определяют, куда и как уйдут данные при отправке формы:

  • action — адрес (URL), на который браузер отправит запрос при отправке формы. Если атрибут не указан — форма отправится на текущий адрес страницы.
  • method — HTTP-метод запроса, которым отправятся данные. Два основных значения: GET и POST.

Если method не указан явно — браузер по умолчанию использует GET. Это стоит запомнить: точно так же, как <button> без type по умолчанию становится submit (Урок 14), <form> без method по умолчанию становится GET — оба правила поведения "по умолчанию" стоит знать твёрдо, чтобы не удивляться неожиданному поведению формы.


GET vs POST — куда физически деваются данные

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

GET — данные уходят в самом адресе (URL)

<form action="/search" method="GET">
  <input type="text" name="query" value="матрица" />
  <button type="submit">Найти</button>
</form>

При отправке этой формы браузер перейдёт на адрес:

/search?query=матрица

Данные формы дописываются к URL после знака ? в виде пар имя=значение — это называется query string (строка запроса). Если полей несколько, они разделяются символом &:

/search?query=матрица&category=films

Когда использовать GET:

  • Поиск, фильтрация, сортировка — операции, которые не изменяют данные на сервере, а только запрашивают их;
  • Ситуации, где полезно, что итоговый URL можно скопировать, сохранить в закладки или отправить другому человеку — он полностью описывает состояние запроса.

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

POST — данные уходят в теле запроса

<form action="/reviews/create" method="POST">
  <input type="text" name="author_name" value="Мария" />
  <button type="submit">Отправить</button>
</form>

При отправке этой формы URL остаётся неизменным (/reviews/create) — данные не видны в адресной строке, они передаются в теле HTTP-запроса, скрыто от визуального наблюдения (хотя всё равно доступны через инструменты разработчика, вкладка Network — "скрыто" не означает "зашифровано" или "безопасно само по себе").

Когда использовать POST:

  • Создание, изменение, удаление данных на сервере (добавить пост, оставить рецензию, изменить пароль);
  • Отправка форм с чувствительными данными (логин, регистрация);
  • Отправка больших объёмов данных — у GET есть практическое ограничение на длину URL, у POST в теле запроса такого жёсткого ограничения нет.

Сравнительная таблица

GET POST
Где данные В URL (query string) В теле запроса
Видно в адресной строке Да Нет
Можно добавить в закладки Да Нет
Ограничение на объём данных Практическое ограничение длины URL Практически не ограничено
Подходит для паролей Нет Да
Изменяет данные на сервере Обычно нет (только запрос) Обычно да (создание/изменение)

Атрибут name — без него поле не существует для сервера

Это то, что мы упоминали ещё в самых первых уроках модуля, но не раскрывали подробно: сервер получает данные формы как набор пар имя=значение. Имя для каждой пары берётся из атрибута name конкретного input — не из id, не из placeholder, не из текста label.

<!-- Поле визуально существует, но сервер его не получит -->
<input type="text" id="username" placeholder="Имя пользователя" />

<!-- Поле будет отправлено как username=<то, что ввёл пользователь> -->
<input type="text" name="username" placeholder="Имя пользователя" />

Без атрибута name поле физически не участвует в отправке формы — пользователь может ввести туда что угодно, но при отправке это значение просто потеряется, будто поля не существовало.

name для чекбоксов и радиокнопок — своя логика

Для radio все кнопки одной группы обязаны иметь одинаковый name — так браузер понимает, что это взаимоисключающий выбор, и только один вариант из группы будет отправлен:

<label><input type="radio" name="rating" value="1" /> 1 звезда</label>
<label><input type="radio" name="rating" value="5" /> 5 звёзд</label>

Отправится только rating=1 или rating=5 — то значение, которое было выбрано.

Для checkbox ситуация другая: если несколько чекбоксов имеют одинаковый name, но пользователь отметил несколько из них, современные браузеры при обычной (не через JavaScript) отправке формы отправят только последнее выбранное значение с этим именем при использовании обычной query string модели "имя=значение" — на практике для множественного выбора значений с одним и тем же смысловым полем принято либо использовать name="category[]" (квадратные скобки — соглашение, которое многие серверные фреймворки понимают как "массив значений"), либо (что чаще и чище) присваивать чекбоксам одной группы разные name, если по смыслу это разные независимые переключатели:

<label><input type="checkbox" name="category_films" value="1" /> Фильмы</label>
<label><input type="checkbox" name="category_series" value="1" /> Сериалы</label>

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

Атрибут value

Значение, которое реально отправится под именем поля. Для текстовых полей value обычно не задаётся заранее (пользователь вводит его сам), но для radio/checkbox/скрытых полей value задаётся разработчиком явно — это то, что сервер получит, если пользователь выберет именно этот вариант.

<input type="hidden" name="film_id" value="42" />

type="hidden" — невидимое пользователю поле, которое, тем не менее, полноценно отправляется вместе с формой. Часто используется для передачи технической информации, которая не должна редактироваться пользователем напрямую (например, идентификатор объекта, к которому относится форма).


enctype — как кодируются данные при POST

Атрибут enctype (encoding type) определяет формат, в котором данные упаковываются в теле запроса при method="POST". По умолчанию используется application/x-www-form-urlencoded — данные кодируются похожим образом на query string, просто в теле, а не в URL.

Если форма содержит <input type="file"> — загрузку файла, — обязательно нужно указать другое значение:

<form action="/upload" method="POST" enctype="multipart/form-data">
  <input type="file" name="avatar" />
  <button type="submit">Загрузить</button>
</form>

multipart/form-data — единственный формат, который умеет корректно передавать бинарные данные файла вместе с остальными текстовыми полями формы в одном запросе. Без него содержимое файла попросту не сможет быть отправлено корректно.


Практика: форма поиска и фильтров (GET) — страница фильмов

Соберём воедино поле поиска (Урок 13) и кнопку (Урок 14) в полноценную рабочую форму с методом GET — здесь как раз уместен именно GET, потому что поиск не изменяет данные, а лишь выполняет запрос, и итоговый адрес удобно сохранять в закладках.

<form class="search" role="search" action="/films" method="GET">
  <input
    type="search"
    name="q"
    class="search__input"
    placeholder="Найти фильм..."
    aria-label="Поиск по сайту"
  />
  <button type="submit" class="btn-icon search__submit" aria-label="Найти">
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
      <path d="M21 21l-4.3-4.3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
    </svg>
  </button>
</form>

Если пользователь введёт "матрица" и нажмёт кнопку — браузер перейдёт на:

/films?q=матрица

Аналогично соберём форму фильтров:

<form class="filter-form" action="/films" method="GET">
  <fieldset class="filter-group">
    <legend>Категория</legend>
    <label class="filter-option">
      <input type="checkbox" name="category_films" value="1" checked />
      Фильмы
    </label>
    <label class="filter-option">
      <input type="checkbox" name="category_series" value="1" />
      Сериалы
    </label>
    <label class="filter-option">
      <input type="checkbox" name="category_cartoons" value="1" />
      Мультики
    </label>
  </fieldset>
  <button type="submit" class="btn btn--secondary btn--sm">Применить фильтры</button>
</form>

Отметив "Фильмы" и "Мультики" и нажав "Применить фильтры", пользователь перейдёт на:

/films?category_films=1&category_cartoons=1

Заметьте: невыбранные чекбоксы вообще не попадают в итоговый URL — только те, что были отмечены. Это ожидаемое поведение: checkbox без атрибута checked и без отметки пользователем не отправляет никакой пары имя=значение.


Практика: форма добавления записи (POST) — мост к блог-API

А теперь — центральный пример урока. В курсе python-sql-fastapi мы проектировали и реализовывали REST API для блога: пользователи, посты, комментарии, теги. Соберём HTML-форму, которая по разметке и name-атрибутам полей соответствует полям поста этого API (title, content, author_id) — и оформим её полностью, используя стили input и button из Уроков 13–14.

<form class="post-form" action="/posts/create" method="POST">
  <div class="field-group">
    <label for="post-title">Заголовок</label>
    <input
      type="text"
      id="post-title"
      name="title"
      class="field"
      placeholder="Название поста"
      required
      minlength="3"
    />
  </div>

  <div class="field-group">
    <label for="post-content">Текст поста</label>
    <textarea
      id="post-content"
      name="content"
      class="field field--textarea"
      placeholder="О чём расскажете?"
      rows="6"
      required
    ></textarea>
  </div>

  <div class="field-group">
    <label for="post-author">Ваш ID автора</label>
    <input
      type="number"
      id="post-author"
      name="author_id"
      class="field"
      placeholder="Например, 1"
      min="1"
      required
    />
  </div>

  <button type="submit" class="btn btn--primary">Опубликовать</button>
</form>
* {
  box-sizing: border-box;
}

.post-form {
  display: flex;
  flex-direction: column;
  gap: 20px;
  max-width: 480px;
  padding: 24px;
  background: white;
  border-radius: 12px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}

.field-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.field-group label {
  font-size: 14px;
  font-weight: 600;
  color: #374151;
}

.field {
  padding: 10px 14px;
  font-size: 15px;
  font-family: inherit;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  outline: none;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.field--textarea {
  resize: vertical; /* разрешаем изменение высоты, запрещаем горизонтальное растягивание */
  min-height: 120px;
}

.field::placeholder {
  color: #9ca3af;
}

.field:focus-visible {
  border-color: #2563eb;
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}

.field:invalid:not(:placeholder-shown) {
  border-color: #dc2626;
}

.btn {
  border: none;
  font: inherit;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 12px 24px;
  border-radius: 8px;
  font-weight: 600;
  transition: background 0.15s ease, transform 0.05s ease;
}

.btn:active {
  transform: scale(0.98);
}

.btn:focus-visible {
  outline: 2px solid #93c5fd;
  outline-offset: 2px;
}

.btn--primary {
  background: #2563eb;
  color: white;
}

.btn--primary:hover {
  background: #1d4ed8;
}

Что произойдёт при отправке этой формы прямо сейчас, без единой строчки JavaScript: браузер соберёт значения полей title, content, author_id в тело POST-запроса, полностью перезагрузит страницу и перейдёт на адрес /posts/create. Если по этому адресу не настроен обработчик, который принимает именно такой формат данных (application/x-www-form-urlencoded) — а наш реальный FastAPI-эндпоинт из блог-проекта ожидает JSON в теле запроса, а не form-urlencoded — сервер ответит ошибкой. Это не баг сегодняшнего урока, а совершенно ожидаемое и правильное наблюдение: нативная отправка формы прекрасно работает для классических серверных приложений (как раз то, что мы будем делать в Django), но не подходит напрямую для JSON-based REST API вроде нашего FastAPI-блога.

Именно поэтому в JS-модуле, в теме про асинхронность, мы вернёмся к этой самой форме и заменим нативную отправку на перехват события submit, сбор значений полей через JavaScript, и отправку их в виде JSON через fetch — то есть именно так, как этого ожидает уже написанный вами API. Разметка, name-атрибуты и стили, которые мы сделали сегодня, останутся полностью без изменений — изменится только способ, которым данные покидают форму.


Компоновка формы: вертикальный ритм и адаптивность

Одно правило вёрстки форм стоит закрепить отдельно: единообразные отступы между группами полей сильно влияют на восприятие формы как аккуратной и профессиональной. В примере выше это достигается одним gap: 20px на .post-form (напомним, display: flex; flex-direction: column) — все группы полей автоматически получают одинаковый отstep между собой, без необходимости вручную выставлять margin-bottom каждой группе.

Поскольку у нас mobile-first подход (Урок 12), форма и так по умолчанию — одна колонка на весь доступный max-width, что удобно на любом экране. Если бы часть полей формы нужно было расположить в одну строку на широком экране (например, "Имя" и "Фамилия" рядом на десктопе, но друг под другом на мобильном) — решение уже знакомо:

.field-row {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

@media (min-width: 600px) {
  .field-row {
    flex-direction: row;
  }

  .field-row .field-group {
    flex: 1;
  }
}

Подводные камни

Забытый name

<!-- Поле не будет отправлено вообще -->
<input type="text" placeholder="Комментарий" />

Самая частая причина "форма как будто ничего не отправляет" у новичков — не в JavaScript и не в сервере, а в банально забытом атрибуте name у одного из полей.

Метод GET для чувствительных данных

<!-- Опасно: пароль окажется в адресной строке, истории браузера, логах сервера -->
<form action="/login" method="GET">
  <input type="password" name="password" />
</form>

Формы с паролями, персональными данными, платёжной информацией — всегда POST, без исключений.

Одинаковый name у логически разных чекбоксов

<!-- При обычной отправке формы браузер отправит только одно значение с этим именем -->
<input type="checkbox" name="category" value="films" checked />
<input type="checkbox" name="category" value="cartoons" checked />

Если нужно отправить несколько независимо выбранных значений с одним и тем же смысловым полем, используйте соглашение name="category[]" (если сервер его поддерживает) либо присваивайте разные имена, как в примере с фильтрами выше.

Забытый enctype при загрузке файла

<!-- Файл не будет передан корректно -->
<form action="/upload" method="POST">
  <input type="file" name="avatar" />
</form>

Без enctype="multipart/form-data" бинарное содержимое файла не сможет быть корректно закодировано в теле запроса вместе с остальными полями.

Ожидание, что нативная форма отправит JSON

Как мы разобрали в разделе 6 — обычная HTML-форма без JavaScript отправляет данные в формате application/x-www-form-urlencoded (для POST) или через query string (для GET), а не в формате JSON. Если сервер — это JSON-based API (как наш FastAPI-блог), нативная отправка формы не сработает "напрямую" без дополнительного слоя JavaScript, который преобразует данные формы в JSON перед отправкой. Это не ошибка вёрстки — а следствие того, что HTML-формы появились задолго до JSON-based API и изначально проектировались для другой модели передачи данных.


Итоги урока — и итоги всего HTML/CSS блока

<form> определяет, куда (action) и как (method) уйдут данные при отправке. GET кладёт данные в URL в виде query string — подходит для поиска и фильтрации, не подходит для чувствительных данных. POST передаёт данные в теле запроса — используется для создания, изменения и удаления данных на сервере, а также везде, где фигурируют пароли или другая приватная информация.

Атрибут name — это то единственное, что делает поле видимым для сервера: без него значение поля никогда не попадёт в отправляемые данные, независимо от того, что ввёл пользователь. Для radio группа полей объединяется одинаковым name, для checkbox стоит внимательно выбирать стратегию именования, если предполагается множественный независимый выбор. enctype="multipart/form-data" обязателен, если форма содержит <input type="file">.

Этим уроком мы закрываем базовый HTML/CSS-блок курса. У нас теперь есть полный набор инструментов: семантическая разметка, box-модель, Flexbox и Grid, адаптивная вёрстка, стилизованные поля ввода и кнопки, и, наконец, полноценные работающие формы. Дальше — модуль JavaScript, где мы оживим уже готовую вёрстку: научимся программировать поведение страницы, работать с DOM и, что особенно важно после сегодняшнего урока, перехватывать отправку именно этой формы поста и отправлять её данные через fetch в наш собственный блог-API на FastAPI.


Вопросы для проверки

  1. Что произойдёт при отправке формы, если атрибут method не указан явно?
  2. В чём принципиальная разница между GET и POST с точки зрения того, куда физически попадают данные формы?
  3. Почему нельзя использовать method="GET" для формы логина с паролем?
  4. Что произойдёт, если у поля input отсутствует атрибут name?
  5. Как правильно именовать группу радиокнопок и почему?
  6. Зачем нужен атрибут enctype="multipart/form-data" и когда его обязательно указывать?
  7. Почему форма из раздела 6 (/posts/create, метод POST) не сработает "напрямую" с реальным FastAPI-эндпоинтом из блог-проекта, хотя name-атрибуты полей совпадают с полями модели Post?
  8. Что произойдёт, если пользователь снимет все отметки с группы чекбоксов и отправит форму методом GET? Как это отразится на итоговом URL?

Практические задания

Задание 1. Функциональная форма поиска и фильтров (GET)

Возьмите поле поиска (Урок 13) и блок фильтров (Урок 13) на странице фильмов и оформите их как две полноценные <form> с методом GET:

  1. Форма поиска отправляется на /films, поле поиска имеет name="q".
  2. Форма фильтров отправляется на /films, каждый чекбокс категории имеет собственное уникальное имя (category_films, category_series, category_cartoons), чтобы независимо друг от друга участвовать в query string.
  3. Проверьте в браузере (без реального сервера, просто открыв HTML-файл локально): после нажатия кнопки отправки посмотрите на итоговый адрес в адресной строке — какие параметры туда попали.
  4. Убедитесь, что если чекбокс не был отмечен, соответствующий параметр отсутствует в итоговом URL.

Задание 2. Полноценная форма добавления поста (POST)

Соберите форму создания поста для блог-проекта (python-sql-fastapi) с нуля, используя стили input/button из Уроков 13–14:

  1. Поля: title (текст, required, minlength="3"), content (textarea, required), author_id (число, required, min="1").
  2. Метод — POST, action="/posts/create".
  3. Оформите визуально: единый вертикальный ритм между группами полей, стилизованные состояния :focus-visible и :invalid:not(:placeholder-shown) для всех полей.
  4. Кнопка отправки — .btn--primary, type="submit".
  5. Добавьте под формой пояснительный текст (обычный <p>, не часть формы): "При отправке этой формы сейчас произойдёт перезагрузка страницы — в модуле JS мы научимся отправлять эти же данные без перезагрузки."

Задание 3. Объединяющая практика — вся страница фильмов с рабочими формами

Соберите итоговую версию страницы фильмов (Уроки 11–15) с тремя работающими формами:

  1. Форма поиска в header (GET, Задание 1).
  2. Форма фильтров в aside (GET, Задание 1).
  3. Форма добавления поста/рецензии в main или на отдельном блоке страницы (POST, Задание 2) — представим, что это форма для добавления рецензии на фильм, физически похожая на форму создания поста в блоге.
  4. Убедитесь, что вся страница остаётся адаптивной (медиа-запросы Урока 12) — обе GET-формы и POST-форма корректно выглядят и на мобильном, и на десктопе.
  5. Проверьте всю страницу на доступность: у каждого поля есть <label> (или aria-label, если подписи скрыты визуально), фокус видим на каждом интерактивном элементе при переходе по Tab.

Предыдущий урок | Следующий урок