Я Ян Килпатрик, ведущий инженер команды верстки Blink, вместе с Кодзи Исии. До работы в команде Blink я был интерфейсным инженером (до того, как у Google была роль «внешнего инженера»), создавая функции в Google Docs, Drive и Gmail. Примерно через пять лет на этой должности я пошел на большой риск, перейдя в команду Blink, эффективно изучая C++ прямо на работе и пытаясь наращивать масштабы чрезвычайно сложной кодовой базы Blink. Даже сегодня я понимаю лишь относительно небольшую часть этого. Я благодарен за время, уделенное мне в этот период. Меня утешило то, что многие «выздоравливающие фронтенд-инженеры» до меня стали «браузерными инженерами».
Мой предыдущий опыт помогал мне лично, когда я работал в команде Blink. Как фронтенд-инженер, я постоянно сталкивался с несогласованностью браузеров, проблемами с производительностью, ошибками рендеринга и недостающими функциями. LayoutNG предоставил мне возможность помочь систематически решать эти проблемы в системе компоновки Blink и представляет собой сумму усилий многих инженеров на протяжении многих лет.
В этом посте я объясню, как такое крупное изменение архитектуры может уменьшить и смягчить различные типы ошибок и проблем с производительностью.
Вид на архитектуру механизмов компоновки с высоты 30 000 футов.
Раньше дерево макета Blink было тем, что я называю «изменяемым деревом».
Каждый объект в дереве макета содержал входную информацию, такую как доступный размер, заданный родителем, положение любых плавающих элементов, и выходную информацию, например, конечную ширину и высоту объекта или его положение по осям x и y.
Эти объекты сохранялись между рендерами. Когда происходило изменение стиля, мы отмечали этот объект как грязный, а также всех его родителей в дереве. Когда запускалась фаза компоновки конвейера рендеринга, мы затем очищали дерево, обходили все грязные объекты, а затем запускали компоновку, чтобы привести их в чистое состояние.
Мы обнаружили, что такая архитектура приводит к возникновению многих классов проблем, которые мы опишем ниже. Но сначала давайте сделаем шаг назад и рассмотрим, каковы входные и выходные данные макета.
Запуск макета на узле этого дерева концептуально использует «Стиль плюс DOM» и любые родительские ограничения из родительской системы макета (сетка, блок или гибкость), запускает алгоритм ограничения макета и выдает результат.
Наша новая архитектура формализует эту концептуальную модель. У нас все еще есть дерево макета, но мы используем его в основном для хранения входных и выходных данных макета. На выходе мы генерируем совершенно новый неизменяемый объект, называемый деревом фрагментов .
Ранее я рассматривал дерево неизменяемых фрагментов , описывая, как оно предназначено для повторного использования больших частей предыдущего дерева для дополнительных макетов.
Кроме того, мы сохраняем объект родительских ограничений, который сгенерировал этот фрагмент. Мы используем его как ключ кэша , о котором подробнее поговорим ниже.
Алгоритм встроенного (текстового) макета также переписан в соответствии с новой неизменяемой архитектурой. Он не только создает неизменяемое представление плоского списка для встроенного макета, но также имеет кэширование на уровне абзаца для более быстрой ретрансляции, форму каждого абзаца для применения функций шрифта к элементам и словам, новый двунаправленный алгоритм Unicode с использованием ICU, большую корректность. исправления и многое другое.
Виды ошибок верстки
Ошибки макета, вообще говоря, делятся на четыре категории, каждая из которых имеет разные первопричины.
Корректность
Когда мы думаем об ошибках в системе рендеринга, мы обычно думаем о правильности, например: «Браузер A имеет поведение X, а браузер B имеет поведение Y» или «Браузеры A и B оба сломаны». Раньше именно на это мы тратили много времени и при этом постоянно боролись с системой. Распространенным вариантом отказа было применение целенаправленного исправления одной ошибки, но через несколько недель выяснилось, что мы вызвали регрессию в другой (казалось бы, несвязанной) части системы.
Как описано в предыдущих постах , это признак очень хрупкой системы. В частности, что касается макета, у нас не было четкого контракта между какими-либо классами, из-за чего разработчики браузеров зависели от состояния, чего им не следует делать, или неправильно интерпретировали какое-то значение из другой части системы.
Например, в какой-то момент у нас на протяжении более года возникла цепочка примерно из 10 ошибок, связанных с гибкой версткой. Каждое исправление вызывало либо проблемы с корректностью, либо производительностью части системы, что приводило к еще одной ошибке.
Теперь, когда LayoutNG четко определяет контракт между всеми компонентами системы макетов, мы обнаружили, что можем применять изменения с гораздо большей уверенностью. Мы также получаем большую выгоду от превосходного проекта Web Platform Tests (WPT), который позволяет нескольким сторонам вносить свой вклад в общий набор веб-тестов.
Сегодня мы обнаруживаем, что если мы выпустим реальную регрессию на нашем стабильном канале, она, как правило, не будет иметь связанных с ней тестов в репозитории WPT и не будет результатом неправильного понимания контрактов компонентов. Кроме того, в рамках нашей политики исправления ошибок мы всегда добавляем новый тест WPT, помогая гарантировать, что ни один браузер не повторит ту же ошибку снова.
Недостаточное признание недействительным
Если у вас когда-либо возникала загадочная ошибка, при которой изменение размера окна браузера или переключение свойства CSS волшебным образом устраняли ошибку, вы столкнулись с проблемой недостаточной инвалидации. Фактически часть изменяемого дерева считалась чистой, но из-за некоторых изменений в родительских ограничениях она не представляла правильный результат.
Это очень часто встречается в двухпроходных режимах макета (дважды проход по дереву макета для определения окончательного состояния макета), описанных ниже. Раньше наш код выглядел так:
if (/* some very complicated statement */) {
child->ForceLayout();
}
Исправление ошибки этого типа обычно выглядит следующим образом:
if (/* some very complicated statement */ ||
/* another very complicated statement */) {
child->ForceLayout();
}
Исправление проблемы такого типа обычно приводило к серьезному снижению производительности (см. чрезмерную недействительность ниже), и его было очень сложно исправить.
Сегодня (как описано выше) у нас есть неизменяемый объект родительских ограничений, который описывает все входные данные из родительского макета в дочерний. Мы сохраняем это вместе с результирующим неизменяемым фрагментом. Благодаря этому у нас есть централизованное место, где мы сравниваем эти два входа, чтобы определить, нужно ли дочернему элементу выполнить еще один проход макета. Эта различающаяся логика сложна, но хорошо сдержана. Отладка этого класса проблем с недостаточной недействительностью обычно приводит к ручной проверке двух входных данных и решению, что во входных данных изменилось, поэтому требуется еще один проход макета.
Исправления этого отличающегося кода обычно просты и легко поддаются модульному тестированию благодаря простоте создания этих независимых объектов.
Отличительный код для приведенного выше примера:
if (width.IsPercent()) {
if (old_constraints.WidthPercentageSize()
!= new_constraints.WidthPercentageSize())
return kNeedsLayout;
}
if (height.IsPercent()) {
if (old_constraints.HeightPercentageSize()
!= new_constraints.HeightPercentageSize())
return kNeedsLayout;
}
Гистерезис
Этот класс ошибок похож на недостоверность. По сути, в предыдущей системе было невероятно сложно обеспечить идемпотентность макета, то есть повторный запуск макета с теми же входными данными приводил к тому же результату.
В приведенном ниже примере мы просто переключаем свойство CSS между двумя значениями. Однако в результате получается «бесконечно растущий» прямоугольник.
С нашим предыдущим изменяемым деревом было невероятно легко вносить подобные ошибки. Если бы код допустил ошибку при чтении размера или положения объекта в неправильное время или на неправильном этапе (например, мы не «очистили» предыдущий размер или положение), мы бы немедленно добавили тонкую ошибку гистерезиса. Эти ошибки обычно не проявляются при тестировании, поскольку большинство тестов сосредоточено на одном макете и рендеринге. Еще более тревожно то, что мы знали, что некоторая часть этого гистерезиса необходима для правильной работы некоторых режимов макета. У нас были ошибки, когда мы выполняли оптимизацию для удаления прохода макета, но вводили «ошибку», поскольку режим макета требовал двух проходов для получения правильного результата.
Благодаря LayoutNG, поскольку у нас есть явные структуры входных и выходных данных, а доступ к предыдущему состоянию не разрешен, мы в значительной степени смягчили этот класс ошибок в системе макета.
Чрезмерная инвалидация и производительность
Это прямая противоположность классу ошибок недостаточной инвалидности. Часто при исправлении ошибки недостаточной инвалидации мы вызывали падение производительности.
Нам часто приходилось делать трудный выбор, отдавая предпочтение правильности производительности. В следующем разделе мы углубимся в то, как мы смягчили подобные проблемы с производительностью.
Подъем двухпроходных схем и обрывы производительности
Гибкая и сеточная компоновка представляет собой сдвиг в выразительности макетов в Интернете. Однако эти алгоритмы фундаментально отличались от алгоритма компоновки блоков, который существовал до них.
Блочная компоновка (почти во всех случаях) требует, чтобы движок выполнил компоновку всех своих дочерних элементов ровно один раз. Это отлично сказывается на производительности, но в конечном итоге не дает такой выразительности, как хотелось бы веб-разработчикам.
Например, часто требуется, чтобы размер всех дочерних элементов увеличился до размера самого большого. Для поддержки этого родительский макет (гибкий или сеточный) выполняет проход измерения, чтобы определить размер каждого из дочерних элементов, а затем проход макета для растягивания всех дочерних элементов до этого размера. Такое поведение используется по умолчанию как для гибкого макета, так и для макета в виде сетки.
Эти двухпроходные макеты изначально были приемлемыми с точки зрения производительности, поскольку люди обычно не вкладывали их глубоко. Однако мы начали замечать серьезные проблемы с производительностью по мере появления более сложного контента. Если вы не кэшируете результат этапа измерения, дерево макета будет колебаться между состоянием измерения и окончательным состоянием макета .
Раньше мы пытались добавить очень специфические кэши к гибкой и сеточной компоновке, чтобы бороться с этим типом падения производительности. Это сработало (и с Flex мы продвинулись очень далеко), но постоянно боролись с ошибками недостаточной и чрезмерной недействительности.
LayoutNG позволяет нам создавать явные структуры данных как для ввода, так и для вывода макета, а кроме того, мы создали кэши проходов измерений и макета. Это возвращает сложность к O(n), что приводит к предсказуемой линейной производительности для веб-разработчиков. Если когда-нибудь возникнет случай, когда макет выполняет трехпроходный макет, мы также просто кэшируем этот проход. Это может открыть возможности для безопасного внедрения более продвинутых режимов макета в будущем — пример того, как RenderingNG фундаментально открывает возможности расширения по всем направлениям. В некоторых случаях Grid-разметка может потребовать трехпроходной разметки, но в настоящее время это встречается крайне редко.
Мы обнаружили, что когда разработчики сталкиваются с проблемами производительности, особенно при компоновке, это обычно происходит из-за экспоненциальной ошибки времени компоновки, а не из-за необработанной пропускной способности этапа компоновки конвейера. Если небольшое постепенное изменение (один элемент меняет одно свойство CSS) приводит к макету на 50–100 мс, это, скорее всего, экспоненциальная ошибка макета.
В итоге
Макет — чрезвычайно сложная область, и мы не рассмотрели все виды интересных деталей, таких как оптимизация встроенного макета (на самом деле, как работает вся встроенная и текстовая подсистема), и даже концепции, о которых здесь говорится, на самом деле лишь поверхностно. и умалчивал многие детали. Однако мы надеемся, что мы показали, как систематическое улучшение архитектуры системы может привести к огромным выгодам в долгосрочной перспективе.
Тем не менее, мы знаем, что впереди у нас еще много работы. Мы знаем о классах проблем (как производительности, так и корректности), над решением которых работаем, и с нетерпением ждем появления новых функций макетирования в CSS. Мы считаем, что архитектура LayoutNG делает решение этих проблем безопасным и простым.
Одно изображение (сами знаете какое!) от Уны Кравец .