Масштабные рефакторинги и миграции часто приходится выпускать единым изменением.
Стековые пулл-реквесты — отличный способ разделить работу на более мелкие изменения, что облегчает ревью и помогает командам выпускать продукты с меньшим риском. Но некоторые изменения, как это, нельзя разделить чисто. В результате у вас остается единственный пулл-реквест, который может стать очень большим, а обсуждения в ходе ревью заставляют его расти еще больше.
Интерфейс ревью должен оставаться быстрым и плавным, даже когда diff и обсуждения к нему огромны. В приложении GitHub Copilot мы переработали представление пулл-реквеста именно с учетом этого требования.
Чтобы проверить пределы возможностей, мы открыли самый большой пулл-реквест, который смогли найти: открытый проект с 2200 файлами, более чем миллионом измененных строк и более чем 400 встроенными комментариями к ревью. Вот как мы добились высокой производительности даже для столь экстремального пулл-реквеста.
Масштаб проблемы
Отрисовка большого diff на высокой скорости — задача понятная: виртуализируйте строки, удерживайте смонтированное DOM-дерево небольшим и опирайтесь на тот факт, со каждая строка представляет собой строку кода известной высоты.
Комментарии — это самое сложное. Высота комментария зависит от того, как переносится его разметка markdown, от раскрывающихся секций, от наличия в нем поля для ответа и от того, загрузились ли в нем изображения. Все это выясняется во время рендеринга. Это вынуждает использовать другую архитектуру.
Три проблемы:
- Измерение. Вы не можете узнать высоту комментария, пока не отрендерите его. Это разрушает концепцию, которая позволяет большим diff оставаться отзывчивыми при прокрутке.
- Конвейер данных. Быстрый интерфейс diff бесполезен, если питающий его конвейер данных зависает или если он отбрасывает уже проделанную работу.
- Как мы на самом деле находили баги. Эти проблемы проявляются под нагрузкой, на определенном движке, в определенной позиции прокрутки. Поэтому мы определили, что означает «нормальное состояние», снабдили интерфейс средствами сбора метрик и запустили весь цикл «изменение → измерение → улучшение» в автоматическом режиме.
Первый шаг — понять геометрию, которая делает быстрым diff, состоящий только из кода. Как только в дело вступают комментарии, этой геометрии становится недостаточно.
Что делает большие diff быстрыми
Вы не можете поместить миллион DOM-узлов на страницу. Стандартное решение — виртуализация: монтируйте только те строки, которые находятся на экране, плюс небольшой запас, и используйте те же DOM-элементы повторно по мере прокрутки пользователя. Список ведет себя так, будто все миллион строк существуют одновременно. Полоса прокрутки имеет правильный размер, переход к строке работает. Но одновременно реальными являются только около 100 строк.
Чтобы эта иллюзия сохранялась, что-то должно обеспечивать геометрию. Высота полосы прокрутки — это сумма высот всех строк. Позиция строки N — это сумма высот строк, находящихся выше нее. Переход к строке, отрисовка полосы прокрутки, определение того, что находится на экране — все это арифметика над таблицей высот. Вы можете построить эту таблицу на основе оценок и корректировать ее по мере измерения строк, и универсальные виртуализаторы переменной высоты делают именно это.
Но если каждая строка представляет собой строку кода с известным размером шрифта, вам не нужно этого делать. Вы можете вычислить всю таблицу заранее, и она никогда не изменится, поэтому позже ничего не придется корректировать.
Назовем это контрактом «все высоты известны до отрисовки». Наш интерфейс diff построен вокруг него:
- Императивный переиспользуемый рендерер строк кода (без компонентов React на строку)
- Геометрия на типизированных массивах для вычислений смещений
- Документы diff, управляемые бэкендом и передаваемые потоком с приоритетом структуры
- Императивный API прокрутки с точным переходом к «строке N»
Ничто из этого не масштабируется плохо, поскольку никакая работа на один кадр не увеличивается вместе с общим количеством строк. Для чистого кода этот дизайн является правильным, и мы сохранили его целиком.
Теперь поместим ветку обсуждения ревью в середину diff. Какова ее высота?
Вы не знаете и не можете узнать это без рендеринга. Ее высота зависит от вещей, которые существуют только во время рендеринга и могут продолжать меняться после первого окрашивания:
- Markdown, который по-разному переносится при разной ширине
- Блоки <details>, которые пользователь может развернуть или свернуть на месте
- Компоновщик ответов, который открывается внутри существующей ветки и увеличивается по мере ввода текста
- Diff с предложенными изменениями, реакции, режим редактирования, баннеры разрешения
- Изображения и асинхронные ресурсы, которые меняют высоту после завершения загрузки
Очевидное решение — зарезервировать слот фиксированной высоты для каждого комментария, размер которого определяется оценщиком. На большом пулл-реквесте этот подход рушится. Оценщик, который верен в среднем, все равно ошибается на крайних значениях. Он резервирует слишком много места для большинства комментариев, оставляя пробелы в виде пустого пространства, и недорезервирует место для сложных комментариев, которые обрезаются или вызывают появление вложенной полосы прокрутки. Если вы измеряете реальную высоту после отрисовки и записываете ее обратно в общую таблицу смещений, все элементы ниже смещаются, пока пользователь уже прокручивает страницу. Это скачок прокрутки, и на большом пулл-реквесте он оказывается значительным.
Поэтому для комментариев нужен другой контракт. Концепция «все высоты известны до отрисовки» недостижима для такого контента. Вместо этого мы могли пообещать следующее: высоты ограничены сверху и снизу, измеряются лениво, а исправления малы и привязаны к тому, на что в данный момент смотрит пользователь.
Две геометрии вместо одной
Идея, которая сделала эту задачу решаемой, заключалась в том, чтобы перестать заставлять единую геометрию обслуживать оба типа контента. Мы разделили высоту документа на два независимых домена:
Геометрия кода сохраняет исходный мир. Она детерминирована, вычисляется через префиксные суммы, точна и никогда не перестраивается при изменении размера комментария.
Геометрия динамических блоков охватывает все, высоту чего мы не можем предсказать, например ветки ревью, черновики и компоновщики ответов. Каждый из них представляет собой блок, идентифицируемый по тому, чем он является, а не по тому, где он сейчас располагается. У него есть стабильный ключ, который сохраняется при загрузке его содержимого, и он привязан к файлу, строке и стороне, а не к координате пикселя, поэтому перекомпоновка не может потерять его из виду. Мы также сохраняем слепок (fingerprint) всего, что может изменить высоту блока: его содержимого, состояния тега <details> (открыт или закрыт), активности компоновщика. И мы записываем ширину, при которой он измерялся в последний раз, округленную до диапазонов (buckets), чтобы обычное изменение размера окна не аннулировало каждое измерение в документе.
Эффективная высота блока тогда проста: измеренная высота, если у нас есть актуальная, кэшированная высота, если слепок и ширина по-прежнему совпадают, и расчетная оценка в противном случае. Эти высоты хранятся в собственном индексе, отдельно от строк кода, поэтому изменяющий размер комментарий никогда не заставляет перестраивать геометрию кода. А количество блоков ограничено комментариями, а не строками. Несколько тысяч блоков — это нормально, если при первой отрисовке они не монтируются и не измеряются все разом.
Планировщик измерений и ошибка, которую мы совершили первой
Эту часть дольше всего удавалось сделать правильно, потому что наш первый вариант дизайна был ошибочным поучительным образом.
Очевидный способ измерения динамического контента — один ResizeObserver на блок, который следит за элементом и записывает его измеренную высоту обратно в макет при каждом изменении. Именно это мы спроектировали, а затем отвергли во время оптимизации производительности. Это цикл обратной связи, которого должны избегать большие виртуализированные интерфейсы. Наблюдатель, который записывает высоту обратно в макет отслеживаемого им элемента, может запустить сам себя, и затраты растут с каждым смонтированным блоком.
Вместо этого в продакшн отправился единый проход измерения, привязанный к состоянию простоя (idle) и прокрутки, подчиняющийся той же дисциплине, что и детерминированная сторона:
- Вне основного потока. Этот механизм срабатывает, когда стабилизируется видимая область, ни разу за кадр прокрутки, и полностью ждет, пока идет прокрутка. Перераспределение макета (reflow) в середине прокрутки — это как раз те рывки, которых мы избегаем. Он запускается снова, как только прокрутка останавливается.
- Ограничено областью просмотра. Кандидатами являются только блоки в пределах примерно 2400 пикселей от области просмотра, поэтому работа выполняется за время O(viewport). Удачные блоки продолжают использовать свои оценки и корректируются по мере приближения.
- Чтение на экране побеждает. Установленный блок находится на экране, поэтому его отрисованная высота является абсолютной истиной. Проход считывает каждого смонтированного кандидата за один пакет, выполняя единственный reflow без записей между ними, и записывает то, что обнаруживает. Смонтированный блок никогда не пропускается в пользу устаревшей оценки. Это единственное правило исправило самый неприятный баг, с которым мы столкнулись: комментарии, которые отображались с полосой пустого пространства внизу, потому что смонтированный блок был отфильтрован из измерений и оставлен с завышенной оценкой.
- Замеры вне экрана — это ограниченный запасной вариант. Для близлежащего блока, который еще не смонтирован, проход выполняет не более одного рендеринга вне экрана, чтобы скорректировать его резервирование до того, как он прокрутится в область видимости. Блоки выше области просмотра пропускают даже это. Их избыточное резервирование скрывается ниже сгиба страницы, поэтому рендеринг не стоит затрат.
- Наблюдатель улавливает остальное. Некоторые изменения высоты не меняют отпечаток и не совпадают с прокруткой: ввод текста в редакторе ответа, завершение загрузки изображения, переключение тега <details>. Каждый смонтированный блок сохраняет ResizeObserver, но по умолчанию все, что он делает, — это помечает блок, чтобы проход бездействия перечитал его. Он никогда сам не записывает высоту, что привело бы к замыканию цикла обратной связи, от которого мы отказались. Он отключается при демонтаже, и неактивная вкладка запроса на вытягивание ничего не наблюдает.
- С одним намеренным исключением. Ожидание выглядело явно неправильным для изменений размера, вызванных вами самостоятельно: развертывание <details>, открытие редактора ответа, загрузка изображения. Блок рос немедленно, но код под ним смещался только при следующем проходе бездействия. В течение одного кадра комментарий был выше, в то время как все под ним оставалось на прежней позиции, и можно было заметить эти два шага. Поэтому, когда блок смонтирован и находится на экране, наблюдатель теперь измеряет его и применяет коррекцию в том же кадре, до отрисовки. Блок увеличивается, код перепозиционируется, и все, что ниже, смещается вместе. Два предохранителя защищают от превращения этого в цикл, которого мы избегали: не более одного синхронного коммита за кадр, чтобы всплеск изменений размера схлопывался в один, и никогда во время активной прокрутки, когда происходит возврат к пакетному проходу.
Привязка прокрутки: коррекция без борьбы с пользователем
Когда измеренная высота отличается от оценки, меняется арифметика полосы прокрутки, и наивным результатом является прыжок области просмотра. Решение состоит в том, чтобы корректировать по идентичности, а не по пикселям:
- Перед применением обновлений высоты зафиксируйте, к чему привязан пользователь (строка или блок, по идентичности), плюс смещение внутри него.
- Примените дельты высоты.
- Определите новую позицию в пикселях для той же привязки.
- Прокрутите так, чтобы привязка осталась на своем месте в области просмотра.
Плюс несколько правил, которые мешают этому ощущаться неправильно:
- Блок выше области просмотра меняет высоту — скорректируйте на дельту (сохраняет ваше место).
- Контент гидратируется ниже области просмотра — не корректируйте (вы его не видите).
- Если вы переключили <details> или открыли ответ в видимом блоке — подавите коррекцию для этого блока, чтобы взаимодействие ощущалось непосредственным, и позвольте контенту ниже течь естественным образом.
- Никогда не боритесь с активным моментом указателя или колеса; пакетная коррекция выполняется после кадра.
У этого последнего правила есть острая грань, и она нас зацепила. Правило «не корректировать, пока пользователь прокручивает» было реализовано как защита по последней наблюдаемой прокрутке, и программные прокрутки также обновляли эту временную метку. Переключение боковой панели дерева файлов изменяет ширину области различий. При включенном переносе строк каждая перенесенная строка выше вас перекомпоновывается в другое количество визуальных строк, все координатное пространство смещается, и интерфейс выдает небольшую собственную прокрутку по мере стабилизации. Защита истолковала это как «пользователь только что прокрутил» и пропустила именно ту коррекцию, которая должна была сохранить ваше место, поэтому файл, который вы читали, уплыл с экрана. Решение состояло в том, чтобы отличать пользовательские прокрутки от тех, которые интерфейс вызвал сам. Любая проверка «взаимодействует ли пользователь?» должна быть такой, которую ваши собственные побочные эффекты не могут удовлетворить.
Таким образом, исправления остаются небольшими, они повторно используют уже имеющиеся у нас измерения и следуют за тем, на что вы смотрите.
Часть 2: Конвейер за интерфейсом
Интерфейс различий может работать только так быстро, как данные, питающие его, и три привычки с этой стороны работы определили возможности пользовательского интерфейса. Первая — структура потока перед контентом. Различия запрашиваются инкрементно, поэтому дерево файлов и метаданные отображаются, пока документ все еще загружается, а весь набор веток ревью разрешается заранее, а не просачивается понемногу. Вторая — отсрочка работы для каждого элемента до тех пор, пока что-то в ней не нуждается. Подсветка синтаксиса выполняется вне основного потока, поэтому строки появляются как простой текст немедленно и раскрашиваются, когда приходят результаты. Подсветка улучшает интерфейс вместо того, чтобы блокировать прокрутку. Большие тела уценки и контекст предлагаемых изменений работают так же: ничего не создается, пока оно не приближается к области просмотра.
Третья привычка касается того, какие затраты стоит сохранять. Выпуск документа различий при уходе со страницы является правильным поведением по умолчанию. Эти документы велики, и удержание каждого из них, которое вы посетили, приводит к тому, что долгий сеанс начинает пожирать память. Но метаданные запроса на вытягивание сохраняются, поэтому оболочка вокруг различий, заголовок и дерево файлов перерисовываются мгновенно, когда вы возвращаетесь, а затем сидят там в течение нескольких секунд в ожидании различий, которые у него были полсекунды назад. Мгновенно отрисованная оболочка вокруг пустых различий выглядит сломанной, даже если в целом вы ждете меньше времени. Поэтому политика осталась прежней, и мы добавили кэш: хранить последние несколько различий в памяти, выселять все, что выходит за рамки этого, и позволять фоновому обновлению замечать, когда что-то устарело.
Часть 3: Цикл измерений, или как мы на самом деле нашли баги
Почти каждый баг в этом проекте был невидимым до тех пор, пока не становился видимым, и воспроизвести его вручную — это мучение. Типичный отчет гласит: «полоса пустого пространства появляется под некоторыми комментариями, но только иногда, только в больших запросах на вытягивание, и исправляется, если прокрутить вперед и назад». Вы не можете отладить это, глядя на экран, поэтому мы создали инструменты для механической отладки.
Инструментирование с помощью реальных сигналов приложения, а не одноразовых логов
Наивный рабочий процесс заключается в том, чтобы рассыпать вызовы console.log, прогнать сценарий вручную, скопировать вывод, вставить его кому-то (или чему-то), кто может его проанализировать, удалить логи и повторить. Это медленно, требует участия человека в цикле и, хуже всего, в итоге вы измеряете собственное рукописное инструментирование, а не реальное поведение приложения.
Поэтому интерфейс несет постоянные, структурированные зонды для собственных инвариантов. Это простые вопросы, на которые он отвечает о себе при каждой отрисовке:
- Действительно ли интерфейс ограничен областью просмотра? Сколько строк и блоков комментариев смонтировано прямо сейчас?
- Сводится ли измерение к одному коммиту за кадр и сколько времени занимает этот кадр?
- Насколько велики вносимые нами коррекции прокрутки?
- Не был ли вставлен какой-либо блок комментариев после начала прокрутки? (После появления бэкенд-топологии это значение должно быть нулевым.)
- Действительно ли наблюдатели для каждого блока удаляются при размонтировании или происходит утечка по одному на блок?
Это объективные сигналы успешности/неуспешности, которые задаются в виде бюджетов в сквозном тесте против синтетического стенда с огромным пулл-реквестом и множеством комментариев. Теперь CI может сообщить нам, в порядке ли интерфейс.
Перевод цикла на автопилот
Центральным элементом стал автономный цикл «изменение → измерение → улучшение». Два направления:
Линия безголового зондирования выполняла декларативный сценарий (открытие пулл-реквеста, прокрутка до определенной позиции, переключение блока деталей, изменение размера окна) против мок-сервера, считывая собственную производственную телеметрию приложения: количество рендеров React, шкалу производительности и семплер requestAnimationFrame для отслеживания задержек (jank). Она полностью выполняла цикл «инструментирование — управление — сбор — анализ — ранжирование» самостоятельно и выводила узкие места по порядку. Поскольку сценарий представляет собой лишь JSON, передаваемый зонду во время выполнения, агент мог профилировать любой сценарий, описав его на обычном английском языке, без изменения ни единой строки исходного кода.
Автопилот управлял реальным десктопным приложением в сценарии с огромным пулл-реквестом в автоматическом режиме по кругу: сначала «холодным», когда комментарии еще представляют собой скелеты, затем «теплым», с загруженными комментариями, переключая блоки <details>, открывая и отменяя формы ответов, сворачивая и разворачивая файлы, переключая дерево боковой панели, углубляясь в список файлов и изменяя размер окна. Каждое измерение дублировалось в лог приложения на диске, поэтому агент мог считывать поведение во время выполнения без присутствия человека за клавиатурой. Каждая выборка содержала сигнал работоспособности, и это была объективная проверка. Теплая выборка считалась корректной только в том случае, если не было пустых промежутков между комментариями, ни один блок комментариев не оставался пустым, а реальное содержимое тредов действительно монтировалось во всем диапазоне прокрутки, включая глубокое погружение в файлы.
Запущенный нами цикл состоял в следующем:
- Воспроизводите автономно на реальном движке. Запустите автопилот, дайте ему поработать в цикле, прочитайте лог на диске.
- Обнаруживайте с помощью сигнала работоспособности, а не на глаз. Доверяйте полям выборок.
- Зондируйте подозрительный стык. Когда сигнал ухудшается, добавьте туда один точечный структурированный зонд, перенастройте систему, перечитайте данные. (Изменение интерфейса приводит к «горячей» перезагрузке живого окна и перезапуску автопилота, поэтому свежий снимок находится примерно в одном цикле от вас.)
- Удалите временные леса. Как только вы поймете инвариант, зафиксируйте его в тесте и проектной документации и оставьте только сигналы уровня детекторов.
К чему это нас приводит
Рецензирование столь масштабного пулл-реквеста раньше означало одно из двух: либо ждать, либо сдаться и читать его где-то в другом месте. Ревью — это не документ с фиксированными размерами. Это диалог, который меняет форму по мере чтения, и подлежащий интерфейс должен быть создан для этого с самого начала, а не исправляться патчами задним числом.
Результатом является представление пулл-реквеста, в котором дифф на миллион строк с сотнями обсуждений в ветках открывается, прокручивается и ведет себя как пулл-реквест нормального размера. Комментарии отрисовываются полностью, вместо того чтобы обрезаться в прокручиваемом блоке. Развертывание свернутой секции сдвигает только код под ней и ничего больше. Возврат к только что покинутому пулл-реквесту возвращает вас ровно на то же место.
Если вы занимаетесь код-ревью профессионально, стоит прочувствовать разницу на пулл-реквесте, который, как вы знаете, дается с трудом. Откройте худший из тех, что у вас есть.
Автор
Ведущий инженер-проектировщик
