От автора: история о том, как я выявил и решил проблему с производительностью в веб-приложении React. Я отлаживал много медленных программ, но никогда в вебе. Это была отличная возможность поэкспериментировать с инструментами и повысить свои навыки. Как выяснилось, моя проблема тяжело поддавалась анализу, но благодаря инструментам производительности Chrome, ручного профилирования и тщательного научного подхода я смог найти решение, и оптимизация CSS была произведена.
Эта статья не о React
Недавно я баловался с React. Интересный фреймворк с большими возможностями для легкой разработки, обслуживания и производительности. Мне очень хотелось его попробовать. Он сильно отличается от Backbone, front-end фреймворка, с которым я очень хорошо знаком.
Мой последний проект – это клон NMBR-9, замечательной настольной игры, которая мне нравилась. Игра похожа на тетрис, только здесь фигуры складываются вертикально, а очки получаются за самую высокую фигуру. Правила довольно легкие, а игра проходит на квадратной площадке, что идеально подходит для рендера с помощью HTML и CSS. Я подумал, такая игра будет хорошим заданием по React для меня.
Едва ли начав, у меня возникли проблемы с производительностью. Посмотрите, как выбранная фигура не успевает за курсором.
Опытные разработчики, возможно, уже поняли, в чем проблема, и посмеиваются. Но я работал с системным программированием – за всю мою карьеру у меня почти никогда не было экранов. Поэтому для меня это было Interesting Engineering Challenge.
Измерение производительности
Инструменты профилирования
В бизнесе есть старая поговорка, которая отлично подходит к ПО: «если что-то нельзя измерить, это нельзя улучшить». Это значит, что мне нужно было сделать 2 вещи:
Понять, какая часть замедляет процесс. Так я получу цель для улучшения.
Определите время рендера одного кадра – т.е. переход от движения мыши к пикселям на экране. Это позволяет сравнить разные билды в абсолютных терминах, тем самым ответив на вопрос «а сделал ли я лучше?».
Я первый раз пользовался встроенным профайлером производительности Chrome, который дает красивое визуальное представление о том, когда какие функции работают. У Google есть хороший урок по инструменту.
Далее мне понадобилось создать свой инструмент. В браузере есть функция requestAnimationFrame(), которая принимает функцию, которую необходимо вызвать далее после отрисовки кадра (т.е. после отрисовки текущего кадра). Обычно requestAnimationFrame() используют для анимации, но я приспособил ее под свои цели. Если поместить вызов функции в React render(), то я могу измерить время от render() до законченного DOM.
1 2 3 4 5 6 7 8 9 |
render() { const renderStartTime = performance.now(); requestAnimationFrame(() => { const delta = performance.now() - renderStartTime; console.log(`render to RAF: ${delta} ms`); }); // ... do render things ... } |
Я могу бы использовать console.time() вместо performance.now(), но мне нравится иметь доступ к цифрам в случае, если я беру среднее значение, как здесь. Единственный способ получить прошедшее время из console.time() – это прочитать его глазами.
Эксперимент
Чтобы максимально контролировать испытания, мне также нужен был последовательный эксперимент. В противном случае, производительность может случайно как улучшиться, так и ухудшиться. То есть мне нужно было повторяемое начальное состояние и повторяемый набор входных данных для тестирования.
Чтобы получить начальное состояние, я прошел пол игры, а затем использовал JSON.stringify(), чтобы получить представление о состоянии игры. Значение я скопировал и вставил в вызов JSON.parse() в конструкторе компонента верхнего уровня.
Для эксперимента мне было необходимо предоставить «случайные», но повторяемые движения мыши, случайный путь по доске игры. К сожалению, обычный JS не позволяет генерировать случайное число. Однако для этого есть библиотека seedrandom. Вот моя функция случайных движений:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
startRandomWalk() { this.renderTimes = []; this.setMouseLocation(10, 10); const interval = 100; Math.seedrandom('random but predictable sequence'); let steps = 0; const MAX_STEPS = 100; const intervalId = setInterval(() => { let row = this.state.mouse.row; let col = this.state.mouse.col; if (Math.random() * 2 > 1) { if (row === 0 || (row < this.state.boardHeight - 1 && Math.random() * 2 > 1)) { row += 1; } else { row -= 1; } } else { if (col === 0 || (col < this.state.boardWidth - 1 && Math.random() * 2 > 1)) { col += 1; } else { col -= 1; } } console.log(`Step ${steps}: moving mouse to ${row}-${col}`); this.setMouseLocation(row, col); steps += 1; if (steps > MAX_STEPS) { clearInterval(intervalId); } }, interval); } |
setMouseLocation() – это та же функция, которая вызывается дочерними компонентами, когда мышь проходит над ними. Это максимально близкий подход к полной последовательности без издевательств над событиями DOM. Мне оставалось подключить эту функцию к button.
Проводим тест
Инструмент готов, пора проводить тесты. Не найдя явных причин в дереве производительности Chrome, я потратил много времени, копаясь в функция React, но я не нашел в коде явного замедляющего цикла. Жаль.
Тем не менее, React много чего делает сам. Каждый раз при повторном рендере он заново строит части теневого DOM и отличает их от реальных элементов. Я подумал, было бы неплохо поэкспериментировать и найти медленные и быстрые операции.
Сначала я хотел немного изменить стили и двигаться к более сложным изменениям, требующим реструктуризации всего приложения.
Попытка 1: удаление инлайнового CSS
Инлайн CSS?!?! Да, мне стыдно. В React это так просто, а вы уже создали весь HTML в JS. Так почему не пойти дальше? По измерениям я получил 15-20% прирост производительности после удаления всего инлайн CSS. Я знал, что когда-то мне придется это сделать, поэтому приятно было убедиться, что инстинкты меня не подвели.
Это было написано в книге. У нас намного больше обработки, чем у них, поэтому лучше, чтобы различий было меньше.
15-20% звучит круто, но с моими проблемами мне нужно было где-то 95%. Продолжим искать.
Попытка 2: удаление вычисляемых стилей
В поисках простых изменений (и боясь полного рефакторинга приложения) я наткнулся на этот пост, где утверждают, что градиенты очень медленные. Легко проверить:
1 |
s/background: radial-gradient\(white, (.*)\);/background-color: $1;/g |
Посмотрите, как быстро! Ушли почти все визуальные лаги (проскрольте вверх и сравните с первой анимацией, разница огромная). Красота пропала, но зная, что градиенты сильно бьют по производительности, я что-нибудь придумаю.
Но в цифрах… ничего не изменилось. Ни на миллисекунду больше или меньше. То есть requestAnimationFrame() – это не все.
Упрощение
Вооруженный доказательствами и теорией, настоящий ученый не находит другого выхода, как не подвергнуть свои гипотезы сомнению. Я решил построить идеальный сценарий, чтобы проверить, действительно ли градиенты так сильно бьют по производительности. Без React, никаких сложных состояний на столе, просто большая сетка, заполненная цветными квадратами и немного профилирования.
В итоге, получилось следующее: CSS Gradient Test (исходники). Загрузите и удивитесь: на моем MacBook 2015 года версия с градиентом проводит повторный рендер несколько секунд, а плоская версия (код тот-же) – практически мгновенна.
Более того, похожая история с requestAnimationFrame(), если посмотреть в консоль: это касается обеих версий, а версия с градиентом грузится намного дольше.
Чтобы понять почему, давайте глубже копнем в инструмент разработчика Chrome.
Это мой скриншот оценки версии с градиентом. Посмотрите на длинную зеленую полосу GPU. По умолчанию Chrome автоматически проводит сложные графические вычисления типа рендера градиентам на GPU. Мой MacBook уже старенький, и там рендер занимает время (позже я запустил тест на домашнем игровом ПК, результаты были намного лучше).
Чтобы понять, почему requestAnimationFrame() отрабатывает несколько секунд прежде, чем кадр появляется на экране, можно взглянуть на другую вкладку производительности – график summary. Ниже представлен график за тот же период.
Обратите внимание, что отсутствует категория GPU – всего 2.5 секунды бездействия. Chrome готов начать работу над кадром, как только предыдущий ушел в GPU. В большинстве случаев это нормально, но у нас самый худший сценарий, когда GPU работает намного тяжелее, чем все, что проходит в основном процессоре.
Стоит отметить, что хотя разница между работой CPU и GPU очевидна в этом патологическом сценарии, в моей реализации NMBR-9 разница была достаточно мала, и сначала мне удавалось ее игнорировать. Даже если бы я заметил это в начале, мне пришлось бы провести подобную последовательность тестов, чтобы найти проблему.
Выводы
Инструмент производительности Chrome дает намного лучший UX для разработчика по сравнению с другими инструментами, с которыми я работал в системной среде. Есть куда расти. Даже у лучших инструментов есть слепые зоны
Принципы настройки производительности одинаково хорошо подходят к системного программированию и к веб-программированию:
Измерение имеет ключевое значение, как относительно (что в коде медленно), так и абсолютно (изменилось ли что-то после правки)
Создайте последовательность экспериментов для запуска и управления, чтобы доверять своим результатам
Я написал этот пост после решения проблемы, из-за чего может показаться, что проблема не была такой и сложной. На самом деле, я провел много часов в исследованиях, замешательстве, чесании головы и тяжелой работы, чтобы понять причину. Уверен, что мою технику можно улучшить и дальше!
Большое спасибо моему инструктору, Charles за бесконечные идеи
Автор: Dan Roberts
Источник: //adadevacademy.tumblr.com/
Редакция: Команда webformyself.