Этот блог был первоначально опубликован на hashnode для writethon
В наше время веб-сайты плохо реагируют на пользовательские вводы и действия. Плохо оптимизированный код фронтенда может очень легко нарушить пользовательский опыт и коэффициент принятия.
- Ваше веб-приложение может иметь большие объемы пользователей, создано для доставки в браузер с помощью CDN для ускорения загрузки/кэширования, разработано с использованием устойчивых архитектур, хорошо работающих бэкендов и механизмов аварийного восстановления.
- Ваше веб-приложение может также загружаться молниеносно в течение 1 с и иметь самый красивый пользовательский интерфейс, который только можно увидеть, благодаря ленивой загрузке, разделению кода и другим оптимизациям времени загрузки.
И наоборот, ваше приложение может иметь плохо работающий фронтенд-код, который в долгосрочной перспективе нарушает все впечатления конечных пользователей. Если ваше приложение очень динамично/реалистично и в основном зависит от действий пользователя, то велика вероятность того, что ваше приложение рендерится на стороне клиента (CSR) с помощью таких технологий, как React, Angular или Vue. Следовательно, становится очень важным оптимизировать фронт-энд для обеспечения бесперебойной работы с приложением.
Как производительность влияет на пользовательский опыт в простом примере (Насколько раздражающим является слишком раздражающий?)
Хорошо работающий фронтенд должен обеспечивать мгновенный отклик на выполненное действие. Пользователи ожидают, что веб-приложения, которые они используют в любом форм-факторе (настольном, мобильном), будут выглядеть как родные, поскольку грань между родными приложениями и стандартными веб-приложениями с каждым днем становится все тоньше благодаря Progressive Web Apps (PWA). Оптимизация вашего приложения может оказать значительное влияние на коэффициент конверсии и количество кликов.
Коэффициент конверсии электронной коммерции в зависимости от категории производительности рендеринга на клике (настольный компьютер) из публикации «Влияние производительности веб-сайта».
- Заботиться о производительности слишком рано или слишком поздно 🐌
- Измерение производительности фронтенда ⏲️
- Способы оптимизации 🤔
- Кэширование и мемоизация 🗂️
- Выбор правильного типа данных
- Запоминание на уровне функций
- Мемоизация на уровне компонентов и предотвращение ненужных повторных рендеров
- Рефлоу макета и трешинг 🌊
- Кэширование вне цикла
- Шаблон чтения и записи
- Использование window.requestAnimationFrame()
- Виртуализация 👁️
- Задержка и дебаунсинг рендеринга ⛹🏼
- Мыслить нестандартно 🧠
- Выгрузка на Web Workers 🧵
- Выгрузка на Canvas 🎨.
- Выгрузка на GPU/GPGPU (экспериментально) 🔬
- Почему я написал этот очень длинный блог?
Заботиться о производительности слишком рано или слишком поздно 🐌
«Двигайся быстро, ломай вещи» — распространенный девиз в быстро развивающихся проектах. Хотя это хороший подход для быстрой поставки «рабочих» продуктов, становится очень легко забыть о написании управляемого производительного кода. Разработчики будут больше сосредоточены на получении результатов в первую очередь и заботятся о производительности позже. В зависимости от приложения, технический долг производительности накапливается и становится неуправляемым.
Халтурные/патчевые исправления вносятся в критические части приложения для устранения проблем с производительностью в самом конце проекта. Это часто может привести к различным неизвестным побочным эффектам для других частей проекта, которые никто в вашей команде никогда не видел. Изначально разработчики пишут простой код, который легко понять и на написание которого уходит меньше времени. Таким образом, написание оптимизированного кода сопряжено с определенными затратами (времени и ресурсов). Без надлежащей документации кодовая база становится сложной с загадочными хаками производительности.
Мы должны забыть о небольшой эффективности, скажем о 97% времени: преждевременная оптимизация — корень всех зол.
Однако мы не должны упускать возможности в этих критических 3%.
— Дональд Кнут
Это не означает, что в каждой строке кода, который вы пишете, должен быть прикол, снижающий производительность.
- Правильное исправление производительности реализуется только тогда, когда его можно измерить. Неизмеренные исправления производительности очень часто могут привести к неожиданным ошибкам и проблемам.
- забота об оптимизации некритичной части вашего приложения — это огромная трата времени и ресурсов.
- исправление проблем производительности в неподходящее время в цикле разработки также может иметь негативные последствия.
Начиная работу над задачей или проектом, хорошей преждевременной оптимизацией может стать…
- Реструктуризация файлов и папок, разбиение кода на функции/компоненты.
- Принудительное использование типов в динамически типизированных языках (оптимизация рабочего процесса).
- Поток данных к родительским и дочерним компонентам и обратно.
И некоторые плохие преждевременные оптимизации могут быть…
- Использование профилировщиков и частое исправление незначительных проблем без обратной связи с пользователями.
- Использование сложных структур данных и алгоритмов, когда простой массив и встроенная функция сортировки справились бы с задачей.
Начиная работу, необходимо мыслить масштабно. Нужно меньше думать о том, «использовать ли мне цикл for или forEach?», а больше о том, «разбить ли мне этот огромный компонент на подкомпоненты, чтобы уменьшить ненужные повторные рендеринги?».
Измерение производительности фронтенда ⏲️
Производительность во время выполнения — это сложная проблема, которую нужно решить. Более сложной частью является измерение производительности и выявление тяжелых компонентов. Хотя существуют различные инструменты для измерения производительности фронтенда. Всегда полезно определить основные болевые точки приложения вручную, щелкая вокруг. Определите компоненты/страницы, принимающие на себя большую часть нагрузки, и используйте их в качестве отправной точки. Могут быть различные способы измерения производительности в зависимости от сценария использования и сложности вашего приложения.
- Ручное тестирование
- Стресс-тестирование с помощью devtools CPU throttling
- Использование инструментов Chrome Devtools
- Измерение производительности на уровне кода
performance.measure()
- Использование профилировщика
- Профилировщик React Devtools
- Профилировщик Angular Devtools
После первого раунда тестирования вы можете получить представление о том, где и как начать оптимизацию вашего приложения. Этот блог предполагает, что у вас есть необходимые знания о том, как читать графики пламени и получать информацию из профилировщика браузера.
Способы оптимизации 🤔
Существует множество различных способов оптимизации приложения в зависимости от используемого вами технологического стека, частоты и формы данных, которые вы получаете с сервера, сценария использования приложения и так далее.
В этой статье рассматриваются некоторые из этих методов по мере возрастания уровня сложности. По мере увеличения сложности, исправление производительности становится очень узким и контекстно-зависимым для вашего приложения.
- Кэширование и мемоизация
- Рефлоу макета и трэшинг
- Виртуализация
- Рендеринг с задержкой и дебаунсом
- Мыслить нестандартно
- Разгрузка на веб-рабочих
- Разгрузка на холст
- Разгрузка на GPU/GPGPU (экспериментально)
Кэширование и мемоизация 🗂️
По определению, кэширование — это техника, которая хранит копию данного ресурса и возвращает ее по запросу. Мемоизация — это тип кэширования, когда дорогостоящие вычисления хранятся в кэше, чтобы избежать частых пересчетов. В двух словах, ваш код запоминает ранее вычисленные результаты и выполняет их по запросу из памяти вместо того, чтобы беспокоить центральный процессор.
Выбор правильного типа данных
Именно здесь ваши старые добрые знания о структурах данных и алгоритмах играют жизненно важную роль. Рассмотрим случай, когда сервер возвращает список пользователей в виде массива объектов с уникальным идентификатором userId
. Для выполнения операций поиска (что вы можете делать часто) потребуется время O(n), где n — количество пользователей в массиве. Если сгруппировать пользователей по userId
один раз и преобразовать его в карту пар ключ-значение. Это может значительно сократить время поиска до O(1). (подробнее о нотации big-O)
По сути, вы проиндексировали свои локальные данные для более быстрого доступа. Вы обменяли немного места в памяти кучи на более простой поиск, вместо того чтобы полагаться на CPU для частых операций.
// the array way 🚫
const usersArray = [{
userId: 'ted',
fullName: 'Ted Mosby',
job: 'architect'
},
{
userId: 'barney',
fullName: 'Barney Stinson',
job: 'unknown'
},
{
userId: 'robin',
fullName: 'Ribon Scherbatsky',
job: 'news anchor'
},
...
]
// straight forward way to lookup/search in O(n) worst case
const ted = usersArray.find(user => user.userId === 'ted')
Хэшмапы/пары ключ-значение имеют постоянное время поиска, просмотра, поиска, вставки и удаления. Вы можете легко создавать карты ключ-значение из массива объектов с помощью функции lodash _.keyBy(usersArray, 'userId')
. Это делает его идеальной структурой данных, если данные постоянно используются внутри циклов for и блокирующего кода.
// the hashmap way ✅
const usersMap = {
'ted': {
userId: 'ted',
fullName: 'Ted Mosby',
job: 'architect'
},
'barney': {
userId: 'barney',
fullName: 'Barney Stinson',
job: 'unknown'
},
'robin': {
userId: 'robin',
fullName: 'Ribon Scherbatsky',
job: 'news anchor'
},
...
}
// efficient way to lookup/search O(1) worst case
const ted = usersMap['ted']
Здесь Array.indexOf()
может быть на порядок медленнее, чем поиск на основе объектных ссылок, и выглядит гораздо чище для чтения. Тем не менее, разница в производительности обоих методов зависит от шаблонов доступа и размера массива/объекта.
📝 В чем подвох?
Использование хэшмапов не всегда должно быть вашим основным решением. Почему так? Потому что (JS — странная штука)
JS движок работает по-разному в каждом браузере, у них своя реализация управления памятью кучи. (V8 в Chrome, Spidermonkey в Firefox и Nitro в Safari).
Поиск объектов может быть иногда быстрее в chrome и медленнее в firefox при одинаковых данных. Это также может зависеть от размера и формы данных в структуре данных (массив/хэшмап).
Всегда измеряйте, влияет ли применяемое изменение на конечную производительность.
Запоминание на уровне функций
Функциональное запоминание — это часто используемая техника в динамическом программировании. Она позволяет запомнить выходные и входные данные функции так, что когда вызывающая сторона снова вызывает функцию с теми же входными данными, она возвращается из своей памяти/кэша вместо повторного запуска фактической функции.
Запоминаемая функция в JS состоит из 3 основных компонентов…
- Обертка функции более высокого порядка, которая оборачивает дорогую функцию в закрытие.
- Дорогая чистая функция, которая возвращает одни и те же выходы для одних и тех же входов при любых условиях. Чистые функции не должны иметь побочных эффектов и не должны зависеть от каких-либо значений за пределами своей области видимости.
cache
hashmap, который действует как наша память и запоминает входы-выходы и пары ключ-значение.> Разница между чистыми и нечистыми функциями
Вот функция высшего порядка memoize, реализованная в typescript. Она принимает функцию и возвращает мемоизированную функцию. Дорогая функция (подлежащая запоминанию) может иметь любое количество аргументов. Ключи кэша преобразуются в примитивные типы данных, такие как string
или number
, используя второй аргумент в функции высшего порядка — transformKey
. Она также полностью безопасна для типов! ✨
type AnyFn = (...args: any[]) => any
function memo<Fn extends AnyFn>(fn: Fn, transformKey: (...args: Parameters<Fn>) => string) {
const cache: Record<string, ReturnType<Fn>> = {}
return (...args: Parameters<Fn>): ReturnType<Fn> => {
// transform arguments into a primitive key
const key = transformKey(...args);
// return from cache if cache hit
if(key in cache) return cache[key];
// recalulate if cache miss
const result = fn(...args);
// populate cache with result
cache[key] = result;
return result;
}
}
const memoizedExpensiveFunction = memo(expensiveFunction, (...args) =>
JSON.stringify(args)
);
Мемоизация очень хорошо подходит для рекурсивных операций, чтобы сократить целые куски лишних операций в дереве рекурсии. Она также полезна в функциях, где часто повторяющиеся входы дают одни и те же выходы. Вместо того чтобы изобретать колесо, вы можете использовать проверенные в боях обертки memorize, предоставляемые библиотеками.
📝 В чем подвох?
Мемоизация не является решением всех проблем, связанных с дорогостоящими вычислениями, не следует прибегать к мемоизации, когда…
дорогостоящая функция зависит или влияет на какое-то другое значение за пределами своей собственной области.
входные данные сильно колеблются и нерегулярны, или когда вы не знаете диапазон входных данных. Поскольку мы обмениваем некоторое пространство в памяти кучи на циклы процессора, нерегулярные входные данные могут создать огромный объект кэша, и использование мемоизации здесь станет бесполезным.
Мемоизация на уровне компонентов и предотвращение ненужных повторных рендеров
В контексте того, как работает React, компонент обновляется только при наличии props или изменении состояния компонента. Когда родительский компонент рендерится, все его дочерние компоненты тоже рендерятся. Рендеринг — это процесс вызова функции/метода рендеринга, поэтому это идеальное место для использования наших методов мемоизации.
Прежде чем приступить к мемоизации нашего компонента, необходимо сначала оптимизировать состояние компонента. Распространенная ошибка, которую совершает большинство разработчиков React, заключается в неправильном использовании хука useState
для хранения постоянных мутирующих переменных, которые не отражаются на пользовательском интерфейсе.
- При передаче функций от родительского компонента к дочернему лучше использовать обертывание функции с помощью
useCallback()
вместо передачи самих функций. Передача необработанных функций в запомненные компоненты все равно вызовет рендеринг, даже если реквизиты не изменились, поскольку родительский компонент рендерится, он создает новую ссылку на функцию и передает ее дочерним компонентам, отсюда и рендеринг.
// passing raw functions ℹ️
export const ParentComponent = () => {
const handleToggle = () => {
// do something
};
return <SomeExpensiveComponent onToggle={handleToggle} />;
};
// using useCallback() to pass functions ✅
export const ParentComponent = () => {
const handleToggle = useCallback(() => {
// do something
}, []);
return <SomeExpensiveComponent onToggle={handleToggle} />;
};
После этих предварительных шагов у вашего компонента теперь должно быть меньше повторных рендеров!
React решает перерисовывать дочерние компоненты каждый раз, когда перерисовывается родительский компонент. Если дочерний компонент запомнен, React сначала проверяет, не изменились ли реквизиты, выполняя неглубокое сравнение реквизитов. Если у вас есть сложный объект в реквизите, он только сравнивает ссылку на объект в старом и новом реквизитах (a===b
). Самое приятное, что у вас есть полный контроль над этой функцией равенства, чтобы определить, когда перерисовывать компонент на основе старых и новых реквизитов.
const ExpensiveChildComponent = ({state}) => <div>{state}</div>
const MemoizedExpensiveChildComponent = React.memo(ExpensiveChildComponent, (oldProps, newProps) => {
// do custom validation on old and new props, return boolean
})
export const ParentComponent = () => {
const [someState, setSomeState] = useState({})
return <MemoizedExpensiveChildComponent state = {someState} />
}
📝 В чем подвох?
Если ваш компонент зависит от
useContext()
, тоReact.memo()
бесполезен. Компонент будет рендериться независимо от наличия мемо.используйте useCallback() только в тех случаях, когда польза от него ощутима, useCallback() может стать источником проблем с производительностью, если не использовать его в нужном месте
используйте useMemo() в компоненте только тогда, когда компонент часто рендерится с одним и тем же реквизитом.
Иногда пользовательское сравнение реквизитов может потреблять больше циклов процессора, чем собственно рендеринг компонента, поэтому сначала измерьте, а затем примените исправление производительности.
Рефлоу макета и трешинг 🌊
Рефлоу макета — это когда браузер вычисляет размеры, положение и глубину элемента на веб-странице. Рефлоу возникает, когда…
- получение/установка измерений метрик элементов с помощью
offsetHeight
,scrollWidth
,getComputedStyle,
и других функций DOM. - добавление/вставка или удаление элемента в дереве DOM.
- изменение стилей CSS.
- изменение размера окна браузера или окна iframe.
- В общем, любая операция, которая потребует от браузера изменения представленного на экране пользовательского интерфейса.
> Очень высокоуровневый обзор конвейера рендеринга браузера
Когда происходит рефлоу, браузер синхронно (блокируя код) пересчитывает размеры и позиции элементов на экране. Как вы уже догадались, переливание — это очень дорогая работа для конвейера рендеринга, поэтому браузер старается ставить обновления в очередь и пакетную обработку, чтобы можно было переливать весь пользовательский интерфейс сразу, а не блокировать основной поток частыми переливаниями.
Стиль recalculate на графике производительности означает, что браузер переливает.
Влияние производительности из-за переливания зависит от сложности переливания. Вызов getBoundingClientRect()
на меньшем DOM-дереве будет иметь меньшее влияние на производительность, чем вызов того же самого на большем вложенном DOM-дереве. Рефлоу само по себе является важной частью процесса рендеринга, и оно приемлемо на меньших полях.
Рассмотрим следующий фрагмент кода,
for (let i = 0; i < listItems.length; i++) {
listItems[i].style.height = listContainer.clientHeight + 15 + "px"
}
Здесь width
и offsetHeight
считываются или записываются в цикле for для всех элементов списка. Предположим, что в списке 500 элементов, и цикл вызывается каждый раз, когда появляется новый элемент списка. Когда эти свойства вызываются слишком часто, браузер продолжает добавлять эти вызовы в очередь, чтобы обработать их позже. В какой-то момент, когда браузер очищает очередь, он пытается оптимизировать и пакетно обрабатывать повторные потоки, но не может, поскольку код запрашивает clientHeight
в быстрой последовательности внутри цикла for-loop, который запускает компоновку → reflow → repaint синхронно на каждой итерации.
Когда это происходит, страница замирает на несколько секунд, и это называется Layout Thrashing. Это незначительная заминка на настольных компьютерах и ноутбуках, но она может привести к серьезным последствиям в виде разрушения браузера на мобильных устройствах низкого класса.
Это очень распространенная ошибка, которую совершают многие разработчики. К счастью для нас, решение очень простое и находится прямо перед вашими глазами.
Кэширование вне цикла
Мы кэшируем значение срабатывания рефлоу вне любого цикла. Таким образом, мы просто вычисляем высоту/ширину только один раз, позволяя браузеру оптимизировать ее самостоятельно.
const listContainerHeight = listContainer.clientHeight
for (let i = 0; i < listItems.length; i++) {
listItems[i].style.height = listContainerHeight + 15 + "px"
}
Шаблон чтения и записи
Мы узнали, что браузер пытается объединить и оптимизировать последующие вызовы макета reflow в один единственный. Мы можем использовать это в своих интересах. Пример кода лучше всего иллюстрирует…
/// "read - write - read - write - read - write" pattern ❌
// read
let listItem1Height = listItem1.clientHeight;
// write (triggers layout)
listItem1Height.style.height = listItem1Height + 15 + "px";
// read (reflows layout)
let listItem2Height = listItem2.clientHeight;
// write (triggers layout)
listItem2Height.style.height = listItem2Height + 15 + "px";
// read (reflows layout)
let listItem3Height = listItem3.clientHeight;
// write (triggers layout)
listItem3Height.style.height = listItem3Height + 15 + "px";
/// "read - read - read - write - write - write" pattern ✅
// read (browser optimizes)
let listItem1Height = listItem1.clientHeight;
let listItem2Height = listItem2.clientHeight;
let listItem2Height = listItem2.clientHeight;
// write (triggers layout)
listItem1Height.style.height = listItem1Height + 15 + "px";
listItem2Height.style.height = listItem2Height + 15 + "px";
listItem3Height.style.height = listItem3Height + 15 + "px";
// reflow just one time and its seamless
Использование window.requestAnimationFrame()
window.requestAnimationFrame()
или rAF используется, чтобы сообщить браузеру, что вы собираетесь выполнять анимацию, следовательно, он вызывает обратный вызов внутри rAF перед следующей перерисовкой. Это позволяет нам пакетно записывать весь DOM (код, запускающий рефлоу) внутри rAF, гарантируя, что браузер запустит все на следующем кадре.
// read
let listItem1Height = listItem1.clientHeight;
// write
requestAnimationFrame(() => {
listItem1Height.style.height = listItem1Height + 15 + "px";
})
// read
let listItem2Height = listItem2.clientHeight;
// write
requestAnimationFrame(() => {
listItem2Height.style.height = listItem2Height + 15 + "px";
})
// read
let listItem3Height = listItem3.clientHeight;
// write
requestAnimationFrame(() => {
listItem3Height.style.height = listItem3eight + 15 + "px";
})
// browser calls rAF on the next frame hence reflows smoothly
Виртуализация 👁️
Игры, как правило, имеют высокодетализированные 3D-модели, огромные текстуры, огромные карты с открытым миром и сложные шейдеры, которые заполняют иммерсивную среду вокруг игрока. Как оптимизировать все эти сложные модели на ограниченном вычислительном GPU и при этом получить 60+ FPS?
Синий конус — это фрустум обзора игрока, и он рендерит только ресурс внутри него, показано за кадром в Horizon Zero Dawn (подробнее об этом).
Они используют технику под названием Frustum Culling. Frustum culling — это процесс удаления объектов, которые находятся полностью за пределами зоны обзора (POV) игрока. Он удаляет все, что находится за пределами POV игрока, и тратит всю вычислительную мощность на рендеринг только тех ресурсов, на которые смотрит игрок. Эта техника была изобретена много лет назад и до сих пор является одним из основных (по умолчанию) способов повышения производительности в играх.
Мы можем использовать эту старую технику и в наших приложениях! Веб-специалисты называют это виртуализацией. Представьте себе большой список или бесконечный (с возможностью просмотра, масштабирования) холст, или огромную (прокручиваемую по горизонтали и вертикали) сетку элементов. Оптимизация времени выполнения для таких случаев использования может стать сложной задачей.
Виртуализация списка
<ul>
, элементы, выделенные синим цветом, отображаются, серые не отображаются и находятся вне поля зрения пользователя (через Brian Vaughn)
К счастью для нас, существует библиотека react (react-window), которая обрабатывает логику виртуализации за вас. Виртуализация работает путем реализации 3 основных идей…
- Наличие DOM-элемента контейнера области просмотра, который действует как контейнер прокрутки.
- Меньший элемент, который содержит элементы списка, доступные для просмотра.
- Абсолютное позиционирование элементов списка на основе текущей позиции прокрутки, ширины и высоты контейнера прокрутки.
Поскольку браузер тратит всю свою вычислительную мощность на рендеринг того, что пользователь видит в данный момент, вы легко получите огромный прирост производительности.
Повышение производительности с виртуализацией и без нее (через виртуализацию списка)
react-window
предоставляет простые в использовании компоненты, которые делают внедрение виртуализации в ваши приложения простым делом. react-window оборачивает ваш элемент списка в родительский компонент, который будет обрабатывать всю логику виртуализации под капотом. react-window
ожидает фиксированной высоты для родительского контейнера прокрутки и заранее рассчитанной высоты для элемента списка.
Иллюстрация API react-window
FixedSizeList
Если высота всех элементов списка известна и рассчитана, вы можете использовать FixedSizeList
. Если высота каждого элемента списка зависит от содержимого элемента, то вы можете предварительно рассчитать высоту с помощью функции и передать ее в VariableSizeList
в свойстве itemSize
. Вы также можете использовать overscanCount
для отображения определенного количества элементов за пределами области прокрутки, если элементы вашего списка нуждаются в предварительной выборке активов изображения или для захвата фокуса пользователя.
const rowHeights = new Array(1000)
.fill(true)
.map(() => 25 + Math.round(Math.random() * 50));
const getItemSize = index => rowHeights[index];
const ListItem = ({ index, style }) => (
<div style={style}>Row {index}</div>
);
const HugeList = () => (
<VariableSizeList
height={150}
itemCount={1000}
itemSize={getItemSize}
width={300}
>
{ListItem}
</VariableSizeList>
);
react-window
также поддерживает пользовательский интерфейс на основе сетки, где есть горизонтальная и вертикальная прокрутка (вспомните большие сайты электронной коммерции или таблицы excel) с переменной высотой или шириной элементов. Пакет react-window-infinite-loader, который поддерживает бесконечную загрузку и ленивую загрузку содержимого вне области прокрутки, а также предоставляет возможности виртуализации.
const HugeGrid = () => (
<VariableSizeGrid
columnCount={1000}
columnWidth={getColumnWidth} // takes in current index
height={150}
rowCount={1000}
rowHeight={getRowHeight} // takes in current index
width={300}
>
{GridItem}
</VariableSizeGrid>
);
📝 В чем подвох?
Само собой разумеется, не используйте виртуализацию для списков с меньшим количеством элементов, сначала измерьте, а затем переходите к виртуализации. Поскольку алгоритм виртуализации требует больших затрат процессора.
Если элемент списка имеет очень динамичное содержимое и постоянно изменяет размер в зависимости от содержимого, виртуализация может оказаться сложной задачей.
Если элементы списка добавляются динамически с помощью сортировок, фильтров и других операций,
react-window
может создавать странные ошибки и пустые элементы списка.
Задержка и дебаунсинг рендеринга ⛹🏼
Задержка и отсрочка рендеринга — это распространенная практика для сокращения ненужных повторных рендеров при частых изменениях данных. Некоторые современные веб-приложения обрабатывают и отображают тонны сложных данных, поступающих с огромной скоростью через WebSockets или длинный опрос HTTP. Представьте себе аналитическую платформу, предоставляющую пользователям аналитику в реальном времени на основе данных, поступающих на фронтенд с помощью WebSockets со скоростью 15 сообщений в секунду. Такие библиотеки, как react и angular, не предназначены для рендеринга сложного дерева DOM с такой скоростью, а человек не может воспринимать изменения данных с быстрыми интервалами.
Дебаунсинг — это распространенная практика, используемая в поисковых входах, где каждое событие onChange()
вызывает вызов API. Дебаунсинг предотвращает отправку запроса API при каждом изменении буквы, вместо этого он ждет, пока пользователь закончит вводить текст в течение определенного времени, а затем отправляет запрос API. Мы можем использовать эту технику и для рендеринга!
Иллюстрация того, как дебаунсинг может оптимизировать ненужные API-запросы
Я не буду слишком углубляться в то, как реализовать дебаунсинг для API-запросов. Мы сосредоточимся на том, как мы можем отменить рендеринг, используя тот же метод. Представьте, что у вас есть поток/всплеск сообщений, проходящих через один канал WebSocket. Вы хотите визуализировать эти сообщения в виде линейного графика. Есть 3 основных шага для отладки рендеров…
- Локальный буфер, который будет хранить ваши WebSocket/часто меняющиеся данные вне контекста React/angular (
useRef()
) - Слушатель WebSocket, который принимает сообщения из сети, анализирует, преобразует их в соответствующий формат и помещает в локальный буфер.
- Функция debounce, при срабатывании которой данные из буфера будут сброшены в состояние компонента для запуска повторного рендеринга.
const FrequentlyReRenderingGraphComponent = () => {
const [dataPoints, setDataPoints] = useState<TransformedData[]>([]);
const dataPointsBuffer = useRef<TransformedData>([]);
const debouceDataPointsUpdate = useCallback(
debounce(() => {
// use the buffer
// update the latest state with buffer data however you want!
setDataPoints((dataPoints) => dataPoints.concat(dataPointsBuffer.current));
// flush the buffer
dataPointsBuffer.current.length = 0;
}, 900),
// sets the state only when websocket messages becomes silent for 900ms
[]
);
useWebsocket(ENDPOINT, {
onmessage: (data) => {
const transformedData: TransformedData = transformAndParseData(data);
// push into buffer, does not rerender
dataPointsBuffer.current.push(transformedData);
// important part of debouncing!!!
debouceDataPointsUpdate();
},
});
return <LineGraph dataPoints={dataPoints} />;
};
Вот высокоуровневая реализация дебаунсинга рендеринга. Вы можете изменить установщик буфера useRef()
в событии сообщения WebSocket и логику промывки во время дебаунсинга так, как вам нужно, что будет эффективно в зависимости от формы данных.
Иллюстрация того, как дебаунсинг может оптимизировать ненужные повторные рендеринги
Существует множество библиотек, которые предоставляют функции дебаунсинга из коробки…
- Функция RxJS
debounce()
. - lodash
_.debounce()
функция. - пользовательский хук react
useDebounce()
.
📝 В чем подвох?
Дебаунсинг следует использовать только в компонентах, которые часто перерисовываются или часто выполняют какую-либо дорогостоящую работу.
Буферизация данных в реальном времени может привести к ошибкам состояния гонки, если не делать это аккуратно, так как вы поддерживаете два источника истины в любой момент времени (данные буфера и фактические данные).
Дебаунсируйте только тогда, когда вы точно знаете, что есть время для промывки буфера и установки состояния, иначе компонент не будет рендериться, а буфер будет бесконечно расти в размере!
Мыслить нестандартно 🧠
Иногда любой оптимизации, которую вы проводите внутри своей кодовой базы, недостаточно. Именно тогда устранение проблем с производительностью становится не просто узким местом в UX, а узким местом в решении, которое предоставляет ваше веб-приложение. Следовательно, мы должны найти умные способы мыслить вне существующей экосистемы в поисках того, как сделать наше веб-приложение «пригодным для использования».
Вы думаете, что такие приложения, как Figma и Google Docs, состоят только из элементов DOM? Эти приложения вышли из нативного подхода, чтобы предоставить пользователям лучшие решения. В этот момент речь идет не об исправлении ошибки производительности, а о добавлении инновационной функции в ваше веб-приложение.
Выгрузка на Web Workers 🧵
Javascript, как известно, является однопоточным. Поскольку он однопоточный, нам не нужно думать о таких сложных сценариях, как тупиковые ситуации. Поскольку он однопоточный, он может выполнять только одну задачу за раз (синхронно). Чтобы поставить все эти задачи в очередь на выполнение процессором, используется механизм, называемый циклом событий.
ОС и браузер имеют доступ к любому количеству потоков, предоставляемых процессором. Именно поэтому браузер может параллельно обрабатывать несколько вкладок одновременно. Что если бы мы могли каким-то образом получить доступ к другому потоку для выполнения некоторых сложных операций?
Именно для этого и созданы Web Workers.
Представьте, что у вас есть огромное приложение React с довольно сложным деревом DOM, которое часто обновляется в зависимости от изменений в сети. Вас просят выполнить огромную обработку изображений/математическую операцию с огромными изображениями или входными данными. Обычно при обычном выполнении такая операция заполняет основной пул потоков, блокируя другие важные операции, такие как прослушивание событий, рендеринг и рисование всей страницы. Поэтому мы используем процесс Web Worker, чтобы перегрузить работу на отдельный поток и получить обратно результаты (асинхронно).
//// main.js
const worker = new Worker('worker.js');
// send complex operation inputs to worker.js
worker.postMessage(data);
// receive data from a worker.js
worker.onmessage = (event) => {
console.log(event.data);
}
//// worker.js
// receive data from main.js
self.onmessage = (event) => {
// do complex operation here
// send results to main.js
self.postMessage(data);
}
API рабочего очень прост: вы отправляете сообщение рабочему. Рабочий будет иметь код для обработки и ответа с результатами слушателям. Чтобы сделать это еще проще, Google создал библиотеку comlink.
Иллюстрация веб-рабочих (через Web workers vs Service workers vs Worklets)
Важно отметить, что веб-рабочие работают в отдельном контексте, поэтому ваши глобальные/локальные переменные, применяемые в основной кодовой базе, не будут доступны в файле worker.js. Поэтому для сохранения контекста между рабочими и основными файлами необходимо использовать специальные методы пакетирования. Если вы хотите интегрировать веб-рабочих с хуком React useReducer()
, пакет use-workerized-reducer
предоставляет простой способ сделать это. Таким образом, вы можете обрабатывать тяжелые состояния, а также управлять жизненным циклом компонентов react на основе результатов работы web worker.
const WorkerComponent = () => {
const [state, dispatch, busy] = useWorkerizedReducer(
worker,
"todos", // reducer name in worker.js
{ todos: [] } // reducer intial state
);
const addTodo = (todo) => {
dispatch({ type: "add_todo", payload: todo })}
}
return <div>{busy ? <Loading /> : <TodoList todos={state.todos} />}</div>;
};
📝 В чем подвох?
- Веб-рабочие имеют отдельный контекст от вашего основного приложения, поэтому необходимо связать ваш код с загрузчиком webpack, например
worker-loader
(подробнее).- Вы не можете использовать web workers для манипуляций с DOM или добавления стилей на страницу.
Выгрузка на Canvas 🎨.
В некоторых случаях сообщения WebSocket приходят с большой скоростью и без передышки. В таких случаях дебаунсинг не решит проблему. Такие случаи использования можно наблюдать на торговых и криптовалютных платформах, где происходит большой объем изменений. CoinBase решает эту проблему элегантно, используя холст в середине реактивного DOM UI. Он очень хорошо работает при быстрых изменениях данных и выглядит органично с родным пользовательским интерфейсом.
Вот как обновляется пользовательский интерфейс по сравнению с сообщениями WebSocket на вкладке «Сеть»…
Скорость обновления данных в веб-приложении CoinBase
Вся таблица — это просто холст, но обратите внимание, что я все еще могу навести курсор на каждую строку и получить эффект наведения подсветки. Это происходит путем простого наложения DOM-элемента поверх холста, но холст выполняет всю тяжелую работу по рендерингу текста и выравниванию.
Это просто длинный холст под ним!
Перекладывание работы на холст очень распространено при работе с высокодинамичными данными, такими как редактирование насыщенного текста, бесконечная динамическая сетка и быстро обновляемые данные. Google выбрал canvas в качестве основного конвейера рендеринга в Google Документах и Листах, чтобы иметь больше контроля над примитивными API и, самое главное, чтобы иметь больший контроль над производительностью.
📝 В чем подвох?
- Использование холста в реактивном DOM-дереве — непростая процедура, придется много «изобретать колесо» для базовых функций, таких как выравнивание текста и шрифты.
- Доступность родного DOM теряется при рендеринге данных/текста на основе холста.
Выгрузка на GPU/GPGPU (экспериментально) 🔬
В этом месте статья становится экспериментальной, и вероятность того, что вы будете использовать эту технику в реальном проекте, очень мала. Представьте себе, что вам нужно обучить нейронную сеть, параллельно обрабатывать сотни изображений или выполнять сложные математические операции с потоком чисел. Вы можете вернуться к использованию рабочего потока в Интернете для выполнения этой работы (что все равно будет работать). Но центральный процессор имеет ограниченное количество потоков и очень ограниченное количество ядер. Это означает, что он может обрабатывать данные быстрее с низкой задержкой, но не может хорошо справляться с быстрыми параллельными операциями.
Именно для этого и созданы графические процессоры! Игры и кодирование/декодирование видео требуют параллельной обработки отдельных пикселей на экране для более быстрого рендеринга при 60+FPS. Графические процессоры имеют тысячи ядер и специально созданы для выполнения тяжелых задач параллельной обработки. Использование CPU для таких задач возможно, но оно будет слишком медленным и будет сильно перегружать CPU, блокируя другие задания ОС.
Компромисс заключается в том, что взаимодействие GPU (GLSL Shaders) со средой JS является самой сложной частью. GPU созданы для работы с текстурами/изображениями в определенной структуре данных. Для выполнения тривиальных вычислений с помощью GPU требуются хакерские методы загрузки и выгрузки данных из GPU. GPU, выполняющие эти виды неспециализированных вычислений, связанных с CPU, называются GPGPU (General Purpose GPU).
Графический процессор NVIDIA GEFORCE RTX 3090 🏇🏼
// generates input matrices
const generateMatrices = () => {
const matrices = [[], []];
for (let y = 0; y < 512; y++) {
matrices[0].push([]);
matrices[1].push([]);
for (let x = 0; x < 512; x++) {
matrices[0][y].push(Math.random());
matrices[1][y].push(Math.random());
}
}
return matrices;
};
// create a kernel(function on GPU)
const gpu = new GPU();
const multiplyMatrix = gpu
.createKernel(function (a, b) {
let sum = 0;
for (let i = 0; i < 512; i++) {
sum += a[this.thread.y][i] * b[i][this.thread.x];
}
return sum;
})
.setOutput([512, 512]);
// call the kernel
const matrices = generateMatrices();
const result = multiplyMatrix(matrices[0], matrices[1]);
Вот результаты реального тестирования GPU.js, обратите внимание, что вы не видите никакой разницы во времени вычислений до операции с матрицей 512×512. После этого время вычислений для CPU увеличивается экспоненциально!
Сравнение производительности CPU и CPU при умножении матриц (с помощью GPU.js)
📝 В чем подвох?
- Общие вычисления на базе GPU следует выполнять с осторожностью, GPU имеют настраиваемую точность вычислений с плавающей запятой и низкоуровневую арифметику.
- Не используйте GPGPU в качестве запасного варианта для каждого тяжелого вычисления, как показано на графике, он дает прирост производительности только в том случае, если вычисления сильно распараллелены.
- GPU лучше всего подходят для обработки и рендеринга изображений.
~ Вот и все, по крайней мере, на данный момент.
Почему я написал этот очень длинный блог?
Без сомнения! Это самый длинный блог, который я когда-либо писал. Он является кульминацией необработанного опыта и знаний, полученных в ходе моих предыдущих проектов. Этот вопрос не давал мне покоя уже очень долгое время. Мы, разработчики, склонны быстро работать над функциями, выпускать рабочий код и заканчивать на этом. Это выглядит хорошо с точки зрения результатов и управления. Но во время работы над функцией совершенно необходимо думать о ситуации с конечными пользователями. Подумайте о типе устройства, которое они будут использовать, и о том, как часто пользователь будет с ним взаимодействовать. Я изучал большую часть веб-разработки на ноутбуке с 2 ГБ RAM и процессором Pentium, так что я знаю, что такое боль T_T.
Не существует правильного способа измерить производительность, установить сроки для исправления производительности или оценить все заранее. Это непрерывный процесс, который требует навыков разведки.
Хотя очень трудно включить/количественно оценить бюджет производительности для каждой функции в быстро развивающемся проекте. Подумайте, как то или иное добавление функции повлияет на ваше приложение в долгосрочной перспективе, и задокументируйте это. Это ответственность отдельного разработчика — мыслить масштабно и пытаться писать производительный код с нуля.
~ чао 🌻 ~
если вы хотите пообщаться со мной, вы можете следить за мной в Twitter @tk_vishal_tk