Обновление количества товара в корзине Битрикс без перезагрузки страницы решается одним из двух способов: либо штатным ajax-механизмом компонента sale.basket.basket, либо своим обработчиком на API Bitrix\Sale\Basket, когда корзина живёт вне стандартного шаблона. На практике чаще сталкиваюсь именно со вторым случаем: заказчик просит кастомный виджет корзины в шапке, мобильное приложение или SPA-фронт, и штатная логика компонента до них просто не дотягивается. Ниже разбираю оба варианта и показываю, где обычно ломается синхронизация счётчика и суммы.
Как штатно работает изменение количества в компоненте корзины
Страница корзины на bitrix:sale.basket.basket с шаблоном .default в актуальных версиях платформы не перезагружается целиком при смене количества в поле ввода. Скрипт шаблона перехватывает изменение значения, отправляет запрос через собственный компонентный action и обновляет только строку товара и итоговую сумму заказа. Это встроенное поведение, а не то, что нужно писать с нуля.
Проблема в другом: этот механизм живёт внутри конкретного экземпляра компонента. Если у вас кастомный шаблон корзины, отдельный виджет в шапке сайта (bitrix:sale.basket.basket.small), мобильное приложение через REST или SPA-фронт на отдельном домене, они не в курсе внутренней логики другого экземпляра компонента. Каждый компонент на странице обновляет только себя. Отсюда и берутся типичные жалобы «в корзине сумма поменялась, а счётчик в шапке нет».
Поэтому дальше разбираю случай, который встречается чаще всего в моей практике: нужен свой ajax-обработчик, который работает независимо от конкретного шаблона компонента и одинаково пригоден и для десктопной страницы, и для мобильного виджета.
Свой AJAX-обработчик на Bitrix\Sale\Basket
Для обработчика использую D7 API модуля sale, а не старый CSaleBasket. Он даёт нормальную валидацию количества, скидочные правила пересчитываются автоматически при сохранении, и не нужно вручную дергать пересчёт заказа.
<?php
require_once($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Sale\Basket;
use Bitrix\Sale\Fuser;
header('Content-Type: application/json; charset=UTF-8');
if (!check_bitrix_sessid()) {
echo json_encode(['success' => false, 'error' => 'Неверный sessid']);
die();
}
if (!Loader::includeModule('sale')) {
echo json_encode(['success' => false, 'error' => 'Модуль sale не подключен']);
die();
}
$itemId = (int)($_POST['id'] ?? 0);
$quantity = (float)($_POST['quantity'] ?? 0);
if ($itemId <= 0 || $quantity <= 0) {
echo json_encode(['success' => false, 'error' => 'Некорректные параметры']);
die();
}
$siteId = Context::getCurrent()->getSite();
$basket = Basket::loadItemsForFUser(Fuser::getId(), $siteId);
$item = $basket->getItemById($itemId);
if (!$item) {
echo json_encode(['success' => false, 'error' => 'Товар не найден в корзине']);
die();
}
$result = $item->setField('QUANTITY', $quantity);
if (!$result->isSuccess()) {
echo json_encode(['success' => false, 'error' => implode(', ', $result->getErrorMessages())]);
die();
}
$basket->save();
$basketSum = 0;
$basketQuantity = 0;
foreach ($basket as $basketItem) {
$basketSum += $basketItem->getPrice() * $basketItem->getQuantity();
$basketQuantity += $basketItem->getQuantity();
}
echo json_encode([
'success' => true,
'itemSum' => $item->getPrice() * $item->getQuantity(),
'basketSum' => $basketSum,
'basketQuantity' => $basketQuantity,
]);
Проверку check_bitrix_sessid() ставлю первой строкой не для галочки. Обработчик меняет данные в БД по POST-запросу, и без проверки токена сессии его можно дергать с чужого сайта поддельной формой. В старом CSaleBasket этот шаг тоже нужен, разница только в наборе методов внутри.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
JS: отправка запроса и обновление DOM без перезагрузки
На фронте вешаю обработчик на изменение поля количества с debounce, чтобы не долбить сервер на каждое нажатие клавиши или клик по стрелочке степпера.
document.querySelectorAll('.basket-item__qty-input').forEach(function (input) {
let timer = null;
input.addEventListener('input', function () {
clearTimeout(timer);
const itemId = input.dataset.itemId;
const quantity = parseFloat(input.value);
if (!quantity || quantity <= 0) {
return;
}
timer = setTimeout(function () {
fetch('/local/ajax/basket_qty.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'id=' + encodeURIComponent(itemId) +
'&quantity=' + encodeURIComponent(quantity) +
'&sessid=' + BX.bitrix_sessid()
})
.then(function (response) { return response.json(); })
.then(function (data) {
if (!data.success) {
console.error(data.error);
return;
}
document.querySelector('[data-item-sum=\"' + itemId + '\"]').textContent = data.itemSum.toFixed(2);
document.querySelector('.basket-total-sum').textContent = data.basketSum.toFixed(2);
document.querySelectorAll('.header-basket-counter').forEach(function (el) {
el.textContent = data.basketQuantity;
});
});
}, 300);
});
});
BX.bitrix_sessid() берёт тот же токен, что проверяется на сервере через check_bitrix_sessid(), поэтому отдельно прокидывать его через скрытое поле не нужно, если main.core уже подключен на странице.
Как синхронизировать счётчик в шапке сайта
Вот тут кроется большинство реальных проблем на проектах. Мини-корзина в шапке (bitrix:sale.basket.basket.small) обычно висит отдельным компонентом на всех страницах сайта, а не только на /personal/cart/. Это отдельный экземпляр со своим кэшем и своей отрисовкой, он не знает о том, что где-то на странице count товара только что поменялся через другой обработчик.
Надёжнее всего не полагаться на внутренние события конкретной версии компонента, а просто вернуть нужные значения прямо в ответе своего же ajax-обработчика, как в примере выше (basketSum и basketQuantity), и обновить DOM-элемент счётчика напрямую по селектору. Это то, что я и делаю в JS-коде: document.querySelectorAll(‘.header-basket-counter’) обновляется в том же callback, без ожидания, что сторонний компонент сам заметит изменение.
Если в шапке используется штатный шаблон компонента bitrix:sale.basket.basket.small с собственной ajax-логикой, а не просто статичная цифра, для полной синхронизации проще всего один раз завести на сайте общий счётчик товаров как отдельный span с фиксированным классом, который обновляют все места, меняющие корзину, а не полагаться на то, что штатный виджет сам подхватит чужое изменение.
Гонка запросов при быстрых кликах по степперу
Если пользователь быстро тыкает плюс на степпере пять раз подряд, уходит пять запросов, и они не гарантированно возвращаются в том порядке, в котором отправлены. Последний по времени ответа может оказаться не последним по факту изменения, и в поле окажется не то число, что реально сохранено в корзине.
Что у меня обычно снимает эту проблему:
- debounce на 250-300 мс перед отправкой запроса, как в примере выше;
- отключение input на время запроса (disabled), чтобы соседний клик не улетел вторым параллельным запросом;
- метка времени в каждом запросе и игнорирование в обработчике ответа, если пришёл более старый timestamp, чем уже отрисованный.
Последний пункT нужен не всегда, но на карточках с несколькими товарами в одной строке (например, таблица позиций в B2B-корзине) без него количество иногда «прыгает» обратно на старое значение при нестабильном интернете у клиента.
Когда штатного AJAX недостаточно и нужна доработка
Со штатным поведением компонента справляется большинство обычных интернет-магазинов на стандартном шаблоне. Свой обработчик и ручная синхронизация счётчика реально нужны в конкретных ситуациях:
- кастомный виджет корзины в шапке или в сайдбаре, отрисованный не через штатный компонент;
- мобильное приложение или PWA, где фронт дергает Bitrix через REST, а не через рендер компонента;
- B2B-корзина со списком из десятков позиций и построчным изменением количества без отправки формы целиком;
- headless-связка, где витрина на отдельном фронтенде, а Битрикс работает только как бэкенд для заказов.
Если задача именно точечная (один обработчик, один виджет), это укладывается в разовую доработку. Если же корзина и весь фронт магазина проектируются заново под такую архитектуру, это уже не патч поверх готового шаблона, а разработка интернет-магазина под ключ, где ajax-логика корзины закладывается сразу в архитектуру, а не прикручивается сбоку.
1С-Битрикс регулярно обновляется, поэтому перед внедрением я сверяюсь с актуальной документацией.
Частые вопросы
Почему при смене количества товара всё равно перезагружается вся страница?
Чаще всего дело в кастомном шаблоне корзины, который переопределяет разметку компонента, но не переносит его JS-логику. Реже - сторонний скрипт или плагин перехватывает submit формы раньше, чем срабатывает штатный обработчик, и браузер уходит в обычную отправку формы. Смотрите вкладку Network в devtools: если при смене количества там вообще нет XHR/fetch-запроса, а сразу идёт полная загрузка документа, значит JS-обработчик на инпут не навешан или падает с ошибкой раньше вызова fetch.
Как обновить счётчик товаров в шапке после смены количества?
Проще и надёжнее всего не полагаться на то, что отдельный компонент мини-корзины сам заметит изменение, а обновлять его DOM-элемент напрямую значениями, которые вернул ваш же ajax-обработчик. Именно так сделано в примере выше: обработчик отдаёт basketQuantity, а JS обновляет все элементы с классом счётчика на странице в том же callback.
Обязательно ли проверять sessid в своём обработчике?
Да. Обработчик меняет данные в базе по POST-запросу от авторизованной сессии, и без проверки check_bitrix_sessid() его может дернуть чужая страница через скрытую форму или скрипт, подменив количество в чужой корзине. Это стандартная защита от CSRF, которую Битрикс даёт из коробки, отключать её ради простоты не стоит.
Что делать, если нужно менять количество сразу в нескольких строках корзины?
Для B2B-таблиц с десятками позиций логичнее принимать в обработчике не один id и quantity, а массив пар id-quantity, в цикле применять setField к каждой найденной позиции корзины и вызывать $basket->save() один раз в конце, а не на каждую строку. Это заметно быстрее, чем слать отдельный запрос на каждое изменённое поле, особенно если пользователь правит сразу пять-шесть позиций перед тем, как уйти со страницы.