AJAX-форма обратной связи — задача, которую на Битриксе решают в первый месяц работы с системой и почти всегда одинаково: находят пример в интернете, копируют, оно работает. Проблемы появляются позже — когда в инфоблоке заявок оказывается тысяча спам-записей, а сообщение об ошибке в форме однажды выводит то, что прислал пользователь.
Разберём рабочую реализацию и то, чего в скопированных примерах обычно нет.
Зачем BX.ajax, если есть fetch
Честный ответ: если вы пишете фронтенд с нуля и не трогаете компоненты Битрикса, fetch вам подойдёт лучше — он проще, у него нормальные промисы и он не тянет за собой ядро BX.
BX.ajax выигрывает в другом сценарии: когда ответ сервера содержит вёрстку с компонентами Битрикса. Он умеет выполнять пришедшие в ответе скрипты и подгружать объявленные расширения — то, что при работе через fetch придётся делать руками. Плюс на большинстве проектов ядро BX на странице уже есть, так что дополнительной загрузки не возникает.
Что важно понимать сразу: BX.ajax не добавляет CSRF-защиту автоматически. Токен нужно прикладывать самому, а на сервере — проверять. Это делается в две строки, но пропускается в девяти самописных обработчиках из десяти.
Базовый пример
HTML-форма
<form id="callback-form" class="callback-form">
<input type="text" name="name" placeholder="Ваше имя" required />
<input type="tel" name="phone" placeholder="Телефон" required />
<textarea name="message" placeholder="Сообщение"></textarea>
<button type="submit">Отправить</button>
<div class="form-message"></div>
</form>JavaScript (BX.ajax)
BX.ready(function() {
var form = BX('callback-form');
BX.bind(form, 'submit', function(e) {
e.preventDefault();
var formData = new FormData(form);
var messageEl = form.querySelector('.form-message');
var submitBtn = form.querySelector('button[type="submit"]');
// Блокируем кнопку
submitBtn.disabled = true;
submitBtn.textContent = 'Отправка...';
BX.ajax({
url: '/local/ajax/callback.php',
data: formData,
method: 'POST',
dataType: 'json',
processData: false, // Важно для FormData!
preparePost: false, // Важно для FormData!
onsuccess: function(response) {
if (response.success) {
messageEl.innerHTML = '<span class="success">Заявка отправлена!</span>';
form.reset();
} else {
messageEl.innerHTML = '<span class="error">' + response.error + '</span>';
}
},
onfailure: function() {
messageEl.innerHTML = '<span class="error">Ошибка соединения</span>';
},
onfinish: function() {
submitBtn.disabled = false;
submitBtn.textContent = 'Отправить';
}
});
});
});Важно: При использовании FormData обязательно установите processData: false и preparePost: false. Иначе данные будут повреждены.
PHP-обработчик
Обработчик ниже — учебный минимум, в прод его в таком виде выкладывать нельзя. В нём нет ни проверки CSRF-токена, ни защиты от автоматических отправок. Публичный эндпоинт, который пишет в инфоблок и шлёт почту, спамботы находят за считанные дни — дальше в статье оба недостающих куска.
<?php
// /local/ajax/callback.php
require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
use Bitrix\Main\Application;
use Bitrix\Main\Web\Json;
header('Content-Type: application/json; charset=UTF-8');
// Проверка метода
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo Json::encode(['success' => false, 'error' => 'Method not allowed']);
die();
}
// Получение данных
$request = Application::getInstance()->getContext()->getRequest();
$name = trim($request->getPost('name') ?? '');
$phone = trim($request->getPost('phone') ?? '');
$message = trim($request->getPost('message') ?? '');
// Валидация
$errors = [];
if (empty($name)) {
$errors[] = 'Укажите имя';
}
if (empty($phone)) {
$errors[] = 'Укажите телефон';
} elseif (!preg_match('/^[\d\s\(\)\-\+]{10,18}$/', $phone)) {
$errors[] = 'Некорректный формат телефона';
}
if (!empty($errors)) {
echo Json::encode([
'success' => false,
'error' => implode(', ', $errors),
]);
die();
}
// Обработка заявки
try {
// Сохранение в инфоблок
\Bitrix\Main\Loader::includeModule('iblock');
$el = new \CIBlockElement();
$elementId = $el->Add([
'IBLOCK_ID' => CALLBACK_IBLOCK_ID,
'NAME' => "Заявка от {$name}",
'ACTIVE' => 'Y',
'PROPERTY_VALUES' => [
'PHONE' => $phone,
'MESSAGE' => $message,
],
]);
if (!$elementId) {
throw new \RuntimeException($el->LAST_ERROR);
}
// Отправка email
\CEvent::Send('CALLBACK_FORM', SITE_ID, [
'NAME' => $name,
'PHONE' => $phone,
'MESSAGE' => $message,
]);
echo Json::encode(['success' => true]);
} catch (\Throwable $e) {
echo Json::encode([
'success' => false,
'error' => 'Ошибка сохранения. Попробуйте позже.',
]);
// Логируем реальную ошибку
AddMessage2Log($e->getMessage(), 'callback_form');
}Отдельно стоит отметить обработку ошибок в catch: пользователю уходит нейтральное «Ошибка сохранения», а реальный текст исключения пишется в лог. Отдавать наружу $e->getMessage() — распространённая привычка, которая на боевом сервере выдаёт структуру инфоблоков, пути к файлам, а иногда и фрагменты SQL.
Расширенный класс для работы с формами
/**
* Класс для AJAX-форм в Битрикс
*/
class BxAjaxForm {
constructor(formSelector, options = {}) {
this.form = typeof formSelector === 'string'
? document.querySelector(formSelector)
: formSelector;
if (!this.form) {
console.error('Form not found:', formSelector);
return;
}
this.options = Object.assign({
url: this.form.action || '/local/ajax/form.php',
method: 'POST',
submitButton: this.form.querySelector('[type="submit"]'),
messageContainer: this.form.querySelector('.form-message'),
loadingClass: 'is-loading',
successClass: 'is-success',
errorClass: 'is-error',
resetOnSuccess: true,
onBeforeSubmit: null,
onSuccess: null,
onError: null,
onComplete: null,
}, options);
this.init();
}
init() {
BX.bind(this.form, 'submit', this.handleSubmit.bind(this));
}
handleSubmit(e) {
e.preventDefault();
// Callback перед отправкой
if (typeof this.options.onBeforeSubmit === 'function') {
const shouldContinue = this.options.onBeforeSubmit(this.form);
if (shouldContinue === false) return;
}
this.setLoading(true);
this.clearMessage();
const formData = new FormData(this.form);
// Добавляем CSRF-токен если есть
const sessid = BX.bitrix_sessid();
if (sessid) {
formData.append('sessid', sessid);
}
BX.ajax({
url: this.options.url,
data: formData,
method: this.options.method,
dataType: 'json',
processData: false,
preparePost: false,
onsuccess: this.handleSuccess.bind(this),
onfailure: this.handleError.bind(this),
});
}
handleSuccess(response) {
this.setLoading(false);
if (response.success) {
this.showMessage(response.message || 'Успешно отправлено', 'success');
if (this.options.resetOnSuccess) {
this.form.reset();
}
if (typeof this.options.onSuccess === 'function') {
this.options.onSuccess(response, this.form);
}
} else {
this.showMessage(response.error || 'Произошла ошибка', 'error');
// Подсветка ошибочных полей
if (response.fields) {
this.highlightErrors(response.fields);
}
if (typeof this.options.onError === 'function') {
this.options.onError(response, this.form);
}
}
if (typeof this.options.onComplete === 'function') {
this.options.onComplete(response, this.form);
}
}
handleError(error) {
this.setLoading(false);
this.showMessage('Ошибка соединения с сервером', 'error');
if (typeof this.options.onError === 'function') {
this.options.onError({ error: 'Network error' }, this.form);
}
}
setLoading(isLoading) {
if (this.options.submitButton) {
this.options.submitButton.disabled = isLoading;
}
this.form.classList.toggle(this.options.loadingClass, isLoading);
}
showMessage(text, type = 'info') {
if (!this.options.messageContainer) return;
// textContent, а не innerHTML: текст приходит с сервера и может
// содержать пользовательский ввод (например, эхо введённого email)
this.options.messageContainer.textContent = text;
this.options.messageContainer.className = 'form-message ' +
(type === 'success' ? this.options.successClass :
type === 'error' ? this.options.errorClass : '');
}
clearMessage() {
if (this.options.messageContainer) {
this.options.messageContainer.innerHTML = '';
this.options.messageContainer.className = 'form-message';
}
// Убираем подсветку ошибок
this.form.querySelectorAll('.field-error').forEach(el => {
el.classList.remove('field-error');
});
}
highlightErrors(fields) {
Object.keys(fields).forEach(fieldName => {
const field = this.form.querySelector(`[name="${fieldName}"]`);
if (field) {
field.classList.add('field-error');
}
});
}
}
// Использование
BX.ready(function() {
new BxAjaxForm('#callback-form', {
url: '/local/ajax/callback.php',
onSuccess: function(response) {
console.log('Form submitted:', response);
// Можно закрыть модалку, показать thank-you page и т.д.
}
});
new BxAjaxForm('#subscribe-form', {
url: '/local/ajax/subscribe.php',
resetOnSuccess: true,
onSuccess: function(response) {
// Отправка цели в метрику
if (window.ym) {
ym(YANDEX_METRIKA_ID, 'reachGoal', 'subscribe');
}
}
});
});Про textContent вместо innerHTML. Сообщение об ошибке часто собирается из пользовательского ввода («Некорректный адрес: значение из поля»). Стоит один раз вставить такое через innerHTML — и форма превращается в готовый вектор XSS. Если разметка в сообщении действительно нужна, экранируйте текст на сервере, а не полагайтесь на то, что «туда всё равно приходит только наш текст».
Обработчик с CSRF-защитой
<?php
// /local/ajax/secure_form.php
require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
use Bitrix\Main\Application;
use Bitrix\Main\Web\Json;
header('Content-Type: application/json; charset=UTF-8');
$request = Application::getInstance()->getContext()->getRequest();
// Проверка CSRF
if (!check_bitrix_sessid()) {
echo Json::encode([
'success' => false,
'error' => 'Сессия истекла. Обновите страницу.',
]);
die();
}
// ... остальная обработкаОтправка файлов
<form id="resume-form" enctype="multipart/form-data">
<input type="text" name="name" required />
<input type="file" name="resume" accept=".pdf,.doc,.docx" />
<button type="submit">Отправить резюме</button>
</form>BX.ready(function() {
new BxAjaxForm('#resume-form', {
url: '/local/ajax/resume.php',
onBeforeSubmit: function(form) {
var fileInput = form.querySelector('[name="resume"]');
if (fileInput.files.length === 0) {
alert('Прикрепите файл резюме');
return false;
}
// Проверка размера (5 MB)
if (fileInput.files[0].size > 5 * 1024 * 1024) {
alert('Файл слишком большой. Максимум 5 MB.');
return false;
}
return true;
}
});
});Проверка файла на клиенте — это про удобство, а не про безопасность. Ограничения по расширению и размеру в JavaScript отсекают честные ошибки пользователя и экономят ему время на загрузке. Атакующий отправит запрос напрямую, минуя вашу форму. Проверять тип и размер на сервере обязательно, причём тип — по реальному содержимому (finfo), а не по расширению и не по присланному Content-Type.
Чек-лист публичной формы
Список, по которому я прохожусь перед выкладкой любой формы, доступной без авторизации:
- CSRF-токен прикладывается и проверяется (
bitrix_sessid()на клиенте,check_bitrix_sessid()на сервере). - Вся валидация продублирована на сервере. Клиентская — только для UX.
- Есть защита от автоматических отправок: honeypot-поле, ограничение частоты по IP или капча на подозрительных попытках. Без этого инфоблок заявок за месяц превращается в свалку.
- Файлы проверяются по содержимому, сохраняются вне DOCUMENT_ROOT или в директории с запретом на выполнение PHP.
- Текст ошибок наружу — нейтральный, детали — в лог.
- Ответ выводится через
textContent, если это не заведомо доверенная разметка. - Кнопка блокируется на время отправки — иначе двойной клик даст две заявки.
И самый практичный совет: прежде чем писать своё, посмотрите на bitrix:main.feedback и веб-формы. В них уже есть валидация, капча и сохранение результатов, а свой обработчик оправдан тогда, когда нужна нестандартная логика — интеграция с CRM, сложные зависимые поля, специфический формат ответа.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.