До недавнего времени я работал в команде Яндекс.Браузера и по следам этого опыта сделал доклад на конференции YaTalks. Доклад был о том, что у браузера под капотом и как ваши странички превращаются в пиксели на экране. Минимум фронтенда, только внутренности браузера, только хардкор.
— Всем привет, меня зовут Костя. Удивительно — сейчас я работаю в команде виртуальной сети Яндекс.Облака. До этого я пять с лишним лет проработал в команде Браузера, так что сегодня буду говорить о вещах, общих для нас с вами.
Как можно догадаться, я не очень хорошо понимаю во фронтенде. Если вы будете говорить со мной о React или еще о чем-нибудь в этом духе, я, скорее всего, вас не пойму. Но я делал очень много вещей в браузере: декодирование видео, бизнес-логику. В том числе я провел много времени, делая разные вещи в отрисовке браузера. Сегодня у нас будет такой, скажем, ликбез о внутреннем устройстве браузера. Я постараюсь пройтись по наиболее интересным вещам, которые мы делали в Яндекс.Браузере или Google в Chromium.
Если говорить про отрисовку в браузере, то это очень сложная штука, которая состоит из большого количества компонентов. В первую очередь вы должны загрузить ресурсы, чтобы показать их. Потом вы должны их распарсить, построить DOM-дерево, стили, лейауты и т. д. Первые три пункта вам, скорее всего, знакомы. Мой доклад будет больше посвящен другим трем частям: Painting, Rasterization и Compositing — тому, что происходит под капотом, когда верстку вы уже написали. Только по словам может показаться, что это примерно об одном и том же — на самом деле это совершенно разные компоненты.

Начнем с Painting и Compositing. Что это вообще такое? Давайте вернемся на много-много лет назад, когда веб был не таким сложным, как сейчас, когда не было всяких 3D, CSS-анимации и прочих штук. Как тогда рисовал браузер? Представьте, что у вас есть ваша страница, на ней какие-то чудесные элементы, картиночки и т. д. Браузер все это рисовал на одну здоровенную текстуру, в большой блок памяти. Он знал, как нарисовать каждый элемент, и если у нас случались какие-то изменения, тут они выделены желтым, то происходило примерно следующее.
Браузер агрегировал их в области, которые здесь обозначены синим цветом. В этой области произошло какое-то изменение, давайте ее перерисуем. Все, что в этой области находилось, просто перерисовывалось и копировалось на текстуру.
Это вполне себе работало. Потом умные люди придумали 3D CSS-анимацию, другие вещи. У нас могло происходить очень много отрисовок в разных местах. Если мы крутим один спиннер, перерисовывать весь пирог элементов, которые под ним расположены, не очень эффективно.
Тогда другие умные люди решили это все немного переделать. У нас есть какое-то DOM-дерево, мы построили его в памяти. Это плюсовые объекты, они сравниваются с тем, какую верстку вы написали.
А потом начинает происходить магия. Браузер преобразует все DOM-дерево в дерево Render Object. Эта штука знает, как нарисовать каждый конкретный DOM-элемент. То есть она знает, что нужно сделать, чтобы на экране появилось что-то вместо вашего дерева или P-элемента.
Следующее дерево — дерево слоев. Что это? Каждый наш элемент может быть ассоциирован с каким-то одним слоем, причем один Render Layer может содержать сразу несколько объектов. Для чего так сделано? Здесь это очень хорошо показано. Мы генерируем набор слоев, на каждом из них есть определенные элементы. Теперь, предположим, на одном из слоев что-то меняется — происходит анимация, пролетает марки-элемент. Тогда мы перерисовываем только один слой, а остальные, например бэкграунд, остаются неизменными. Мы их потом просто склеиваем в композиты и на выходе получаем итоговую картинку — текущий кадр анимации.
Для создания новых слоев есть фиксированный набор причин. Например, слой создается, чтобы вынести на него 3D CSS-анимацию, canvas, видео-элемент — в общем, нечто связанное с тяжелой анимацией, пригодное для перерисов отдельно от всего остального содержимого.
Но такой подход имеет несколько проблем. Сейчас нужно включить мыслетопливо. Подумайте, что здесь будет изображено? Тут всего два элемента.
Вот. Хотя, казалось бы, элементы расположены один за одним. Почему canvas вылетает наверх? У нас же они последовательно расположены, я не задавал никакой порядок.
Давайте усложним. Тут у нас появляется еще один div, вот такой.
В общем, ожидаемое поведение. У нас div поверх div, но canvas почему-то сверху. Магия! Хорошо, давайте снова усложним этот пример.
Изменилась ровно одна строка, я добавил transform.
И теперь у нас все расположено правильно — по крайней мере, в терминах canvas и div. Но вот этот div все еще располагается внизу, хотя он был следующим элементом в нашей верстке.
Это так называемый фундаментальный баг композитинга. Если вы поищете по трекеру Chromium, то увидите кучу багов, которые слинкованы с одним древнющим. Он так и называется.
Так что произошло? Как я уже говорил, некоторые элементы выносятся на Render Layer, некоторые не выносятся. Какие-то рисуются совместно с другими. Здесь произошло следующее: div-элементы остаются в том же слое, что и бэкграунд. Canvas вылетает на отдельный слой. А z-ordering осуществляется только между слоями. Из-за того, что у нас бэкграунд и div в одном слое, а canvas в другом, получается баг: canvas перекрывает div.
Но как только мы выносим этот div-элемент на отдельный слой и он начинает нормально использовать z-order, он также начинает понимать, кто за кем расположен. И тут уже все отрисовывается «нормально».
И одна из последних инициатив, развивающихся уже несколько лет, — так называемая Slimming Paint, которая должна это починить. Ее смысл в том, что нам нужно отделить Painting от вынесения на слои, то есть понимание, что нужно сделать для отрисовки этих элементов, от того, как их потом композитить друг с другом. Если у нас вот такая простая верстка, она превращается во что-то подобное. Есть простой список команд, которые нужно сделать, чтобы получить контент страницы. И если мы вернемся к этому примеру, она будет выглядеть примерно так.

То есть мы сказали: вот тебе Paint, вот тебе контент — пожалуйста, дай мне что-нибудь. Он дает список для отрисовки, который уходит в Compositor, и Compositor понимает, как нужно разбить весь контент на слои, чтобы они были нормально расположены друг относительно друга.
И если вы не заметили, это скриншот из Chrome. Я сделал его где-то недели две назад, то есть баг все еще живой. Проект еще не закончен, он сейчас в процессе разработки.

То есть Compositor по этому списку и по каким-то тайным знаниям, которые передает plink, может понять, как правильно разбить это все на слои.

Помимо того, что такой подход в принципе починит этот баг, мы также получаем довольно дешевые изменения в отрисовке. Предположим, у нас был список команд отрисовки и происходят изменения — скажем, элемент B уходит, элемент E добавляется. Тогда мы можем просто смержить два списка, не парясь с деревьями и т. д. На выходе мы получим новый список элементов для отрисовки, и, возможно, новый список слоев, которые в дальнейшем будут композититься.
Это был короткий рассказ о том, что происходит, когда браузер осуществляет Paint, и что происходит потом, когда он пытается скомпозитить слои.

Давайте перейдем к другой теме: Rasterization. Как раз в Rasterization в Яндекс.Браузере было сделано много всего, и я этим тоже занимался. В чем смысл растеризации? На выходе предыдущего этапа, когда мы сделали Paint, есть список команд, которые мы должны осуществить, чтобы получилась какая-то картинка. Растеризация — это превращение списка команд в реальные пиксели.
Если вы откроете в инспекторе браузера вкладку More tools → Rendering, то там есть галочка Layer borders. И вы видите вот такую сетку. Что здесь происходит? Когда браузер рисует страницу, он на самом деле теперь не делает ее целиком. Каждый слой браузер берет и разбивает на какое-то количество вот таких квадратиков. Исторически сложилось, что они размером 256 на 256 пикселей. То есть каждый слой разбивается на вот такое количество отдельных текстур. На каждую текстуру потом рисуется контент текущего тайла, и потом они все склеиваются в одну большую текстуру.
Это помогает во многом. Прежде всего, мы можем перерисовывать только те тайлы, которые изменились. Но еще это позволяет нам приоритизировать отрисовку. То есть в первую очередь должны быть нарисованы те тайлы, которые видит пользователь, так называемые viewport. Затем мы должны нарисовать soon border, это то, что вокруг viewport. Дальше — направление скролла: если ее скроллят вниз, мы прорисовываем как можно больше вниз. Если вверх — прорисовываем как можно больше вверх. Если у нас осталась квота памяти, мы нарисуем что-нибудь еще, но не факт, что она останется к этому моменту.
Так мы получаем довольно дешевое обновление контента на странице. Предположим, мы взяли текущий кадр и пользователь что-то проинвалидировал — например, выделил текст. Тогда мы пририсуем только те тайлики, которые изменились.
То есть зеленые тайлы — это которые остаются от предыдущей отрисовки, красные — это которые мы перерисовали. Но у такого подхода есть и другие преимущества.
Мы можем сделать — и в Chrome это сделали — так называемую оптимизацию маленьких перерисовок. Предположим, у вас есть какой-то throbber, курсор или еще что-то в этом духе, выполняющее небольшую перерисовочку в маленьком прямоугольнике. Тогда нам нет необходимости перерисовывать весь квадрат. Это логично. Например, если у нас моргает курсор, то перерисовывается только он. Это существенно экономит CPU.
Следующая оптимизация, которую они сделали. Где тут может быть неэффективность? Тайлы сдвинуты? Хорошая идея, но я клоню к другому. Вот просто белый прямоугольник. Это белый тайл, на котором не нарисовано ни единого пикселя. Но это текстура. Она занимает память 256 на 256 на четыре байта.
Еще одна оптимизация, которую придумали в Chrome: а давайте мы возьмем еще эти тайлы, одноцветные, и закодируем их не кучей пикселей, а, по сути, координаторами, размером и цветом. Интернет сейчас полон страницами с большим количеством одноцветных областей для больших мониторов. И такие страницы, соответственно, оптимизируются, мы получаем большую экономию памяти.
Мы в Яндексе пошли немножко дальше и решили сделать более специфичный эксперимент. Как вы думаете, где тут еще можно сэкономить?
У нас есть тайл. Контент на нем расположен в какой-то крохотной области — полоска, слово «Яндекс». Зачем нам рисовать целиком, если контента очень мало, а все остальное одноцветное?
Что мы сделали? Этим занимался конкретно я. Мы разбили каждый тайл на пять тайлов. Если контент в нем только в серединке, то мы выделяем текстуру для этого контента только под то, что сделано в серединке. Вот красная область. Все остальное мы кодируем так же — размер, координаты и цвет.
То есть конкретно на данной странице все эти области теперь стали не текстурой. Используются не байты в памяти, а просто команды о том, что нам нужно тут нарисовать, залить одним цветом. Это дало нам экономию примерно в 40% по памяти GPU в среднем на пользователя.
На более сложных страницах это выглядит так. Учитывая то, что более сложные страницы используют больше слоев, а каждый слой — отдельный тайлинг, то на любом слое можно немножко поэкономить.
Если вы сейчас включите эту галочку, то увидите не такую сетку из прямоугольников, а вот такую.
Что это такое, почему тайлы здесь такие широкие, и почему их мало? Смысл здесь в следующем. В Chrome подумали: почему бы нам не сделать не только хардварный композитинг, но и хардварную отрисовку. Что они делают? У нас есть список команд о том, что нужно сделать: нарисовать прямоугольник, залить цветом и т. д. Все это уходит на GPU, и GPU рисует такую текстуру. Перерисовка происходит очень быстро, поэтому тайлы тоже можно сделать большими. Вот немножко шакалистое видео, но оно очень хорошо показывает преимущество, которое случилось на телефонах как раз за счет того, что отрисовка стала хардварно ускоренной. Я думаю, разница тут очень-очень заметная.
Общение разработчиков браузера с фронтендерами мне кажется очень полезным. Оно случается не очень часто, но дает много пользы. Поэтому когда к нам приходят наши коллеги из других отделов и спрашивают, как нам сделать верстку побыстрее и получше, то мы стараемся им помочь и рассказать о местах, где что-то не оптимально и можно ускориться.
И я не устану повторять свои советы. (Не буду приводить здесь конспект, на эту тему был отдельный большой доклад. — прим. автора.)
Здесь я собрал набор полезных ссылок, про отрисовку и не только, а также немножко про Яндекс.Браузер. Спасибо.
Автор: Константин Крамлих