На этой странице

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

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

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

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

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

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

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

const uniqueItems = uniqWith(items, isEqual)

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

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 реактивность добавлялась прямо в исходный объект, через геттеры и сеттеры. Реактивный объект и ваш объект были одним и тем же:

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

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

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

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: убрать лишнюю работу

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

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

  this.setCachedItems(this.getInitialCachedItems())
}

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

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

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

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

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

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

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:

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().

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

import { isProxy, toRaw } from 'vue'

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

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

  • Vue 3
  • Performance
  • Reactivity
  • Migration
Александр Ануфриев
Александр Ануфриев

Senior frontend-разработчик