Вступление

Недавно я обнаружил интересную ошибку в работе emoji-picker-element:

Я работаю на экземпляре fedi с 19 тыс. пользовательских эмодзи [...], и когда я открываю панель выбора эмодзи [...], страница замирает как минимум на целую секунду, а после этого на некоторое время замирает общая производительность.

Если вы не знакомы с Mastodon или Fediverse, то на разных серверах могут быть свои собственные эмодзи, как в Slack, Discord и т.д. Наличие 19k (на самом деле ближе к 20k в данном случае) крайне необычно, но не является чем-то неслыханным.

Поэтому я запустил их пример, и, святые угодники, она оказалась медленной:

Здесь было несколько ошибок:

  • 20 тысяч пользовательских эмодзи означали 40 тысяч элементов, поскольку каждый из них использовал <button> и <img>.

  • Не использовалась виртуализация, поэтому все эти элементы были просто засунуты в DOM.

К моей чести, я использовал <img loading="lazy">, так что эти 20 тысяч изображений не загружались все сразу. Но несмотря ни на что, рендеринг 40 тысяч элементов будет ужасно медленным - Lighthouse рекомендует не более 1 400!

Первой моей мыслью, конечно, было: "У кого, черт возьми, есть 20 тысяч пользовательских эмодзи?". Второй мыслью было: "Вздох Похоже, мне придется заняться виртуализацией".

Я старательно избегал виртуализации в emoji-picker-element, а именно потому, что 1) это сложно, 2) я не думал, что мне это нужно, и 3) это влияет на доступность.

Я уже проходил этот путь: Pinafore - это, по сути, один большой виртуальный список. Я использовал роль ARIA feed, сделал все вычисления самостоятельно и добавил опцию отключения "бесконечной прокрутки", поскольку некоторым людям она не нравится. Это не первое мое родео! Я просто с ужасом думал о том, сколько кода мне придется написать, и задавался вопросом о том, как это отразится на размере моего "крошечного" ~12kB emoji picker.

Однако через несколько дней мне в голову пришла мысль: а как насчет CSS content-visibility? Я видел, что много времени тратится на верстку и рисование, и плюс это могло бы помочь "заиканию". Это может быть гораздо более простым решением, чем полная виртуализация.

Если вы не знакомы, content-visibility - это новая функция CSS, которая позволяет "скрывать" определенные части DOM с точки зрения верстки и рисования. Она в основном не влияет на дерево доступности (поскольку узлы DOM все еще там), не влияет на поиск на странице (⌘+F/Ctrl+F) и не требует виртуализации. Все, что ему нужно, - это оценка размеров внеэкранных элементов, чтобы браузер мог зарезервировать там место.

К счастью для меня, у меня была хорошая атомарная единица для определения размера: категории эмодзи. Пользовательские эмодзи на Fediverse, как правило, делятся на небольшие категории: "blobs", "cats" и т. д.

Для каждой категории я уже знал размер эмодзи и количество строк и столбцов, поэтому вычисление ожидаемого размера можно было сделать с помощью пользовательских свойств CSS:

.category {
  content-visibility: auto;
  contain-intrinsic-size:
    /* width */
    calc(var(--num-columns) * var(--total-emoji-size))
    /* height */
    calc(var(--num-rows) * var(--total-emoji-size));
}

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

Следующее, что я сделал, - написал контрольную точку Tachometer, чтобы отслеживать свой прогресс. (Я люблю Tachometer.) Это помогло подтвердить, что я действительно улучшаю производительность и насколько.

Мой первый бенчмарк был очень прост в написании, и прирост производительности был налицо... Он просто немного разочаровал.

При начальной загрузке я получил примерно 15 % улучшения в Chrome и 5 % в Firefox. (В Safari content-visibility есть только в Technology Preview, поэтому я не могу проверить ее в Tachometer). Это не повод для беспокойства, но я знал, что виртуальный список может работать гораздо лучше!

Поэтому я копнул немного глубже. Затраты на верстку почти исчезли, но остались другие затраты, которые я не мог объяснить. Например, что это за большой неопознанный сгусток в трассировке Chrome?

Всякий раз, когда мне кажется, что Chrome "скрывает" от меня какую-то информацию о производительности, я делаю одно из двух: открываю chrome:tracing или (с недавних пор) включаю экспериментальную опцию "показывать все события" в DevTools.

Это дает вам немного больше низкоуровневой информации, чем стандартная трассировка Chrome, но без необходимости возиться с совершенно другим пользовательским интерфейсом. Я считаю, что это неплохой компромисс между панелью Performance и chrome:tracing.

И в этом случае я сразу же увидел нечто, что заставило меня повернуть шестеренки в голове:

Что такое ResourceFetcher::requestResource? Даже без поиска в исходном коде Chromium я догадывался - может быть, дело во всех этих <img>? Не может быть, верно...? Я использую <img loading="lazy">!

Ну, я последовал своему чутью и просто закомментировал src из каждого <img>, и что вы знаете - все эти загадочные расходы исчезли!

Я также протестировал в Firefox, и это также было значительным улучшением. Таким образом, это привело меня к мысли, что loading="lazy" - не такой уж крутой, как я предполагал.

На этом этапе я решил, что если я собираюсь избавиться от loading="lazy", то я могу пойти на полный шаг и превратить эти 40 тысяч элементов DOM в 20 тысяч. В конце концов, если мне не нужен <img>, то я могу использовать CSS, чтобы просто установить background-image на псевдоэлементе ::after на <button>, сократив время создания этих элементов вдвое.

.onscreen .custom-emoji::after {
  background-image: var(--custom-emoji-background);
}

На данный момент это был простой IntersectionObserver для onscreen, когда категория прокручивалась в поле зрения, и у меня был собственный loading="lazy", который был гораздо более производительным. На этот раз Tachometer показал улучшение на ~40% в Chrome и ~35% в Firefox. Вот это уже больше похоже на правду!

Примечание: я мог бы использовать событие contentvisibilityautostatechange вместо IntersectionObserver, но я обнаружил кроссбраузерные различия, и к тому же это нагружало бы Safari, заставляя его загружать все изображения. Однако, как только поддержка браузеров улучшится, я обязательно воспользуюсь этим!

Я был рад такому решению и отправил его. В целом бенчмарк показал улучшение на ~45% как в Chrome, так и в Firefox, а оригинальный пример сократился с ~3 секунд до ~1,3 секунды. Человек, сообщивший об ошибке, даже поблагодарил меня и сказал, что теперь выборка эмодзи стала намного удобнее.

Тем не менее, что-то меня не устраивает в этой ситуации. Глядя на трассировку, я вижу, что рендеринг 20 тысяч узлов DOM просто никогда не будет таким же быстрым, как виртуализированный список. А если я захочу поддерживать еще большие экземпляры Fediverse с еще большим количеством эмодзи, это решение не будет масштабироваться.

Однако я впечатлен тем, как много вы получаете "бесплатно" с помощью content-visibility. Тот факт, что мне не нужно было менять стратегию ARIA или беспокоиться о поиске на странице, был просто находкой. Но перфекциониста во мне все еще раздражает мысль о том, что для достижения максимального совершенства виртуальный список - это то, что нужно.

Может быть, со временем веб-платформа получит настоящий виртуальный список в качестве встроенного примитива? Несколько лет назад были попытки сделать это, но, похоже, они заглохли.

Я с нетерпением жду этого дня, а пока признаю, что content-visibility - хорошая грубая и готовая альтернатива виртуальному списку. Она проста в реализации, дает приличный прирост производительности и не имеет практически никаких препятствий для доступа. Только не просите меня поддерживать 100 тысяч пользовательских эмодзи!

Комментарии (3)


  1. Aquahawk
    29.09.2024 19:04
    +4

    То, что виртуальный дом написанный на js производительнее нативного дома является для меня непостижимой вещью. И уж тем более непостижимо зачем добавлять сразу все десятки тысяч айтемов в список, еще ios 3 в UITableView списки делались так чтобы переиспользовать объекты строк и менять в них контент, чтобы пооизводительность списка не зависела от количества элементов.


    1. cyberhippie
      29.09.2024 19:04

      Знаешь, есть саркастический термин модернфутбол? Вот такое и модернпрограммирование!))


    1. director-rentv
      29.09.2024 19:04
      +2

      Почему написанный на жс виртуальный дом быстрее - так он попросту легче, каждая реализация заботится только о том, что и как нужно ей, в то время как браузер тащит за собой огромную кучу обратной совместимости + других вещей, которые в конкретной ситуации для тех же фреймворков (коль скоро в виртуальном доме заговорили) к черту не нужны. Когда разрабы браузера делают реально нативную реализацию чего-нибудь эдакого изолированного (strucuredClone, JSON.stringify/parse например), она почти всегда на порядок быстрее накостыленного на жсе варианта

      Про базовую виртуализацию и пример с UITableView можно сказать только то, что покуда в мобилках у нас есть единый поставщик базовых компонентов, единые гайдлайны и прочее, то в вебе этого попросту нет. Сейчас есть грустные попытки сделать подобные штуки (в частности, помню кастомизируемый селект, но он, вроде бы, заглох). А без этого кто во что горазд. Вон, Карловский со своим $mol носится (без негатива :)), там-сям кто-то чето на конфе ляпнет про виртуализацию, кто-то чето на каком-то фреймворке с миллионом ограничений реализует, а суммарно воз вроде и ныне там: каждая компания - сам себе велосипедостроительный завод, поставляющий отборные костыли.

      Ну это я утрирую, конечно. Разрабы браузеров и комитеты стандартизации действительно трудятся на благо рядовых формошлепов, и время от времени шикарные штуки уровня ResizeObserver появляются. Сейчас вот добивают гвозди в крышку гроба костыльных модалок и тултипов