Инженерные истории · 02
Коммерческое состояние без фронтенд-фреймворка
- Vanilla JS
- AJAX Cart
Как удержать согласованность цены между тремя компонентами buy box, не связанными общим DOM, и как сохранить работоспособность корзины, пока стороннее приложение переписывает drawer, в теме Shopify без фронтенд-фреймворка.
Shopify OS 2.0 · Vanilla JavaScript · AJAX Cart + Sections Rendering API · Root-cause debugging · Puppeteer QA
Часть серии об инженерии витрины Lumway. Модель подписки, которой управляет этот buy box, разобрана в статье 01; работа над производительностью по соседству, в статье 03. Эта статья о состоянии и стабильности: как buy box без фреймворка остаётся согласованным и как я свёл три отдельных сбоя корзины в продакшене к одной первопричине.
Buy box, это место, где бренд добавок совершает или теряет продажу. На Lumway, американском магазине товаров для здоровья, страница товара и рекламные лендинги используют один и тот же интерфейс покупки: выбрать набор (1, 2 или 3 банки), выбрать разовую покупку или подписку, увидеть цену и экономию, добавить в корзину. Он занимает мало вертикального места на телефоне, и все части должны быть согласованы. Если карточка набора показывает одну цену, а кнопка другую, или drawer показывает количество, которое покупатель не выбирал, магазин теряет доверие в момент принятия решения.
Я построил этот интерфейс без фронтенд-фреймворка. Эта статья о двух связанных задачах: как удержать три отдельных компонента в согласованном состоянии, используя только собственные примитивы платформы, и как сохранить работоспособность пути добавления в корзину на странице, где стороннее приложение свободно переписывает корзину прямо под ним.
Buy box, это диспетчер блоков, управляемый схемой
Страница товара, это одна секция Online Store 2.0, product-details-lum, заменяющая нативный шаблон товара темы. Раскладка, это двухколоночный flex: галерея слева, колонка покупки справа. Правая колонка, это не фиксированный шаблон, а диспетчер над блоками, которые определяет продавец:
{% for block in section.blocks %}
{% case block.type %}
{% when 'bundle' %} {% render 'bundle-selector-lum', block: block, product: pdl_product %}
{% when 'purchase_type' %}{% render 'purchase-type-lum', block: block, product: pdl_product %}
{% when 'add_to_cart' %} {% render 'add-to-cart-lum', block: block, product: pdl_product %}
...
{% endcase %}
{% endfor %}
Контент-команда может менять порядок блоков, добавлять или удалять их (rating, title, labels, bundle, purchase_type, add_to_cart, info_cards, cross_sell, product_tabs, плюс @app для совместимости с приложениями) из Customizer. В предложении ничто не захардкожено: каждый видимый элемент отображается на настройку схемы или блок.
Одна строка ближе к началу секции делает всё это переносимым:
assign pdl_product = product | default: section.settings.product
На шаблоне товара секцией управляет собственный product страницы. На рекламной странице, где у Liquid нет product в области видимости, секция откатывается к настройке выбора товара, которую задаёт продавец. Каждый дочерний snippet рендерится относительно pdl_product, так что идентичный buy box ложится на любой лендинг без дублирования кода: новый рекламный лендинг, это копия шаблона с заменёнными привязками товара. Эта переносимость также порождает проблему состояния, потому что гарантирует, что buy box работает на страницах, несущих другие формы товара, которые ему не принадлежат.
Три компонента, общего DOM нет, цена одна
Селектор набора, переключатель типа покупки и кнопка добавления в корзину, это три отдельных snippet, у каждого свой scoped {% style %}, своя разметка и своя самодостаточная IIFE. Они не делят ни узла DOM, ни JavaScript-модуля. Это сделано намеренно: продавец может убрать любой из них из buy box или изменить их порядок, не сломав остальные.
Загвоздка в том, что они всё равно должны сходиться на одном числе. Когда покупатель нажимает «3 банки», селектор набора, кнопка и мобильная закреплённая панель должны отражать цену за три банки, её зачёркнутую сумму compare-at и (если выбрана подписка) цену подписки за банку. Само сопоставление plan-to-pack, которое читает интервал подписки из текста плана, а не из захардкоженных ID, разобрано в статье 01; здесь компонентам нужно лишь дёшево транслировать свой выбор друг другу.
Делают они это через небольшой явный контракт:
- Один маркер-класс на настоящей форме. Snippet добавления в корзину рендерит единственную подлинную
{% form 'product' %}в buy box, помеченную.product-buy-form-pdp. Этот класс, это единственный источник истины для «формы, которая реально отправляется». - Глобальная переменная window для текущего набора. Селектор набора публикует
window.__lumBundlePackкак{ variantId, total, paid, free }, так что любой компонент может прочитать, какой набор активен и сколько банок он содержит (платные плюс бесплатные), не обходя DOM. - Два собственных события.
variant:changeсрабатывает при смене набора;lum:atc-priceнесёт вычисленную цену и её тип (разовая или подписка) от логики типа покупки к кнопке и закреплённой панели.
Это весь слой состояния. Ни store, ни reducer, ни виртуального DOM. Рендерер цены на кнопке просто слушает lum:atc-price и перерисовывается; закреплённая панель делает то же самое. Состояние течёт в одну сторону, событиями, и каждый потребитель сам решает, что с ним делать.
Почему передача сообщений, а не фреймворк
Почему не взять небольшой фреймворк или хотя бы общий контроллер? Среда это исключает. Это тема на Liquid, которую контент-команда правит ежедневно через Customizer, деля страницу в рантайме с Recharge, Shogun, ElevateAB, Klaviyo и стеком приложений аналитики и пикселей. Из этого следует три вещи.
Во-первых, сторонние скрипты мутируют DOM, когда им вздумается. Приложение с progress-bar корзины вставляет и удаляет узлы, включая сам drawer. Компонент, который считал бы, что владеет страницей, ошибся бы в течение секунды после загрузки.
Во-вторых, Theme Editor перерисовывает секции изолированно. Когда продавец правит блок, Shopify запускает shopify:section:load и подменяет HTML этой секции без полной перезагрузки. Фреймворк, смонтированный один раз при загрузке страницы, потерял бы свои привязки, поэтому каждый компонент слушает это событие и переинициализирует себя.
В-третьих, тайминг враждебный. Скрипты приложений приходят поздно и не по порядку. Поэтому каждая IIFE защищается от двойной привязки маркер-свойством на своём корневом узле и перезапускает свою инициализацию с небольшим разбросом по времени, а не доверяет одному событию готовности:
var root = document.querySelector('.bsl-' + uid);
if (!root || root.__bslBound) return;
root.__bslBound = true;
// ...
[200, 600, 1200, 2500].forEach(function (d) { setTimeout(init, d); });
document.addEventListener('shopify:section:load', function () { userTouched = false; setTimeout(init, 60); });
Слабо связанная передача сообщений подходит этому миру лучше, чем подошёл бы фреймворк. Каждый компонент независимо перезагружаем, терпим к DOM, который не контролирует, и дёшев. Цена, это дисциплина: глобальные переменные и события, это неформальный контракт, не типизированный, поддерживаемый согласованным вручную. Фреймворк дрался бы с Theme Editor и скриптами приложений за контроль над одними и теми же узлами и проиграл бы.
Один запрос, чтобы добавить и перерисовать
Snippet добавления в корзину рендерит настоящую форму товара и закреплённую панель цены, затем добавляет через AJAX, объединяя AJAX Cart API и Sections Rendering API в один round trip:
var fd = new FormData(atcForm);
fd.append('sections', 'cart-drawer,cart-icon-bubble');
fd.append('sections_url', window.location.pathname);
fetch('/cart/add.js', { method: 'POST', headers: {...}, body: fd })
Один POST /cart/add.js возвращает и JSON корзины, и свежесрендеренный HTML для drawer и cart-icon bubble, без второго запроса на перерисовку. Обработчик открывает drawer прямо из этого ответа, с нативным submit() в .catch в качестве запасного варианта. Это счастливый путь; вся длина в этой системе, в режимах отказа.
Три бага, одна болезнь
Пока я выкатывал buy box, всплыли три сбоя корзины. Они выглядели не связанными: добавлялось неверное количество, drawer исчезал посреди взаимодействия, мёртвая кнопка добавления в корзину. Все три свелись к одной первопричине.
Болезнь: поиски по DOM в масштабах всего документа на странице, которая несёт больше одной формы товара и больше одной .shopify-section, пока стороннее приложение подарков и progress-bar мутирует drawer. На обычной теме в стиле Dawn с одной формой товара на страницу document.querySelector('input[name="id"]') в порядке. На этой теме, где сосуществуют buy box, скрытая форма подарка и собственный header, тот же запрос, это скрытый баг. Каждое из трёх исправлений, это одна и та же идея, применённая в другом месте: перестать доверять всему документу, ограничиться тем элементом, который действительно имеешь в виду.
Баг 1: не та форма (PR #62)
На лендингах тема рендерит вторую, скрытую free-product-form для своей механики подарка. Эта форма несёт собственный input[name="id"] с плейсхолдером value="0". Селектор набора записывал выбранный вариант в форму через querySelector('input[name="id"]') в масштабах всего документа, который захватывал ту форму, что шла в DOM первой. Иногда это была скрытая форма подарка. Покупатель выбирал «3 банки», вариант уходил не в ту форму, и настоящий buy box отправлял свой исходный вариант с одной банкой. Добавление либо клало в корзину одну банку вместо трёх, либо падало и откатывалось к нативной отправке, что и есть та задержка и потеря стилей кнопки, о которых сообщал клиент.
Выглядит как баг в коде, случайный селектор; коммерчески это баг конверсии. Покупатель, который намерен купить три банки и молча получает одну, это заказ поменьше, сбитый с толку клиент и, вероятно, тикет в поддержку. Исправление ограничивает каждое чтение и запись сначала собственной формой buy box, с запасными вариантами, деградирующими в безопасном порядке:
function variantInput() {
return document.querySelector('.product-buy-form-pdp input[name="id"]')
|| document.querySelector('product-form input[name="id"]')
|| document.querySelector('form[action*="/cart/add"] input[name="id"]')
|| document.querySelector('input[name="id"]');
}
Маркер-класс идёт первым; запрос в масштабах всего документа выживает лишь как крайняя мера, которую конкретные селекторы почти наверняка перехватили заранее.
Баг 2: drawer, который уничтожал сам себя (PR #64)
Этот был настоящим корнем, и на его поиск ушло больше всего времени, потому что симптом был таким странным. Изменение количества у line item в drawer корзины, плюс или минус, иногда полностью стирало drawer.
JavaScript корзины после /cart/change перерисовывает список секций, разрешая каждую цель как «найти по id, иначе найти по селектору». Для секции cart-icon-bubble селектором был обобщённый .shopify-section. На стоковой теме у header есть #cart-icon-bubble, так что ветка с id побеждает и селектор не используется никогда. Но у собственного header Lumway, lhead, такого узла нет. Поиск по id промахивался, срабатывал запасной вариант, и document.querySelector('.shopify-section') возвращал первую .shopify-section на странице: собственный хост drawer корзины. Код перезаписывал drawer разметкой cart-icon. Каждый плюс или минус тихо уничтожал drawer и заменял его глифом корзины покупок.
Исправление в том, чтобы никогда не позволять обобщённому селектору подменять конкретную цель:
const elementToReplace =
document.getElementById(section.id) ||
(section.selector && section.selector !== ".shopify-section"
? document.querySelector(section.selector)
: null);
if (!elementToReplace) return;
Разрешать строго по id, затем по конкретному селектору; если этот селектор, это обобщённый .shopify-section, считать цель отсутствующей и пропускать её. Секция, у которой нет места на этом собственном header, просто не рендерится, и это правильно. Ту же защиту я применил в путях обновления количества и удаления бесплатного товара, поскольку оба обходили тот же список.
Баг 3: мёртвая кнопка (PR #63)
Третий сбой был страховкой для второго. Когда покупатель опустошает корзину изнутри drawer, стороннее приложение подарков и progress-bar полностью удаляет элемент <cart-drawer> из DOM. После этого кнопка добавления в корзину казалась мёртвой. Клик по ней не давал ничего видимого.
Причина была неочевидной. Обработчик отправки искал drawer в момент клика. Не найдя, он выходил рано, до вызова preventDefault(), и кнопка выглядела замороженной. Покупатель не мог добавить в корзину на странице, где корзину только что опустошили. В магазине, где повторное добавление после пустой корзины, это совершенно нормальный путь, это потерянные заказы.
Исправление меняет момент принятия решения. Возможности страницы фиксируются один раз, в момент привязки, а не перепроверяются в момент клика, когда приложение могло изменить DOM:
var pageHasDrawer = !!document.querySelector('cart-drawer');
atcForm.addEventListener('submit', function (e) {
if (!pageHasDrawer) return; // page genuinely never had a drawer -> native POST
e.preventDefault();
// ... always AJAX-add ...
});
Если у страницы был drawer в момент привязки скрипта, обработчик всегда предотвращает действие по умолчанию и добавляет через AJAX. Если drawer исчез к моменту возврата ответа, код перестраивает его из payload секций /cart/add.js, а не предполагает, что тот всё ещё существует:
function openDrawerWith(data) {
var drawer = document.querySelector('cart-drawer');
if (drawer && typeof drawer.renderContents === 'function') { drawer.renderContents(data); return; }
var host = document.getElementById('shopify-section-cart-drawer');
var html = data && data.sections && data.sections['cart-drawer'];
if (host && html) { host.innerHTML = html; /* re-open the fresh drawer */ }
}
Sections Rendering API делает восстановление возможным: поскольку ответ на добавление уже несёт срендеренный HTML drawer, я могу заново материализовать drawer, удалённый другим приложением, без перезагрузки страницы.
Бесплатный подарок, добавленный с защитой
Бесплатное цифровое руководство, это небольшая иллюстрация того же защитного подхода. Встроенный механизм подарка темы не может сработать для этого buy box: его форма подарка рендерится только на нетоварных шаблонах, а собственный buy box использует собственный путь AJAX. Поэтому snippet добавления в корзину прикрепляет подарок сам.
ensureFreeGift() читает настроенный вариант подарка и долларовый порог из собственных DOM-узлов приложения, проверяет корзину через /cart.js и добавляет ровно один подарок только если есть платный товар, порог достигнут и подарок ещё не присутствует. Важны две детали. Он добавляет подарок как разовую строку без selling_plan, так что Recharge никогда не превращает бесплатный товар в повторяющееся списание. И это по принципу best-effort, обёрнуто так, что сбой разрешается в null и никогда не блокирует основное добавление:
return fetch('/cart/add.js', { method: 'POST', /* ... */
body: JSON.stringify({ items: [{ id: Number(giftId), quantity: 1 }],
sections: 'cart-drawer,cart-icon-bubble', sections_url: window.location.pathname })
}).then(r => r.ok ? r.json() : null).catch(() => null);
Подарок, это приятное дополнение; платное добавление, это выручка. Код отражает этот приоритет: подарок может тихо упасть, добавление товара, нет. (Ценообразование с $1 до $0 и невидимость в фулфилменте, это операционная работа, которая была согласована, но не полностью выкачена, так что это паттерн устойчивости, а не готовая функция.)
Проверка в чистой комнате
Баги состояния и корзины, это ровно тот тип, который пропускает быстрая ручная проверка и на который натыкается реальный покупатель. Поэтому у buy box есть безголовый тестовый стенд: скрипты puppeteer-core, управляющие чистым системным Chrome без расширений. audit.js прогоняет полную матрицу buy box: каждый набор против подписки и разовой покупки, синхронизация форм, сопоставление планов, цены, проверенные до цента, добавление в корзину, удаление и повторное добавление, а также стилизация кнопки. qty.js, removebug.js и cartpage.js покрывают пути drawer, в которых жили три бага: изменения количества не должны убивать drawer, опустошённая корзина должна принимать повторное добавление.
Три подвоха проверки из этого магазина стоит передать дальше, потому что каждый порождает тест, который врёт:
- Грязный профиль браузера прячет баг. В моём собственном профиле Chrome есть расширение, которое принудительно ставит drawer корзины в
visibility:hidden. Функциональные тесты, запущенные там, проходят, пока реальный drawer сломан. Стенд использует чистый безголовый Chrome ровно по этой причине. - CDN минифицирует JS темы, так что комментарии, это не доказательство деплоя. Живой ассет меньше исходника в репозитории, потому что CDN срезает комментарии и пробелы. Чтобы подтвердить, что исправление выкачено, я грепаю живой файл на строковый литерал, который обязан пережить минификацию, например защиту
!== ".shopify-section", а не комментарий, объясняющий его. - Интенсивное тестирование cart API задевает ограничитель частоты запросов. Долбёжка
/cart/addи/cart/changeс одного IP вызывает у Shopify 429 с откатом примерно от 15 до 30 минут. Ритм тестов должен это учитывать, что в свою очередь определяет, как батчится матрица.
Результат
Каждое исправление вышло отдельным, откатываемым pull request, перепроверенным в чистом безголовом Chrome. Проверенные технические результаты:
- Рассинхрон количества из-за не той формы закрыт. Выбор набора записывает правильный вариант в собственную форму buy box, что подтверждено на карточках набора, кнопке и закреплённой панели через
audit.jsна PDP и лендингах. - Drawer больше не самоуничтожается при изменениях количества, потому что ни один рендер секции не может откатиться к обобщённой цели
.shopify-section. - Добавление в корзину переживает опустошённую корзину. Возможность решается один раз в момент привязки, добавление всегда идёт через AJAX, а drawer, удалённый приложением, перестраивается из ответа на добавление.
Цифры конверсии и выручки магазина принадлежат бренду и находятся под NDA, так что здесь я держусь инженерии: сбои воспроизведены, найдены первопричины, исправлены и перепроверены.
Почему это важно коммерчески
Путь корзины, это путь выручки, и эти сбои были худшего вида: тихие. Ни один не бросал очевидной ошибки. Покупатель получал одну банку вместо трёх, смотрел, как drawer моргнул и исчез, или сдавался с кнопкой, которая выглядела замороженной, и уходил. Нет строчки в логе для клиента, который тихо уходит, а в магазине с преимущественно платным мобильным трафиком эта тихая поломка, это деньги. Ограничение области, как выясняется, это коммерческое решение не меньше, чем решение о качестве кода: самые ценные изменения здесь по одной-две строки каждое, и вся их ценность в том сбое, который они предотвращают.
Что я перенёс бы в следующую тему
- Никогда не ставь
preventDefaultв зависимость от живого поиска в DOM, который приложение корзины может удалить. Зафиксируй возможности страницы один раз, в момент привязки, затем держись пути AJAX и перестраивай то, что пропало. - Любой запасной
querySelectorв масштабах всего документа в этой теме под подозрением. На странице с несколькими формами товара и несколькими секциями «найти первый» обычно, это не то намерение. Ограничивайся маркер-классом или id, а обобщённое совпадение пусть будет крайней мерой, которая срабатывает редко. - Передача сообщений побеждает фреймворк, когда ты не владеешь страницей. Глобальные переменные window, собственные события и маркер-класс держали три независимых компонента согласованными, пока Theme Editor и скрипты приложений переписывали DOM вокруг них.
- Тестируй в чистой комнате и проверяй деплой по тому, что переживает минификацию. Грязный профиль браузера и CDN, срезающий комментарии, каждый соврут тебе, если позволишь.
Технические ссылки
sections/product-details-lum.liquid: buy box как диспетчер блоков и строка переносимостиpdl_product.snippets/bundle-selector-lum.liquid: селектор набора,window.__lumBundlePack, исправлениеvariantInput()с ограничением по форме (PR #62).snippets/add-to-cart-lum.liquid: настоящая форма товара (.product-buy-form-pdp), добавление через AJAX плюс Sections Rendering, захватpageHasDrawerи перестройка drawer (PR #63),ensureFreeGift().assets/cart.js: защищённое разрешение секций вupdateQuantityиremoveFreeProduct(PR #64).snippets/free-product-form.liquid: скрытая вторая форма (value="0"), в которую PR #62 перестал писать.~/Desktop/lumway-qa: стенд наpuppeteer-core(audit.js,qty.js,removebug.js,cartpage.js) и его заметки по проверке.
По теме: статья 01 разбирает нативную модель подписки на Selling Plans и сопоставление plan-to-pack, которым управляет этот buy box. Статья 03 разбирает мониторинг реальных пользователей и работу над сдвигами макета на тех же страницах.
Нужен инженер Shopify, который работает по всему коммерческому пути?
Я работаю там, где сходятся UX витрины, продуктовая логика, ограничения платформы, измерения и доставка в продакшн.
Начать разговор