Широкий вход, узкий выход: Vue 3 внутри jQuery‑формы без бесконечных циклов
- среда, 29 июля 2026 г. в 00:00:10
Добавить на страницу новые функции, не трогая саму страницу, звучит как противоречие. Но альтернативу мы посчитали командой: полное переписывание заняло бы год, в течение которого страница не получила бы ни одной новой функции. Мост между старым кодом и новым занял два месяца, и строил его уже я один. Расскажу, как этот мост устроен, почему получился несимметричным, где чуть не зациклился и что в нём так и осталось кривым.
Страница — карточка заказа в CRM, ей больше семи лет. Открываешь вёрстку в инспекторе и тонешь: сотня полей, вложенные коллекции позиций, скрытые инпуты, про которые непонятно, кто их читает. Поверх всего этого jQuery — он пересчитывает суммы, дёргает бэкенд за результатом и походя подменяет куски разметки тем HTML, что пришёл в ответ. Всё это работает и приносит деньги, поэтому трогать её по‑настоящему страшно.
Задача выглядела скромно: добавить пару блоков на Vue 3, посчитать кое‑что по позициям заказа и показать подсказку. Ничего такого, чего не делали десятки раз на других страницах.
Но тут план, который я успел себе нарисовать, разошёлся с реальностью. Главное ограничение оказалось вообще не техническим: новые функции должны работать только для части клиентов, для тех, кто попал в определённый сегмент. Остальные обязаны видеть страницу такой же, как вчера, ни пикселем иначе.
Это переворачивает задачу. Если бы требовалось заменить старое новым, можно было бы спорить о сроках и рисках переписывания. Здесь же нужны два поведения одной страницы, работающих одновременно: старый и новый блок существуют бок о бок, а какой из них увидит пользователь, решает один параметр — сегмент клиента. Причём он вычисляется прямо на странице и может смениться по ходу работы, например когда менеджер выберет в заказе другого клиента. Задача, получается, свелась к тому, чтобы две реализации ужились на одной странице.
Первая мысль была простой: компонентам нужны данные заказа (позиции, цены, суммы), а всё это уже есть в форме. Значит, достаточно прочитать её при монтировании, сложить в состояние и отрисовать. Так я и сделал, и работало это ровно до того момента, пока я не попробовал повторить руками то, что с заказом делает реальный менеджер, а не синтетический прогон.
Меняю количество в позиции, а новый блок как ни в чём не бывало показывает старую сумму. Добавляю позицию — блок её будто не замечает, а при смене клиента не происходит вообще ничего.
Причину я понял не сразу, хотя она лежала на поверхности: страница уже реактивна. Просто её реактивность живёт на jQuery‑событиях и прямых правках DOM, а Vue туда не заглядывает. Прочитать форму один раз — всё равно что сфотографировать движущийся объект и удивляться, что фотография не шевелится.
Значит, нужна подписка. И тут выясняется вторая половина задачи: новые блоки не только показывают данные, они их меняют. Пользователь правит параметры позиции, применяет код, подтверждает расчёт, и всё это должно доехать до сабмита Symfony‑формы, иначе изменения просто не сохранятся. То есть связь нужна в обе стороны.
Пока я разбирался с реактивностью, обнаружилась проблема поинтереснее.
Открываю форму в поисках артикула или внешнего кода товара. Полей там щедро, полтора десятка на одну строку позиции: цена, три вида скидки, тип цены, ставка НДС, порядок строки, служебные идентификаторы записи. А вот того, что мне нужно, нет: ни артикула, ни внешнего кода, ни характеристик, ни остатков. Я честно понадеялся, что упустил какое‑то скрытое поле, но нет.
Логика в этом есть: форма хранит то, что уедет на сервер при сохранении. Всё остальное сервер подставил в разметку заранее, при рендере страницы, и в полях формы этому взяться неоткуда.
Но мне нужно было отправлять состав заказа в сервис расчёта, а он оперирует внешними кодами позиций. Хуже того: ответ возвращается тоже с ними. То есть без этих кодов я не могу ни отправить запрос, ни сопоставить ответ со строками формы.
Пришлось строить второй канал данных, параллельный форме:
const ids = collectItemIds(orderForm); // из формы: только id const items = await fetchItems({ id: { in: ids } }); // из API: коды и метаданные
Один запрос на все позиции сразу, плюс запрос самого заказа, пара справочников и собственно расчёт. Данные клиента, к счастью, приезжают вместе с заказом, отдельно их дёргать не пришлось.
До меня не сразу дошло, что из этого следует для архитектуры. А следует вот что: у данных на странице два источника, и роли у них разные (хотя поначалу я валил всё в одну кучу). Ответ API — это то, что вернул сервер на конкретный момент, и оно неизменяемо: метаданные, справочники, результат расчёта. Состояние формы — актуальные правки пользователя; оно меняется постоянно и всегда учитывает то, чего сервер ещё не видел.
Рендер читает из состояния формы. Ответы API дают недостающее и не переписывают то, что пользователь уже изменил. Скажем, результат расчёта ложится только в «свои» поля: производные суммы и идентификаторы. Формулировка простая, но она сняла целый класс споров «а откуда брать вот это значение», а вместе с ними и несколько багов, где правка затиралась пришедшим позже ответом сервера.
Вот к чему я в итоге пришёл:

Каналов между легаси и Vue три, и все устроены по‑разному. Симметрично не получилось, и, как я понял позже, симметрия здесь была бы ошибкой.
Первое направление — от формы к состоянию. Здесь я не стал перечислять поля, за которыми слежу, а повесил один делегированный обработчик на всю форму:
$('#order-form').on('change', (e) => { const name = e.target.name; // order_form[items][0][priceType] const path = parseFieldName(name); // ['items', '0', 'priceType'] if (!path) return; formBridge.setField(path, readValue(e.target)); });
readValue достаёт значение с поправкой на тип элемента: чекбоксы, селекты и радиокнопки читаются по‑разному.
Событие change всплывает, поэтому один обработчик ловит изменения любого поля формы, включая те, которых ещё не существовало в момент подписки. Для формы, где строки добавляются и удаляются на лету, это принципиально.
Имя поля Symfony кодирует путь внутри структуры данных: order_form[items][0][priceType]. Разбираем его в массив ключей и получаем адрес значения в состоянии. Приём не новый, так работает любой сериализатор форм последние лет пятнадцать. И поэтому он хорош: не пришлось изобретать маппинг полей на свойства, соглашение об именовании само по себе даёт готовую схему.
К этому добавились подписки на кастомные jQuery‑события, которые легаси и так рассылал по странице: смена клиента, смена контрагента, изменение состава позиций.
Вход получился широким: состояние принимает всё, о чём форма сообщает событием. Vue‑часть не знает, какие в форме есть поля, и не должна знать. Оговорка тут одна, зато существенная: есть поле, которое не порождает событий вовсе, о нём в конце.
С обратным направлением я сначала пошёл по симметричному пути: раз состояние — источник истины, давайте синхронизировать его обратно в форму целиком.
Ошибку я понял быстро. После первой же полной перезаписи форма повела себя так, будто по ней одновременно кликают десять человек: посыпались легаси‑пересчёты, задёргалась валидация, начали без причины перерисовываться блоки. Причём код тут был ни при чём, порочной оказалась сама идея. Обратная запись в форму — это вмешательство в чужую систему, где каждое записанное поле может запустить чужой пересчёт или сдвинуть валидацию. Писать «на всякий случай» здесь нельзя.
В итоге обратный канал разделился на два, и оба узкие.
Автоматический — четыре поля. Ровно те, которыми владеет новый код и которые обязаны уехать в сабмит:
const DOM_SYNC_PATHS = [ ['calculationId'], ['resultId'], ['code'], ['codeStatus'], ];
За ними стоит watch: изменилось в состоянии, записалось в скрытый инпут; если инпута нет, он создаётся.
Императивный — десять точек. Это прямые вызовы в момент действия пользователя, без всякой подписки: поменял тип цены, ввёл скидку, сбросил значение, применились результаты расчёта по позициям.
syncFieldToDom(['items', index, 'discountAmount'], value);
Разница между каналами смысловая: первый отвечает на вопрос, какими полями формы владеет новый код, а второй — в какой момент этот код имеет право вмешаться.
Соотношение вышло показательным: на входе все поля формы без исключения, на выходе четыре автоматических и десяток ручных. Мост оказался клапаном, а не трубой.
Есть в этом коде одна строка, которая объясняет весь подход лучше остальных.
Пользователь выбирает вариант из списка типов цены. Нужно не только записать новое значение в форму, но и добиться, чтобы старая система пересчитала суммы. Она умеет это годами, и переписывать её ради одной фичи никто не собирался.
syncFieldToDom(['items', index, 'price'], option.price); syncFieldToDom(['items', index, 'priceType'], option.id, { dispatchChange: true });
Флаг dispatchChange диспатчит нативное событие change на инпуте. Легаси‑обработчик, подписанный на форму много лет назад, просыпается и делает свою работу.
Во всём коде это единственное место с таким флагом, и выглядит он как костыль, потому что костылём и является. Но один костыль в известном месте лучше десятка костылей в местах неизвестных.
Здесь и проходит граница между «внедрить Vue в легаси» и «поставить его рядом»: я пришёл ко второму. Новый код не притворяется, что старого нет, и не пытается его заменять. Он знает, где у старого рычаги, и пользуется ими.
Двусторонняя связь порождает петли, и у них обнаружилось три разных природы: эхо между DOM и состоянием, расчёт, кормящий сам себя, и нестабильные ссылки в сравнении объектов. Видов три, а мест, где пришлось ставить защиту, — шесть: у второго вида несколько точек приложения.
Петля первая: эхо. Состояние пишет в DOM, DOM генерирует событие, событие пишет в состояние. Лечится проверкой на равенство прямо в мосте:
const setField = (path, value) => { const current = getByPath(state, path); if (isEqual(current, value)) return; // эхо гасится здесь updateState(setByPath(state, path, value)); };
Три строки, которые обрывают цикл в самой узкой точке: если значение не изменилось, записи нет, а значит нет ни watch, ни всей цепочки за ним.
Петля вторая: расчёт кормит сам себя. Эта живёт целиком внутри Vue и DOM не касается. Расчёт возвращает производные суммы и идентификаторы, и всё это пишется в то же состояние, за которым следит автотриггер пересчёта: посчитали, записали результат, увидели изменение и посчитали снова.
Лечится списком исключений, в котором набралось 24 поля и комментарий для тех, кто придёт после:
// Добавляя в состояние новое поле, которое заполняет расчёт, // обязательно внесите его сюда. Иначе получите цикл. const EXCLUDED_FROM_AUTOTRIGGER = [ 'calculationId', 'resultId', 'appliedRules', 'discountTotal', 'creditTotal', /* ...ещё 19 */ ];
Двадцать четыре поля в списке исключений — такое в статьях обычно не показывают, но что есть, то есть. Некрасиво, требует дисциплины, однако более элегантного решения я не нашёл. Развести «данные пользователя» и «данные расчёта» по двум хранилищам не показалось оправданным: в таблице они рендерятся вперемешку, в одной и той же строке, и объединять их пришлось бы при каждом чтении.
Тот же вид, вторая точка: состав позиций. Исключить его из триггера целиком нельзя: для нового, ещё не сохранённого заказа добавление строки обязано запускать пересчёт. Но расчёт пишет свои результаты в те же самые строки. Выручила строка‑сигнатура: вместо объекта позиций слежу за слепком, собранным только из полей‑входов — идентификатор, количество, цена и ещё семь параметров строки, которые пользователь задаёт руками. Результаты расчёта в слепок не входят и потому не будят пересчёт.
Тот же вид, третья точка: гонка ответов. Пользователь правит форму, пока запрос расчёта ещё летит. Возвращается ответ, посчитанный для старого состава, и затирает то, что человек успел ввести. Лечится счётчиком поколений: каждый запуск расчёта увеличивает счётчик, а вернувшийся ответ пишется в состояние, только если его поколение всё ещё актуально.
const generation = ++calculationGeneration; const result = await calculate(payload); if (generation !== calculationGeneration) return; // ответ устарел, пока летел
Петля третья: нестабильная ссылка. Самая неочевидная, к DOM она отношения не имеет вовсе.
Реинициализация срабатывала без видимой причины: состояние вроде не менялось, а трекер изменений — отдельный механизм, который следит за флагом «есть несохранённые правки», — каждый раз твердил «изменено». Час я подозревал что угодно, от не того watch до реактивности Vue и гонки запросов, только не самое очевидное. Разобрался, когда залогировал объект, который отдаёт getOriginalState, и увидел в консоли два вызова подряд с одинаковым содержимым, но разными ссылками.
// было: новый объект на каждый вызов → трекер сравнивает по ссылке // → видит 'изменение' → реинициализация → по кругу getOriginalState: () => mapOrderToForm(order.value), // стало: кэш по ссылке на исходный объект const cache = new WeakMap(); getOriginalState: () => { const source = order.value ?? defaultOrder.value; // defaultOrder - заготовка if (!source) return null; // для ещё не созданного заказа if (!cache.has(source)) cache.set(source, mapOrderToForm(source)); return cache.get(source); },
На одну строку фикса ушёл час поисков. Обидно, но для таких багов это в порядке вещей.
Шестое место я закрыл позже остальных, когда всё уже работало. Легаси иногда пересобирает состав позиций из DOM целиком и затирает поля, которые только что записал расчёт. Пришлось завести список полей, переживающих пересборку.
Вопрос, который я задал бы сам, читая такую статью: как проверять конструкцию, где половина логики живёт в чужом DOM. Цепочка разобрана на части, и каждая покрыта отдельно.
На легаси‑стороне тестами закрыт парсер имён полей, включая вложенные коллекции с индексами, и сборка состава позиций из разметки. Это чистые функции, тестируются тривиально, и они же самое хрупкое место входного канала: сломается разбор имени, и данные молча перестанут доезжать.
Vue‑сторону разобрал детальнее. Мост, наблюдатели, инициализация: срабатывание на смену клиента, автотриггер пересчёта, слепок строк, запись четырёх полей в скрытые инпуты, оба режима активации и путь с ошибкой. Отдельный кейс — те самые правки формы во время идущего пересчёта, ради которых заведён счётчик поколений.
Чего у меня нет, так это сквозного браузерного теста, который проходит всю цепочку целиком: правка в форме, пробуждение легаси‑пересчёта, возврат в состояние, обновление Vue‑блока. Части проверены, стык проверяется руками. Оправдываться не буду: дыра есть, и я её вижу не хуже читателя.
Ошибки сети отдельной обработки не получили. Если запрос упал, пользователь видит стандартную модалку, ту же, что и на остальных страницах, а блок остаётся с последними известными данными. Это осознанно: заводить отдельный язык ошибок ради нескольких блоков смысла не было.
Web Components. Изоляция, которую они дают, здесь работает против задачи: мне нужен был компонент, который делит состояние со страницей и пишет в её форму, то есть полная противоположность изолированному виджету. Shadow DOM пришлось бы пробивать в обе стороны.
Переписать страницу целиком. Тот самый год из первого абзаца я не выдумал, его действительно считали. Вместе с формой уезжают серверная валидация, контроллер, сборка модели заказа и легаси‑пересчёт, так что получается сквозной проект через весь стек, который фронтовой задачей уже не назовёшь.
Впрочем, срок тут даже не главное: переписывание не решало исходную задачу. Переписанная страница даёт одно поведение для всех, а требовалось два одновременно, и пришлось бы городить внутри неё те же две ветки, только в новом коде и без страховки в виде работающего оригинала. Всё сказанное дальше — архитектура под конкретные вводные, жёсткий срок и сегментацию пользователей; при других вводных я бы выбирал иначе.
Перевести всех клиентов сразу. Постепенное расширение сегмента даёт то, чего не даёт одномоментный переезд: каждая новая группа приходит на код, обкатанный на предыдущей, а откат сводится к правке состава сегмента, без всякого срочного релиза. Платой стала необходимость какое‑то время содержать обе реализации.
Событийная шина вместо общего состояния. Шина плоха не сама по себе: без проекции она хорошо описывает, что произошло, но не отвечает на вопрос, какое значение сейчас, а компоненту при монтировании нужно именно текущее состояние. Связка «шина плюс отдельная проекция» рабочая и дала бы лучший декуплинг. Я выбрал общее состояние, потому что проекция всё равно понадобилась бы, а лишний слой стоил бы времени, которого не было. Это выбор в пользу простоты, к самой шине претензий нет.
Держать состояние формы отдельно от остального. А вот тут решение оказалось лучше, чем я рассчитывал. Состояние легаси‑формы зарегистрировано в общем хранилище как обычная сущность, наравне с состоянием страниц, которые давно переехали на Vue целиком: тот же трекер изменений и флаг «изменено», читается оно так же, как остальные. Компоненты не знают, что за состоянием стоит jQuery. А когда страница когда‑нибудь переедет, менять придётся одну функцию, ту, что достаёт исходное состояние.
Пути к полям — строки без типов. ['items', index, 'discountAmount'] компилятор не проверяет никак: переименуют поле в форме или в состоянии, и всё сломается в рантайме, молча, без ошибки сборки. Если у вас тайпчек держится на нуле ошибок, вы уже поморщились, глядя на эту строку; морщусь и я каждый раз, когда пишу такой путь заново. В отличие от остальных компромиссов в этой статье, у этого нет даже кривого оправдания: генерация типов из схемы формы решила бы вопрос, просто до неё не дошли руки.
Легаси следит за новым кодом. Неприятная находка: в старом файле обнаружился MutationObserver, ожидающий появления блока, который рисует Vue. Трафик через границу идёт не только в две стороны, есть и третья. Это скрытая зависимость: легаси завязан на разметку нашего компонента. Поменяем структуру блока при рефакторинге, и сломается код, который грепом по импортам не найти. Ближайший кандидат на исправление — заменить наблюдение за структурой DOM на явный контракт через data‑атрибут.
Данные грузятся дважды. Сервер уже отрендерил страницу и знает про заказ всё, а я иду и запрашиваю то же самое ещё раз. Это ограничение снимается со стороны бэкенда: достаточно отдавать недостающее прямо в разметке, атрибутами. В этом проекте пока договорились иначе, и лишние запросы при загрузке страницы — цена такой договорённости.
Про статус всего этого скажу прямо: фича только выходит в прод. Механизм отлажен и ведёт себя так, как задумывался, поверх него уже спокойно пишет код не только я. Но эксплуатационного стажа у конструкции нет: всё описанное выше — про то, как она устроена и как ведёт себя на тестах, полгода реальной работы за ней пока не числится. Если вы читаете это в поисках проверенного временем рецепта, считайте, что перед вами отчёт с первого дня пути. Вернусь с продолжением, когда будет что сказать про прод.
Двусторонняя связь не обязана быть симметричной. Широкий вход и узкий выход я не проектировал заранее: к этой форме меня привели неудачи, и только потом стало понятно, почему она правильная. Читать чужую систему можно жадно, а вот писать в неё стоит точечно.
Форма оказалась источником состояния, витриной данных она не работает: хранит то, что уедет на сервер, отображение её не заботит. Я потерял время, пытаясь вытащить из неё сведения, которых там никогда не было.
Петли надо разрывать явно и в одном месте. Шесть предохранителей выглядят как шесть костылей, но каждый стоит в известной точке и лечит понятную проблему. Куда хуже была бы россыпь флагов isUpdating по компонентам (я начинал именно с этого). Проверить этот тезис довелось быстрее, чем я рассчитывал: когда механизм заработал, поверх него начал делать фичи коллега. Объяснять пришлось ровно один список, тот самый из двадцати четырёх полей.
Страница заказа, с которой всё началось, до сих пор рендерится Symfony и до сих пор пересчитывается jQuery. Просто теперь на ней живут блоки, которые об этом знают и умеют договариваться.