Інженерні історії · 02
Стан комерції без фронтенд-фреймворку
- Vanilla JS
- AJAX Cart
Як утримати три DOM-роз'єднані компоненти buy box у ціновій узгодженості та зберегти кошик у роботі, поки сторонній застосунок переписує 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, американському wellness-магазині, сторінка товару й адверторіальні лендинги несуть однакову поверхню купівлі: обрати набір (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 немає, ціна одна
Селектор набору, перемикач типу покупки й кнопка додавання в кошик це три окремі snippets, кожен зі своїм ізольованим {% style %}, власною розміткою і власним самодостатнім IIFE. Вони не мають спільного вузла DOM і спільного модуля JavaScript. Це навмисно: продавець може прибрати будь-який із них із buy box або переставити їх, не ламаючи інші.
Заковика в тому, що вони все одно мають зійтися на одному числі. Коли покупець натискає «3 банки», селектор набору, кнопка й мобільна липка панель мають відображати ціну за три банки, її перекреслену загальну compare-at ціну і (якщо обрано підписку) ціну підписки за банку. Саме зіставлення плану з набором, яке читає інтервал підписки з тексту плану, а не з захардкоджених 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, ні virtual DOM. Рендерер ціни кнопки просто слухає lum:atc-price і перемальовується; липка панель робить те саме. Стан тече в один бік, як події, і кожен споживач сам вирішує, що з ним робити.
Чому передавання повідомлень, а не фреймворк
Чому не взяти невеликий фреймворк чи хоча б спільний контролер? Середовище це виключає. Це тема на Liquid, яку контент-команда щодня редагує спільно через Customizer, ділячи сторінку під час виконання з Recharge, Shogun, ElevateAB, Klaviyo та стосом застосунків аналітики й пікселів. З цього випливає три речі.
По-перше, сторонні скрипти змінюють DOM, коли їм заманеться. Застосунок зі смужкою прогресу кошика вставляє й видаляє вузли, зокрема й сам 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 за один обмін із сервером:
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, без другого запиту на перерендер. Обробник відкриває drawer прямо з цієї відповіді, з нативним submit() у .catch як запасним варіантом. Це щасливий шлях; уся складність у цій системі в режимах відмови.
Три баги, одна хвороба
Під час випуску buy box виринуло три збої кошика. Вони виглядали не пов'язаними: додалася неправильна кількість, drawer зникав посеред взаємодії, мертва кнопка додавання в кошик. Усі три звелися до однієї першопричини.
Хвороба: пошуки по DOM на рівні всього документа на сторінці, яка несе більш ніж одну форму товару й більш ніж одну .shopify-section, поки сторонній застосунок безкоштовного подарунка й смужки прогресу змінює drawer. На звичайній темі в стилі Dawn з однією формою товару на сторінку document.querySelector('input[name="id"]') це нормально. На цій темі, де співіснують buy box, прихована форма безкоштовного подарунка й власний хедер, той самий запит це прихований баг. Кожне з трьох виправлень це одна й та сама ідея, застосована в іншому місці: перестань довіряти всьому документу, обмеж область до елемента, який справді маєш на увазі.
Баг 1: неправильна форма (PR #62)
На лендингах тема рендерить другу, приховану free-product-form для своєї механіки подарунка. Ця форма несе власну input[name="id"] із заглушкою value="0". Селектор набору записував обраний variant у форму через querySelector('input[name="id"]') на рівні всього документа, який хапав ту форму, що йшла в DOM першою. Іноді це була прихована форма подарунка. Покупець обирав «3 банки», variant потрапляв не в ту форму, і справжній buy box відправляв свій початковий variant на одну банку. Додавання або клало в кошик одну банку замість трьох, або зривалося й відкочувалося до нативної відправки, а це та затримка й втрата стилізації кнопки, про які повідомляв клієнт.
Виглядає це як баг у коді, випадковий селектор; комерційно це баг конверсії. Покупець, який має намір купити три банки й непомітно отримує одну, це менше замовлення, збентежений клієнт і, ймовірно, звернення в підтримку. Виправлення спершу обмежує кожне читання й запис власною формою 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. На стандартній темі хедер має #cart-icon-bubble, тож гілка з id перемагає, а селектор ніколи не використовується. Але власний хедер 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, вважай ціль відсутньою й пропусти її. Секція, якій немає місця на цьому власному хедері, просто не рендериться, і це правильно. Я застосував той самий запобіжник у шляхах оновлення кількості й видалення безкоштовного товару, бо обидва обходили той самий список.
Баг 3: мертва кнопка (PR #63)
Третій збій був страхувальною сіткою для другого. Коли покупець спорожняє кошик зсередини drawer, сторонній застосунок безкоштовного подарунка й смужки прогресу повністю видаляє елемент <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() читає налаштований variant подарунка й доларовий поріг з власних вузлів DOM застосунку, перевіряє кошик через /cart.js і додає рівно один подарунок лише тоді, коли є платний товар, поріг досягнуто, а подарунка ще немає. Важливі дві деталі. Він додає подарунок як разовий line item без 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 має headless-стенд для тестів: скрипти puppeteer-core, які керують чистим системним Chrome без розширень. audit.js проганяє повну матрицю buy box: кожен набір проти підписки й разової покупки, синхронізація форм, зіставлення планів, ціни перевірені до цента, додавання в кошик, видалення й повторне додавання, і стилізація кнопки. qty.js, removebug.js і cartpage.js покривають шляхи drawer, у яких жили три баги: зміни кількості не мають вбивати drawer, спорожнений кошик має приймати повторне додавання.
Три підступні моменти перевірки з цього магазину варто передати далі, бо кожен породжує тест, який бреше:
- Забруднений профіль браузера ховає баг. Мій власний профіль Chrome має розширення, яке примусово ставить drawer кошика в
visibility:hidden. Функціональні тести, запущені там, проходять, тоді як справжній drawer зламаний. Стенд використовує чистий headless Chrome саме з цієї причини. - CDN мініфікує JS теми, тож коментарі не доказ деплою. Розгорнутий файл менший за вихідний код у репозиторії, бо CDN прибирає коментарі й пробіли. Щоб підтвердити, що виправлення випустили, я через grep шукаю в живому файлі строковий літерал, який має пережити мініфікацію, наприклад запобіжник
!== ".shopify-section", а не коментар, що його пояснює. - Інтенсивне тестування cart API натикається на обмежувач частоти. Шквал запитів до
/cart/addі/cart/changeз однієї IP-адреси викликає Shopify 429 з приблизно 15-30-хвилинною перервою. Ритм тестів має це враховувати, що своєю чергою визначає, як матриця розбивається на пакети.
Результат
Кожне виправлення випустили як ізольований, оборотний pull request, повторно перевірений у чистому headless Chrome. Перевірені технічні результати:
- Розсинхрон кількості через неправильну форму закрито. Вибір набору записує правильний variant у власну форму 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 plan і зіставлення плану з набором, яке забезпечує цей buy box. Стаття 03 розглядає моніторинг реальних користувачів і роботу зі зсувом макета на тих самих сторінках.
Потрібен інженер Shopify, який працює на всьому комерційному шляху?
Я працюю там, де сходяться UX вітрини, логіка товару, обмеження платформи, вимірювання та релізи в продакшн.
Почати розмову