[{"data":1,"prerenderedAt":340},["ShallowReactive",2],{"post-ru-vue-3-proxy-deep-comparison":3},{"id":4,"title":5,"body":6,"category":320,"cover":321,"coverAlt":322,"date":323,"description":324,"extension":325,"locale":326,"meta":327,"navigation":328,"path":329,"published":328,"readingMinutes":330,"seo":331,"slug":332,"stem":333,"tags":334,"__hash__":339},"posts\u002Fru\u002Fposts\u002Fvue-3-proxy-deep-comparison.md","244 650 сравнений: почему после миграции на Vue 3 автокомплит открывался 12 секунд",{"type":7,"value":8,"toc":310},"minimark",[9,13,16,19,24,27,30,33,44,50,58,65,68,72,75,81,84,90,116,119,126,130,133,139,146,149,153,156,162,165,179,185,191,195,198,204,214,217,221,224,234,240,245,249,252,298,301,307],[10,11,12],"p",{},"Мы перевели один из компонентов бэк-офиса inSales на Vue 3, и автокомплит со списком из 700 доменных зон стал открываться около 12 секунд. inSales — e-commerce-платформа, на которой работает больше 60 000 магазинов, а в бэк-офисе их владельцы всем этим управляют. Нажимаешь на поле, ждёшь, ждёшь ещё немного, и только потом появляется список.",[10,14,15],{},"Первая мысль напрашивалась сама: Vue 3 медленнее. Но дело было не в нём. Vue 3 не добавил в компонент ни одного сравнения. Он сделал дороже сравнения, которые жили там годами, а их оказалось очень много.",[10,17,18],{},"12 секунд я видел у себя, когда воспроизводил баг. Это наблюдение, а не бенчмарк. Впрочем, чтобы заметить поле, которое зависает на 12 секунд, бенчмарк и не нужен.",[20,21,23],"h2",{"id":22},"куда-уходило-время","Куда уходило время",[10,25,26],{},"У компонента было две проблемы, и заметными они стали только вместе.",[10,28,29],{},"Во-первых, при инициализации он перекладывал локальный список в отдельный кеш. Кеш нужен для внешнего поиска: там результаты приходят с сервера порциями, и их приходится объединять. В локальном режиме полный список уже передан в компонент, так что копия была не нужна совсем.",[10,31,32],{},"Во-вторых, при заполнении кеша дубли убирались так:",[34,35,41],"pre",{"className":36,"code":38,"language":39,"meta":40},[37],"language-js","const uniqueItems = uniqWith(items, isEqual)\n","js","",[42,43,38],"code",{"__ignoreMap":40},[10,45,46,49],{},[42,47,48],{},"uniqWith"," собирает массив результата и сравнивает каждый новый элемент со всеми, которые туда уже попали. Для 700 разных элементов получается:",[34,51,56],{"className":52,"code":54,"language":55,"meta":40},[53],"language-text","1-й элемент:     0 сравнений\n2-й элемент:     1 сравнение\n3-й элемент:     2 сравнения\n...\n700-й элемент: 699 сравнений\n\n700 × 699 \u002F 2 = 244 650 глубоких сравнений\n","text",[42,57,54],{"__ignoreMap":40},[10,59,60,61,64],{},"Если объединять уже заполненный кеш с новой порцией, становится ещё хуже: 700 новых элементов против 700 закешированных дают до 700 × 700 = 490 000 сравнений. А ",[42,62,63],{},"isEqual"," сравнивает глубоко, то есть обходит все ключи и вложенные объекты. Сотни тысяч вызовов превращаются в миллионы чтений свойств.",[10,66,67],{},"Алгоритм был квадратичным и во Vue 2. Просто тогда он обходился достаточно дёшево, чтобы его никто не замечал.",[20,69,71],{"id":70},"что-поменялось-во-vue-3","Что поменялось во Vue 3",[10,73,74],{},"Во Vue 2 реактивность добавлялась прямо в исходный объект, через геттеры и сеттеры. Реактивный объект и ваш объект были одним и тем же:",[34,76,79],{"className":77,"code":78,"language":39,"meta":40},[37],"const zone = { value: 'info', text: '.info' }\n\nVue.observable(zone) === zone \u002F\u002F true\n",[42,80,78],{"__ignoreMap":40},[10,82,83],{},"Во Vue 3 исходный объект остаётся нетронутым, а перед ним появляется Proxy:",[34,85,88],{"className":86,"code":87,"language":39,"meta":40},[37],"import { reactive, toRaw } from 'vue'\n\nconst zone = { value: 'info', text: '.info' }\nconst reactiveZone = reactive(zone)\n\nreactiveZone === zone        \u002F\u002F false\ntoRaw(reactiveZone) === zone \u002F\u002F true\n",[42,89,87],{"__ignoreMap":40},[10,91,92,93,95,96,99,100,103,104,107,108,111,112,115],{},"Данные одинаковые, но технически это два разных объекта. Лишних полей, которые ",[42,94,63],{}," пришлось бы сравнивать, у Proxy нет, и ",[42,97,98],{},"isEqual(zone, reactiveZone)"," честно возвращает ",[42,101,102],{},"true",". Вопрос в том, чего стоит этот ответ. Каждое ",[42,105,106],{},"proxy.value",", ",[42,109,110],{},"Object.keys(proxy)"," и ",[42,113,114],{},"'value' in proxy"," проходит через обработчики Vue, которые запоминают, от чего зависит код. Один такой вызов почти ничего не стоит. Миллионы вызовов в квадратичном цикле стоят секунды.",[10,117,118],{},"Поэтому точнее сказать так: Vue 3 не создавал 244 650 сравнений. Он поднял цену каждого, и старый алгоритм наконец стал заметен пользователю. Для обычной работы интерфейса Proxy — хороший компромисс. Глубокий обход сотен объектов во вложенных циклах обычной работой интерфейса не назовёшь.",[10,120,121,122,125],{},"Заодно я проверил, нет ли во Vue своего глубокого сравнения, которое само снимает Proxy. Публичного нет. Внутренние функции вроде ",[42,123,124],{},"looseEqual"," тоже обходят свойства и тоже идут через Proxy.",[20,127,129],{"id":128},"исправление-1-убрать-лишнюю-работу","Исправление 1: убрать лишнюю работу",[10,131,132],{},"Первый вопрос был не про скорость. Зачем локальному режиму вообще убирать дубли?",[34,134,137],{"className":135,"code":136,"language":39,"meta":40},[37],"initCachedItems() {\n  if (!this.isExternalSearch) return\n\n  this.setCachedItems(this.getInitialCachedItems())\n}\n",[42,138,136],{"__ignoreMap":40},[10,140,141,142,145],{},"Именно этот ранний ",[42,143,144],{},"return"," убрал задержку. Локальный список уже был в компоненте, и копировать его в кеш, чтобы ещё раз почистить от дублей, было чистыми накладными расходами. После правки автокомплит с 700 зонами стал открываться без ожидания.",[10,147,148],{},"Если тяжёлая операция не нужна, её лучше удалить, чем ускорять. Такое исправление ещё и проще всего проверить на ревью: в нём нет нового алгоритма, которому надо поверить.",[20,150,152],{"id":151},"исправление-2-удешевить-оставшиеся-сравнения","Исправление 2: удешевить оставшиеся сравнения",[10,154,155],{},"Для внешнего поиска кеш нужен, поэтому глубокое сравнение там осталось. Я убрал из него только Proxy:",[34,157,160],{"className":158,"code":159,"language":39,"meta":40},[37],"import isEqual from 'lodash\u002FisEqual'\nimport uniqWith from 'lodash\u002FuniqWith'\nimport { toRaw } from 'vue'\n\nthis.cachedItems = uniqWith(\n  this.cachedItems.concat(newItems),\n  (first, second) => isEqual(toRaw(first), toRaw(second))\n)\n",[42,161,159],{"__ignoreMap":40},[10,163,164],{},"Здесь важны несколько деталей.",[10,166,167,168,171,172,175,176,178],{},"Проверки ",[42,169,170],{},"isProxy"," перед ",[42,173,174],{},"toRaw"," нет. Обычный объект ",[42,177,174],{}," возвращает без изменений, так что условие только добавило бы лишнюю ветку в горячий цикл.",[10,180,181,182,184],{},"В кеше по-прежнему лежат исходные элементы. ",[42,183,174],{}," нужен только на время одного сравнения, и ссылка на raw-объект сразу забывается. Хранить raw-ссылки как отдельное состояние — уже другая история: изменения через них проходят мимо реактивности, и в приложении появляются две ссылки на одни и те же данные, которые ведут себя по-разному.",[10,186,187,188,190],{},"И ещё: ",[42,189,174],{}," не разворачивает всё рекурсивно. Если Proxy вручную положили внутрь обычного объекта, он там и останется. В наших элементах такого не было, но об этом стоит помнить, прежде чем переносить приём в другой код.",[20,192,194],{"id":193},"почему-я-не-стал-сравнивать-по-id","Почему я не стал сравнивать по id",[10,196,197],{},"Учебный способ избавиться от квадратичной дедупликации — ключ:",[34,199,202],{"className":200,"code":201,"language":39,"meta":40},[37],"const itemsById = new Map(items.map(item => [item.id, item]))\nconst uniqueItems = [...itemsById.values()]\n",[42,203,201],{"__ignoreMap":40},[10,205,206,207,210,211,213],{},"Это O(n) вместо O(n²), и в новом коде я бы так и сделал. Но здесь такой ключ поменял бы контракт. Сейчас два объекта считаются дублями, только если у них совпадают все поля. С ключом по ",[42,208,209],{},"id"," два объекта с одинаковым ",[42,212,209],{},", но разными остальными полями молча склеятся в один. Я не знал всех мест использования настолько хорошо, чтобы обещать, что это ни на что не повлияет. А исправление производительности — плохой повод менять само понятие дубля.",[10,215,216],{},"Поэтому я сохранил прежнюю логику сравнения и убрал только стоимость Proxy. Diff получился скромнее, зато безопаснее.",[20,218,220],{"id":219},"тест-на-контракт-вместо-секундомера","Тест на контракт вместо секундомера",[10,222,223],{},"Напрашивается тест «список должен открываться быстрее 1,5 секунды». Только он зависит от компьютера, загрузки процессора и того, что параллельно гоняется в CI. Такой тест будет либо флакать, либо окажется настолько щедрым, что ничего не докажет.",[10,225,226,227,111,230,233],{},"На самом деле проверить я хотел другое: сравнение не читает реактивные свойства. Vue умеет сообщать об этом напрямую через ",[42,228,229],{},"effect",[42,231,232],{},"onTrack",":",[34,235,238],{"className":236,"code":237,"language":39,"meta":40},[37],"import { effect, reactive, stop, toRaw } from 'vue'\nimport isEqual from 'lodash\u002FisEqual'\n\nconst sourceZone = { value: 'info', details: { available: true } }\nconst reactiveZone = reactive(sourceZone)\nconst trackedKeys = []\n\nconst runner = effect(\n  () => isEqual(toRaw(sourceZone), toRaw(reactiveZone)),\n  { onTrack: ({ key }) => trackedKeys.push(key) }\n)\nstop(runner)\n\n\u002F\u002F Без toRaw Vue отследит 'value', 'details', 'available' и так далее\nexpect(trackedKeys).toEqual([])\n",[42,239,237],{"__ignoreMap":40},[10,241,242,244],{},[42,243,232],{}," срабатывает только в dev-сборке, но тесты обычно в ней и запускаются. Такой тест упадёт по правильной причине: кто-то вернул Proxy в горячий путь. И ему неважно, насколько быстрый сегодня раннер.",[20,246,248],{"id":247},"что-я-теперь-проверяю-после-миграции-на-vue-3","Что я теперь проверяю после миграции на Vue 3",[10,250,251],{},"После этого бага я по-другому смотрю на код, который переехал на Vue 3. Второго взгляда заслуживают места, где:",[253,254,255,275,285,288],"ul",{},[256,257,258,259,262,263,266,267,270,271,274],"li",{},"объекты сравниваются через ",[42,260,261],{},"===",", служат ключами в ",[42,264,265],{},"Map"," или ",[42,268,269],{},"Set"," или ищутся через ",[42,272,273],{},"indexOf",";",[256,276,277,107,279,107,281,284],{},[42,278,63],{},[42,280,48],{},[42,282,283],{},"differenceWith"," и похожие функции работают с большими реактивными массивами;",[256,286,287],{},"реактивные объекты уходят в сторонние библиотеки, особенно в те, что запоминают объекты по ссылке;",[256,289,290,291,266,294,297],{},"экземпляр редактора, карты, графика или другого сложного класса завёрнут в ",[42,292,293],{},"ref()",[42,295,296],{},"reactive()",".",[10,299,300],{},"Для быстрой диагностики обычно хватает трёх значений:",[34,302,305],{"className":303,"code":304,"language":39,"meta":40},[37],"import { isProxy, toRaw } from 'vue'\n\nconsole.log({\n  isProxy: isProxy(item),\n  sameReference: item === originalItem,\n  sameRawReference: toRaw(item) === originalItem\n})\n",[42,306,304],{"__ignoreMap":40},[10,308,309],{},"Вывод шире этой истории. Если после обновления что-то стало медленнее, обновление редко виновато целиком. Оно меняет цену операций, которые код и так выполнял, и первым делом стоит найти старый код, который выполнял их слишком много. У нас порядок был такой: проверить, нужна ли работа вообще, сохранить контракт и только потом убрать лишнюю стоимость из того, что осталось.",{"title":40,"searchDepth":311,"depth":311,"links":312},2,[313,314,315,316,317,318,319],{"id":22,"depth":311,"text":23},{"id":70,"depth":311,"text":71},{"id":128,"depth":311,"text":129},{"id":151,"depth":311,"text":152},{"id":193,"depth":311,"text":194},{"id":219,"depth":311,"text":220},{"id":247,"depth":311,"text":248},"Производительность","\u002Fimages\u002Fblog\u002Fvue-3-proxy-deep-comparison.svg","Схема, на которой 700 элементов дают 244 650 глубоких сравнений через Vue Proxy, и два исправления, которые убрали задержку","2026-10-21","После перехода на Vue 3 автокомплит с 700 доменными зонами открывался около 12 секунд. Виноват оказался старый квадратичный поиск дублей, который стал дорогим, когда начал читать каждый объект через реактивный Proxy.","md","ru",{},true,"\u002Fru\u002Fposts\u002Fvue-3-proxy-deep-comparison",6,{"title":5,"description":324},"vue-3-proxy-deep-comparison","ru\u002Fposts\u002Fvue-3-proxy-deep-comparison",[335,336,337,338],"Vue 3","Performance","Reactivity","Migration","8Rcov72WVRjnOqIgK803aSsSO0Qj_bDOmJyz_nnoDp8",1793195987073]