javascript

Широкий вход, узкий выход: Vue 3 внутри jQuery‑формы без бесконечных циклов

  • среда, 29 июля 2026 г. в 00:00:10
https://habr.com/ru/articles/1064008/

Добавить на страницу новые функции, не трогая саму страницу, звучит как противоречие. Но альтернативу мы посчитали командой: полное переписывание заняло бы год, в течение которого страница не получила бы ни одной новой функции. Мост между старым кодом и новым занял два месяца, и строил его уже я один. Расскажу, как этот мост устроен, почему получился несимметричным, где чуть не зациклился и что в нём так и осталось кривым.

Страница, которую нельзя переписать

Страница — карточка заказа в 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. Просто теперь на ней живут блоки, которые об этом знают и умеют договариваться.