Введение
На этот раз мне поручили разработать функцию Snap для сервиса echo. Snap — это функция, которая захватывает экран ровно в том виде, в каком он отображается в момент нажатия пользователем значка камеры в заголовке. Вместо того чтобы объяснять что-либо только словами, она также отправляет снимок экрана, поэтому полученное изображение служит и объяснением, и доказательством того, что пошло не так.
Это означало, что нам нужно было вывести и скорость, и точность этой функции выше определённого порога. Сначала о скорости: целевым показателем была примерно одна секунда, поскольку захват экрана не должен прерывать процесс отправки отчёта. Вторым показателем была точность: экран, который видит пользователь, сообщающий о проблеме, и снимок экрана должны были быть идентичны. Если бы они различались, снимок не имел бы доказательной ценности.
Когда я впервые начал работать над улучшениями, я думал, что проблему можно решить только повышением скорости. Оптимизируя скорость и делая различные снимки экрана, я обнаружил, что существует также проблема с точностью. В этой статье обобщено, как я работал над этими двумя этапами и как сегодня выглядит получившийся конвейер. Сначала приведу результаты:
|
Экран |
Количество узлов |
Текущее значение |
Сравнение координат клона (точность) |
|---|---|---|---|
|
Экран простого списка |
1 476 |
436–457 мс |
0 из 996 |
|
Экран диаграммы |
4 745 |
1 186 мс |
0 из 4 281 |
Захват, который до улучшений занимал 3,3 секунды, теперь занимает 0,45 секунды на лёгком экране и 1,2 секунды даже на тяжёлом экране с втрое большим количеством узлов. Точность также повысилась: на тяжёлом экране координаты всех 4 281 элементов теперь в точности совпадают с фактическим экраном.
Ограничения захвата DOM
Корень проблемы заключался в том, что Snap не делал настоящий снимок экрана. Библиотеки вроде html-to-image не могут получить доступ к пикселям, в которых фактически отрисована веб-страница, поэтому на практике они работают следующим образом:
① Клонировать всё дерево DOM экрана
② Применить текущие CSS-значения каждого элемента по отдельности в виде встроенных стилей
③ Вставить результат в <foreignObject> внутри SVG
④ Загрузить этот SVG как <img> и отрисовать его на canvas
Иными словами, это похоже на повторный рендеринг того же HTML браузером. Проблема в том, что SVG внутри <img> на шаге ④ полностью изолирован от исходной страницы. Такая конструкция используется по соображениям безопасности, и у этой изоляции есть три характеристики, каждая из которых впоследствии проявилась в виде реальной ошибки.
-
У него нет доступа к веб-шрифтам родительского документа. Если они не встроены, они заменяются системными шрифтами, из-за чего меняется ширина символов и смещается макет.
-
Доступ к сети заблокирован. Поскольку URL-адреса изображений нельзя получить напрямую, перед захватом их все необходимо преобразовать в data URI (формат, в котором содержимое файла преобразуется в строку и вставляется в неё).
-
Выполнение скриптов отключено. Элементы, поведение которых меняется в зависимости от выполнения JavaScript, при захвате отрисовываются иначе.
Процесс разработки 1 — поиск причины медленной работы и проверка четырёх библиотек
Первый запрос, который я получил, звучал так: «Похоже, создание снимка экрана занимает довольно много времени». Когда я измерил этот процесс, диалог открывался сразу, но индикатор загрузки продолжал работать более трёх секунд, пока снимок экрана не был готов. В это время основной поток полностью останавливался, и даже клики не обрабатывались. JavaScript в браузере использует однопоточную структуру, которая обрабатывает только одну задачу за раз, поэтому, когда захват занимает этот поток, экран выглядит зависшим.
Поиск узких мест на каждом этапе
Сначала я думал, что задержку вызывает большое количество элементов на экране, поэтому начал с разделения процесса на этапы и измерения продолжительности каждого из них. Измерения показали, что одним из факторов был размер сериализованного SVG. Анализ привёл к выводу, что на каждый узел приходилось около 40 КБ встроенных стилей. Другим фактором был cssText: по исходному коду я подтвердил, что, когда это значение пусто, библиотека использует путь, при котором свойства перемещаются по одному, вместо быстрого пути копирования всех стилей сразу.
Извлекая фактический список, я обнаружил, что приложение имело около 1 000 свойств вычисленных значений, половина из которых была пользовательскими свойствами CSS. Это были переменные, начинающиеся с --, в которых содержались цвета темы или пути к значкам. Однако поскольку computed value возвращает конечное значение после подстановки переменных, не было причин повторно внедрять сами переменные в клон. После удаления свойств, не связанных с визуальным отображением, я сократил список примерно до 100 элементов.
// utils/inlineStyleProperties.ts — 인라인할 CSS 프로퍼티 127개
export const INLINE_STYLE_PROPERTIES = [
'display', 'position', 'top', 'right', 'bottom', 'left', /* ... */
// 'box-sizing' 은 반드시 있어야 합니다. Chrome의 computed width 는 그 요소의
// box-sizing 기준값이라, 빼면 클론이 padding+border 만큼 넓어집니다.
'width', 'height', 'box-sizing', /* ... */
// ::before / ::after 규칙도 이 목록으로 만들어집니다.
// 빠지면 의사요소(구분선·체크박스·화살표)가 통째로 사라집니다.
'content',
// 아이콘 대부분이 mask 로 렌더됩니다. 빠지면 아이콘이 전부 사라집니다.
'mask-image', 'mask-size', '-webkit-mask-image', /* ... */
];
Код 1. Пропуск элемента в списке не вызывает ошибку; он лишь приводит к незаметному расхождению снимка экрана. Поэтому я оставил причину в комментарии.
Самым тревожным элементом в этом списке был content. Если бы его пропустили, все элементы, отрисовываемые с помощью ::before / ::after, отображались бы пустыми. Я обнаружил это не в ходе тестирования, а случайно, читая исходный код библиотеки. Главный недостаток на этом этапе заключался в том, что при составлении списка я не предусмотрел способ проверить, что действительно необходимо. Именно это стало непосредственной причиной того, что позднее я начал оценивать другие библиотеки.
Я реализовал и сравнил каждый из четырёх подходов
Список, извлечённый с помощью AI, по своей природе ненадёжен. Snap — часть общей библиотеки, предназначенной для использования в нескольких эпизодах, но список я составил всего по нескольким экранам. Если приложение другой команды использует свойство, не включённое в список, снимок экрана незаметно разойдётся с оригиналом без возникновения ошибки. Поскольку для инструмента составления отчётов об ошибках я считал незаметно неверные результаты хуже медленных, я проверил альтернативы, не требующие ручного составления списка.
|
Подход |
Метод |
Лёгкий экран |
Тяжёлый экран |
Кратность увеличения |
|---|---|---|---|---|
|
Вариант A |
html-to-image + CSS-свойства, извлечённые с помощью AI |
378–388 мс |
828–865 мс |
2,2× |
|
Вариант B |
modern-screenshot + CSS-свойства, создаваемые во время выполнения |
575–863 мс |
1 870–2 045 мс |
2.8× |
|
Вариант C |
snapdom + дедупликация классов |
1,235–1,985 мс |
5,186 мс (26 секунд при первом запуске) |
4.2× |
|
Вариант D |
html2canvas — интерпретирует CSS в JS и напрямую рисует его |
516–610 мс |
(Другие условия измерения) |
— |
Именно последний столбец определил решение в этой таблице. При увеличении числа узлов в 4.3 раза сами темпы роста различались. Поскольку число свойств, считываемых и записываемых для каждого узла, составляло около 100 у варианта A и около 450 у варианта B, было неизбежно, что разрыв будет увеличиваться по мере усложнения экрана. Если бы мы сравнивали только лёгкие экраны, мы выбрали бы вариант B.
-
Вариант B (modern-screenshot) — Простая замена библиотеки не дала улучшений. Она уменьшает размер результата, встраивая только значения, отличающиеся от значений по умолчанию для каждого тега, но в конечном итоге всё равно приходится считывать всё, чтобы определить различия. Передача списка, сгенерированного во время выполнения, сократила время до 806 мс, но на тяжёлых экранах оно превышало две секунды.
-
Вариант C (snapdom) — Поскольку он объединяет одинаковые стили в классы, разметка получалась наименьшей, а точность отображения была единственной среди трёх вариантов, достигшей идеального результата. Однако публичной опции для включения только подмножества шрифта не было, поэтому приходилось встраивать весь шрифт приложения. В результате первый снимок тяжёлого экрана занимал 26 секунд.
-
Вариант D (html2canvas) — Мы протестировали его, поскольку это широко используемая библиотека. Так как она относится к другой категории (интерпретирует CSS в JS и напрямую рисует на canvas), привлекательным казалось то, что встраивание шрифтов совершенно не требуется. Однако поддержка mask-image, которую используют большинство значков приложения, полностью отсутствовала в сборке, поэтому все семь значков в заголовке и на панели инструментов отображались чёрными квадратами. Последний релиз вышел в 2022 году, и библиотека иногда встречала современную CSS-функцию, выбрасывала исключение и аварийно завершалась.
Почему мы оставили библиотеку, которую использовали изначально
В итоге мы оставили вариант A. Только он соответствовал требованию в одну секунду на тяжёлых экранах, а поскольку мы не могли знать, на каких экранах будет использоваться snap-view, то решили, что едва уложиться в измеренное время на проверенных экранах недостаточно.
Аргумент в пользу варианта B заключался в том, что «без курирования он безопаснее при работе с незнакомым CSS», но когда мы сравнили два снимка попиксельно на экранах, которые не проверяли при создании белого списка, то обнаружили различие примерно в 10%. Сначала я подумал, что вариант A действительно что-то пропускает, но, проверив каждый снимок по фактическим координатам экрана, мы выяснили, что оба ошибались одинаково. Разница заключалась не в том, что один был точнее, а в том, что две библиотеки немного по-разному отображали один и тот же CSS. Мы решили, что не можем выбрать вариант, который в 2.2 раза медленнее, ради непроверенного преимущества. При этом это не доказывает, что «вариант A безопаснее». Это лишь означает, что проблема не наблюдалась на двух экранах, поэтому эта слабость всё ещё сохраняется.
Эксперимент с вариантом D также преподал нам один дорогостоящий урок. Когда html2canvas завершается с ошибкой, он оставляет iframe на странице, но querySelectorAll('*') не видит его содержимое. Когда мы повторно запустили вариант A после накопления пяти таких элементов, размер разметки вырос до 66 МБ, а время создания снимка увеличилось до 4,960 мс. Единственной подсказкой было 13-кратное отклонение от базового значения; если бы мы приняли это число без проверки, то подготовили бы полностью неверный документ со словами: «Вариант A занимает пять секунд на этом экране».
Процесс разработки ② — После ускорения мы обнаружили, что снимки отличаются от экрана
После того как мы решили проблему скорости, мы разместили снимки рядом с фактическим экраном и обнаружили четыре расхождения. Причина всех четырёх отличалась от наших первоначальных предположений. В частности, мы подозревали, что проблемы 2, 3 и 4 связаны со шрифтами, но на самом деле шрифты были ни при чём.
|
# |
Симптом |
Фактическая причина |
Категория |
|---|---|---|---|
|
1 |
Флажки и стрелки сортировки ag-grid полностью отсутствовали |
Фон псевдоэлементов не обрабатывался путём встраивания ресурсов библиотеки |
Ресурсы |
|
2 |
Текст выглядел крупнее и сместился со своего места |
pixelRatio: 1 основан на CSS-пикселях (при масштабе браузера пользователя 90%) |
Масштаб |
|
3 |
Хлебные крошки в заголовке перенеслись на две строки |
Округление десятичного значения до 0.007px изменило поведение flex-wrap |
Макет |
|
4 |
Под заголовком появилась пустая полоса |
<noscript> снова начал работать, но только внутри снимка |
Макет |
Отсутствующие значки
Флажки выбора строк и стрелки сортировки столбцов полностью исчезали, но только на снимке. При этом значки камеры и уведомлений в заголовке отображались нормально. Мы не понимали, почему одни и те же значки ведут себя по-разному, поэтому изучили исходный код библиотеки и обнаружили, что ресурсы преобразуются в URI данных только в двух местах: в пути, который считывает собственный фон элемента, и в пути, который считывает теги <img>. Однако правила для псевдоэлементов создаются как текст <style> и добавляются отдельно, поэтому ни один из этих путей их не обрабатывал. Проблемный CSS находился в SCSS хост-приложения, поэтому мы не могли исправить его самостоятельно.
Подсказка обнаружилась в неожиданном месте. Изучая исходный код опции fontEmbedCSS, которую мы использовали только для встраивания шрифтов, мы выяснили, что она принимает полученную строку как есть, создаёт из неё <style> и вставляет его в самое начало клона. Несмотря на название, фактически это была произвольная точка внедрения CSS.
const cssText = options.fontEmbedCSS != null ? options.fontEmbedCSS : ...
if (cssText) {
const styleNode = document.createElement('style')
styleNode.appendChild(document.createTextNode(cssText))
clonedNode.insertBefore(styleNode, clonedNode.firstChild)
}
Код 2. Проверка этой единственной строки решила проблему, которую мы ранее описывали как «не имеющую пути к исправлению».
Поэтому непосредственно перед созданием снимка мы просканировали псевдоэлементы каждого элемента, выбрали только те, что используют url(), преобразовали соответствующие изображения в URI данных, добавили к целевым элементам атрибут-маркер и передали правила CSS, нацеленные на эти маркеры. Критически важны были две вещи. Требовалось использовать !important, поскольку правила, создаваемые библиотекой, вставляются позже и поэтому получают приоритет благодаря порядку в документе. Кроме того, объявления для одинаковых элементов должны были совместно использовать номера маркеров; иначе каждая строка ag-grid копировала бы данные одного и того же изображения флажка по одному разу на строку, из-за чего SVG разрастался бы.
Здесь одно из наших предположений оказалось неверным. Мы считали, что разбор таблиц стилей будет дешевле сканирования каждого элемента, и сначала реализовали именно такой подход, но фактические измерения показали, что сканирование таблиц стилей было гораздо медленнее обхода всех элементов. getComputedStyle оказался дешевле, чем мы ожидали, тогда как сканирование таблиц стилей было затратным. Переход к прямому обходу сократил код и фактически уменьшил время создания снимка с 348 мс до 332 мс.
Снимки, которые были больше экрана
Создав снимки нескольких экранов, мы заметили, что текст в заголовке выглядит крупнее и смещается в сторону. Казалось, что макет сломан, но на самом деле проблема была в масштабировании. Экран отображается с использованием физических пикселей devicePixelRatio на каждый CSS-пиксель, но для опции было задано фиксированное значение pixelRatio: 1. Я использовал масштаб браузера 90%, из-за чего DPR составлял 0.9, поэтому снимок становился в 1.112 раза больше (1÷0.9).
const capturePixelRatio = (): number =>
Math.min(window.devicePixelRatio || 1, 2);
Код 3. В окружении с DPR 2 время создания снимка почти не изменилось; увеличился только размер (120 КБ → 316 КБ).
Пустая полоса под заголовком
Только на снимке появилась пустая полоса высотой 24 пикселя непосредственно под заголовком, и всё содержимое ниже сместилось вниз. Однако при повторном измерении координат элементов клона они идеально совпали. Если на этапе DOM отступа не было, оставалась только стадия растеризации, поэтому я напрямую просканировал пиксели полученного изображения и извлёк «диапазоны по оси y, содержащие чернила». Только заголовок остался на исходной позиции; всё остальное сместилось вниз ровно на 24 пикселя. Поскольку смещение произошло один раз, а не накапливалось, это означало, что что-то в самом начале потока занимало место одной строки.
Виновником оказался <noscript>. Согласно спецификации HTML, правило браузера по умолчанию «скрывать это» применяется только при включённых скриптах. Однако, как уже упоминалось, SVG внутри <img> отображается в контексте, где скрипты отключены, поэтому только при создании снимка этот тег снова стал видимым и занял одну строку. Причина, по которой заголовок не пострадал, заключается в том, что position: fixed помещает его за пределы потока, поэтому всё выглядело как «пустая полоса под заголовком». Это поведение, предусмотренное спецификацией, а не ошибка Chrome.
const EXCLUDED_TAG_NAMES = new Set(['NOSCRIPT', 'SCRIPT']);
filter: (node) => {
if (!(node instanceof Element)) return true;
if (EXCLUDED_TAG_NAMES.has(node.tagName)) return false;
return !EXCLUDED_CLASS_NAMES.some((name) => node.classList?.contains(name));
},
Код 6. После исправления даже линии толщиной 1 пиксель, такие как подчёркивания вкладок и границы заголовков таблицы, вернулись на свои места.
В итоге ключом стали инструменты диагностики
Оглядываясь назад, можно сказать, что большую часть работы в этой задаче мы фактически потратили на создание инструментов. Сравнивая снимки экрана визуально, мы не смогли заметить ни одну из четырёх проблем.
-
Шаг 1 · Сравнение координат с созданным вручную клоном — только 1 из 996 узлов был смещён (во время анимации). Здесь мы ничего не обнаружили.
-
Шаг 2 · Извлечение и сравнение SVG, фактически созданного библиотекой — на этом этапе учитывались стили псевдоэлементов и фильтры. Именно здесь мы обнаружили перенос строк flex-контейнера.
-
Шаг 3 · Прямое сканирование пикселей полученного изображения — мы подсчитали, содержатся ли чернила в каждой строке, и извлекли границы областей содержимого. Именно здесь мы обнаружили <noscript>.
Думаю, главный урок всей этой задачи заключается в том, почему шага 2 оказалось недостаточно. В iframe, используемом для сравнения, скрипты включены, поэтому <noscript> скрыт. В результате координаты идеально совпадали, тогда как смещено было только фактическое изображение. Мы не понимали, что сравнение координат на уровне DOM не способно обнаружить проблемы на стадии растеризации, пока не усовершенствовали инструменты ещё на один шаг.
Текущий конвейер создания снимков
Так выглядит текущий код с применением всех описанных выше решений и исправлений. Он содержит три фрагмента логики, которые временно изменяют экран, а затем восстанавливают его состояние; я оставил комментарии, объясняющие причину каждого шага, в том же порядке.
export const captureViewport = async (): Promise<string | null> => {
try {
// 퀵메뉴 닫힘 등 직전 DOM 변경이 화면에 반영된 뒤 캡처합니다.
await new Promise((r) =>
requestAnimationFrame(() => requestAnimationFrame(r)));
// 의사요소 배경을 data URI 로. pinScrollOffsets 보다 먼저
const { css: pseudoCss, restore: restorePseudo } =
await inlinePseudoBackgrounds();
// 현재 라인을 파악해야 하므로 스크롤 처리보다 먼저 진행
const unpinFlexLines = pinFlexLines();
// scrollTop 은 CSS 가 아니라 런타임 상태여서 복제본이 찾지 못함
// transform 으로 번역해 두면 복제본이 그대로 복사함
const unpinScrollOffsets = pinScrollOffsets();
let result: string | null;
try {
const shot = toJpeg(document.body, {
...CAPTURE_OPTIONS, // 화이트리스트
pixelRatio: capturePixelRatio(), // 실 기기 픽셀 기준
// foreignObject 는 웹폰트에도 네트워크에도 접근할 수 없으므로 필요한 것은 전부 여기에 넣음
fontEmbedCSS: CAPTURE_FONT_EMBED_CSS + GLOBAL_CAPTURE_CSS + pseudoCss,
});
const timeout = new Promise<null>((r) => setTimeout(r, TIMEOUT_MS, null));
result = await Promise.race([shot, timeout]);
} finally {
// 복구는 반드시 역순. 순서를 바꾸면 스크롤 값이 엉뚱하게 잡힘
unpinScrollOffsets();
unpinFlexLines();
restorePseudo();
}
return result; // 실패·타임아웃이면 null — 제보 흐름을 막지 않음
} catch (e) {
console.warn('[captureViewport] failed', e);
return null;
}
};
Код 7. Точка входа для создания снимка.
Здесь я немного подробнее объясню pinScrollOffsets. Это приложение устроено так, что внутренний контейнер main’s прокручивается вместо всего окна браузера, однако позиция прокрутки является состоянием времени выполнения, а не CSS, поэтому в клоне она становится равной 0. В результате, даже если пользователь сообщает о проблеме при просмотре середины экрана, снимок содержит верхнюю часть. У всех трёх библиотек была одна и та же проблема, и даже две библиотеки с соответствующими параметрами оказались неэффективны при такой структуре.
Решением стало преобразование прокрутки в CSS. Мы перемещаем дочерние элементы прокручиваемого контейнера на величину прокрутки с помощью transform: translate(), одновременно сбрасывая позицию прокрутки контейнера в 0. Поскольку эти два изменения компенсируют друг друга, фактический экран не смещается ни на один пиксель, но transform является частью вычисленного стиля, поэтому клон копирует его как есть. Побочным эффектом становится смещение sticky/fixed-элементов, поэтому мы измеряли величину смещения и исправляли её в противоположном направлении. До применения исправления 96 элементов были смещены; после него это число уменьшилось до 1.
Что ещё осталось
Главная проблема заключается в том, что список разрешённых свойств по-прежнему приходится поддерживать вручную. Свойства, не включённые в список, отображаются со стандартными настройками браузера, не вызывая ошибок. Кроме того, каждый хост, в который интегрируется библиотека, представляет собой отдельный репозиторий, а список находится внутри распространяемого пакета, поэтому даже если другая команда обнаружит проблему, ей приходится уведомлять нас и ждать выпуска новой версии. Текущий список был сокращён до минимума, поэтому его можно расширить примерно до 300 записей, исключив только свойства, не связанные с визуальным выводом; мы планируем найти оптимальную точку в пределах одной секунды.
Вторая проблема заключается в отсутствии механизма защиты валидации. Созданные ранее сравнение координат и сканирование пикселей были инструментами, разработанными и использованными разово, а не автоматизированными тестами. Ситуацию, в которой мы едва не исключили часть содержимого, и сегодня можно воспроизвести точно таким же способом. Следующей задачей мы считаем добавление автоматического теста, сравнивающего результаты создания снимков.
Третья проблема — область охвата валидации. До сих пор мы измеряли только два типа экранов: экран со списком в виде таблицы и экран с диаграммой. Мы не знаем, какой CSS будет использоваться на других экранах, а базовая структура остаётся неизменной: если снимок незаметно сместится, никто об этом не узнает.
Именно поэтому мы сохранили существующую возможность для пользователей загружать самостоятельно сделанные фотографии вместо автоматических снимков. Автоматическое создание снимка рассматривается как «удобная функция, включённая по умолчанию, не требующая от пользователя никаких действий», а если ей не удаётся запечатлеть нужную сцену, пользователь может прикрепить изображение самостоятельно. Однако это скорее запасной вариант, чем фундаментальное решение. Мы продолжим рассматривать другие возможные улучшения — например, ограничение области захвата не всем экраном, а областью, указанной пользователем, или использование API захвата экрана браузера.
Заключение
Работая над этой задачей, я понял, что на самом деле это были два разных вида работы. Скорость зависела от поиска того, что было затратным, и ответ дали числа. Точность зависела от возможности увидеть различия, а чтобы найти ответ, сначала пришлось создать инструменты. Каждый раз, когда мы переходили на уровень выше — от визуального осмотра к координатам, а от координат к пикселям, — мы обнаруживали ещё одну проблему.
С этими библиотеками также часто было трудно работать, изучая только документацию. includeStyleProperties используется лишь в определённой ветке, поэтому сначала я ошибочно решил, что в Chrome он «игнорируется». Несмотря на своё название, fontEmbedCSS оказался произвольной точкой для внедрения CSS, тогда как параметры с совершенно подходящими названиями, такие как restoreScrollPosition и clip: 'viewport', при такой структуре не оказывали никакого эффекта. Когда возникала проблема, самым быстрым способом отладки было сначала найти в исходном коде место использования параметра.
Остались и некоторые сожаления. Мы не создали одновременно с белым списком валидацию, которая обеспечивала бы его соблюдение, и не проверили проблемы с точностью в ходе работы над производительностью, обнаружив их только позднее. Тем не менее благодаря этому опыту я теперь понимаю немного больше, чем просто способ использования библиотеки: я лучше представляю, как браузер рисует экран и что исчезает, когда этот результат снова преобразуется в изображение.
Справочные материалы
-
html-to-image — github.com/bubkoo/html-to-image
-
html2canvas — github.com/niklasvh/html2canvas · Официальная документация
-
React Flow — reactflow.dev · github.com/xyflow/xyflow
-
Сохранение компонентов в виде изображений — html2canvas, html-to-image — https://hermesj.tistory.com/4
Owler