# 244 650 сравнений: почему после миграции на Vue 3 автокомплит открывался 12 секунд

> После перехода на Vue 3 автокомплит с 700 доменными зонами открывался около 12 секунд. Виноват оказался старый квадратичный поиск дублей, который стал дорогим, когда начал читать каждый объект через реактивный Proxy.

- Адрес: https://anu3ev.com/ru/blog/vue-3-proxy-deep-comparison/
- Опубликовано: 2026-10-21
- Тема: Производительность
- Время чтения: 6 мин

Мы перевели один из компонентов бэк-офиса inSales на Vue 3, и автокомплит со списком из 700 доменных зон стал открываться около 12 секунд. inSales — e-commerce-платформа, на которой работает больше 60 000 магазинов, а в бэк-офисе их владельцы всем этим управляют. Нажимаешь на поле, ждёшь, ждёшь ещё немного, и только потом появляется список.

Первая мысль напрашивалась сама: Vue 3 медленнее. Но дело было не в нём. Vue 3 не добавил в компонент ни одного сравнения. Он сделал дороже сравнения, которые жили там годами, а их оказалось очень много.

12 секунд я видел у себя, когда воспроизводил баг. Это наблюдение, а не бенчмарк. Впрочем, чтобы заметить поле, которое зависает на 12 секунд, бенчмарк и не нужен.

## Куда уходило время

У компонента было две проблемы, и заметными они стали только вместе.

Во-первых, при инициализации он перекладывал локальный список в отдельный кеш. Кеш нужен для внешнего поиска: там результаты приходят с сервера порциями, и их приходится объединять. В локальном режиме полный список уже передан в компонент, так что копия была не нужна совсем.

Во-вторых, при заполнении кеша дубли убирались так:

```js
const uniqueItems = uniqWith(items, isEqual)
```

`uniqWith` собирает массив результата и сравнивает каждый новый элемент со всеми, которые туда уже попали. Для 700 разных элементов получается:

```text
1-й элемент:     0 сравнений
2-й элемент:     1 сравнение
3-й элемент:     2 сравнения
...
700-й элемент: 699 сравнений

700 × 699 / 2 = 244 650 глубоких сравнений
```

Если объединять уже заполненный кеш с новой порцией, становится ещё хуже: 700 новых элементов против 700 закешированных дают до 700 × 700 = 490 000 сравнений. А `isEqual` сравнивает глубоко, то есть обходит все ключи и вложенные объекты. Сотни тысяч вызовов превращаются в миллионы чтений свойств.

Алгоритм был квадратичным и во Vue 2. Просто тогда он обходился достаточно дёшево, чтобы его никто не замечал.

## Что поменялось во Vue 3

Во Vue 2 реактивность добавлялась прямо в исходный объект, через геттеры и сеттеры. Реактивный объект и ваш объект были одним и тем же:

```js
const zone = { value: 'info', text: '.info' }

Vue.observable(zone) === zone // true
```

Во Vue 3 исходный объект остаётся нетронутым, а перед ним появляется Proxy:

```js
import { reactive, toRaw } from 'vue'

const zone = { value: 'info', text: '.info' }
const reactiveZone = reactive(zone)

reactiveZone === zone        // false
toRaw(reactiveZone) === zone // true
```

Данные одинаковые, но технически это два разных объекта. Лишних полей, которые `isEqual` пришлось бы сравнивать, у Proxy нет, и `isEqual(zone, reactiveZone)` честно возвращает `true`. Вопрос в том, чего стоит этот ответ. Каждое `proxy.value`, `Object.keys(proxy)` и `'value' in proxy` проходит через обработчики Vue, которые запоминают, от чего зависит код. Один такой вызов почти ничего не стоит. Миллионы вызовов в квадратичном цикле стоят секунды.

Поэтому точнее сказать так: Vue 3 не создавал 244 650 сравнений. Он поднял цену каждого, и старый алгоритм наконец стал заметен пользователю. Для обычной работы интерфейса Proxy — хороший компромисс. Глубокий обход сотен объектов во вложенных циклах обычной работой интерфейса не назовёшь.

Заодно я проверил, нет ли во Vue своего глубокого сравнения, которое само снимает Proxy. Публичного нет. Внутренние функции вроде `looseEqual` тоже обходят свойства и тоже идут через Proxy.

## Исправление 1: убрать лишнюю работу

Первый вопрос был не про скорость. Зачем локальному режиму вообще убирать дубли?

```js
initCachedItems() {
  if (!this.isExternalSearch) return

  this.setCachedItems(this.getInitialCachedItems())
}
```

Именно этот ранний `return` убрал задержку. Локальный список уже был в компоненте, и копировать его в кеш, чтобы ещё раз почистить от дублей, было чистыми накладными расходами. После правки автокомплит с 700 зонами стал открываться без ожидания.

Если тяжёлая операция не нужна, её лучше удалить, чем ускорять. Такое исправление ещё и проще всего проверить на ревью: в нём нет нового алгоритма, которому надо поверить.

## Исправление 2: удешевить оставшиеся сравнения

Для внешнего поиска кеш нужен, поэтому глубокое сравнение там осталось. Я убрал из него только Proxy:

```js
import isEqual from 'lodash/isEqual'
import uniqWith from 'lodash/uniqWith'
import { toRaw } from 'vue'

this.cachedItems = uniqWith(
  this.cachedItems.concat(newItems),
  (first, second) => isEqual(toRaw(first), toRaw(second))
)
```

Здесь важны несколько деталей.

Проверки `isProxy` перед `toRaw` нет. Обычный объект `toRaw` возвращает без изменений, так что условие только добавило бы лишнюю ветку в горячий цикл.

В кеше по-прежнему лежат исходные элементы. `toRaw` нужен только на время одного сравнения, и ссылка на raw-объект сразу забывается. Хранить raw-ссылки как отдельное состояние — уже другая история: изменения через них проходят мимо реактивности, и в приложении появляются две ссылки на одни и те же данные, которые ведут себя по-разному.

И ещё: `toRaw` не разворачивает всё рекурсивно. Если Proxy вручную положили внутрь обычного объекта, он там и останется. В наших элементах такого не было, но об этом стоит помнить, прежде чем переносить приём в другой код.

## Почему я не стал сравнивать по id

Учебный способ избавиться от квадратичной дедупликации — ключ:

```js
const itemsById = new Map(items.map(item => [item.id, item]))
const uniqueItems = [...itemsById.values()]
```

Это O(n) вместо O(n²), и в новом коде я бы так и сделал. Но здесь такой ключ поменял бы контракт. Сейчас два объекта считаются дублями, только если у них совпадают все поля. С ключом по `id` два объекта с одинаковым `id`, но разными остальными полями молча склеятся в один. Я не знал всех мест использования настолько хорошо, чтобы обещать, что это ни на что не повлияет. А исправление производительности — плохой повод менять само понятие дубля.

Поэтому я сохранил прежнюю логику сравнения и убрал только стоимость Proxy. Diff получился скромнее, зато безопаснее.

## Тест на контракт вместо секундомера

Напрашивается тест «список должен открываться быстрее 1,5 секунды». Только он зависит от компьютера, загрузки процессора и того, что параллельно гоняется в CI. Такой тест будет либо флакать, либо окажется настолько щедрым, что ничего не докажет.

На самом деле проверить я хотел другое: сравнение не читает реактивные свойства. Vue умеет сообщать об этом напрямую через `effect` и `onTrack`:

```js
import { effect, reactive, stop, toRaw } from 'vue'
import isEqual from 'lodash/isEqual'

const sourceZone = { value: 'info', details: { available: true } }
const reactiveZone = reactive(sourceZone)
const trackedKeys = []

const runner = effect(
  () => isEqual(toRaw(sourceZone), toRaw(reactiveZone)),
  { onTrack: ({ key }) => trackedKeys.push(key) }
)
stop(runner)

// Без toRaw Vue отследит 'value', 'details', 'available' и так далее
expect(trackedKeys).toEqual([])
```

`onTrack` срабатывает только в dev-сборке, но тесты обычно в ней и запускаются. Такой тест упадёт по правильной причине: кто-то вернул Proxy в горячий путь. И ему неважно, насколько быстрый сегодня раннер.

## Что я теперь проверяю после миграции на Vue 3

После этого бага я по-другому смотрю на код, который переехал на Vue 3. Второго взгляда заслуживают места, где:

- объекты сравниваются через `===`, служат ключами в `Map` или `Set` или ищутся через `indexOf`;
- `isEqual`, `uniqWith`, `differenceWith` и похожие функции работают с большими реактивными массивами;
- реактивные объекты уходят в сторонние библиотеки, особенно в те, что запоминают объекты по ссылке;
- экземпляр редактора, карты, графика или другого сложного класса завёрнут в `ref()` или `reactive()`.

Для быстрой диагностики обычно хватает трёх значений:

```js
import { isProxy, toRaw } from 'vue'

console.log({
  isProxy: isProxy(item),
  sameReference: item === originalItem,
  sameRawReference: toRaw(item) === originalItem
})
```

Вывод шире этой истории. Если после обновления что-то стало медленнее, обновление редко виновато целиком. Оно меняет цену операций, которые код и так выполнял, и первым делом стоит найти старый код, который выполнял их слишком много. У нас порядок был такой: проверить, нужна ли работа вообще, сохранить контракт и только потом убрать лишнюю стоимость из того, что осталось.
