Производительность
244 650 сравнений: почему после миграции на Vue 3 автокомплит открывался 12 секунд
После перехода на Vue 3 автокомплит с 700 доменными зонами открывался около 12 секунд. Виноват оказался старый квадратичный поиск дублей, который стал дорогим, когда начал читать каждый объект через реактивный Proxy.
На этой странице
Мы перевели один из компонентов бэк-офиса 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
})
Вывод шире этой истории. Если после обновления что-то стало медленнее, обновление редко виновато целиком. Оно меняет цену операций, которые код и так выполнял, и первым делом стоит найти старый код, который выполнял их слишком много. У нас порядок был такой: проверить, нужна ли работа вообще, сохранить контракт и только потом убрать лишнюю стоимость из того, что осталось.