Три четверти кода в моём SDK почти никто не запускал

воспроизведение сессии

Встраивая сторонний скрипт, вы тратите трафик своих пользователей на чужие решения. Я со своим скриптом обращался неаккуратно.

Виджет BugDump поставлялся одним файлом на 450 КБ — 117 КБ в gzip — каждому посетителю каждой страницы, куда его встроили. За него платили анонимные читатели маркетинговых страниц. За него платили люди, которые даже не заметили кнопку. Многовато для функции, которой большинство посетителей никогда не пользуется.

Поэтому я измерил, из чего он состоит.

Замер

esbuild точно скажет, куда ушли байты бандла, если попросить у него metafile. Вот мой:

Байты после минификации

Доля

html2canvas-pro

248 965

55,5%

Мой код

117 162

26,1%

@rrweb/record

75 064

16,7%

@rrweb/packer

7 077

1,6%

Семьдесят два процента бандла занимали две библиотеки, и обе относились к функциям, которые работают, только когда кто-то делает что-то конкретное. html2canvas рендерит скриншот страницы. rrweb записывает сессию для воспроизведения. Пока посетитель читает пост в блоге, ни той, ни другой делать нечего.

Очевидная идея — сделать две сборки: «полную» и «лёгкую», без тяжёлых функций. Я её отверг, и причину стоит назвать прямо: это ответ на этапе сборки на вопрос, который решается во время выполнения. html2canvas нужен в момент, когда пользователь нажимает «Скриншот», а этого никакой флаг сборки не предскажет. Клиент на полной сборке всё равно платил бы 249 КБ при каждой загрузке страницы за кнопку, которую большинство его посетителей никогда не нажмёт. А клиент на лёгкой сборке, чей тариф включает воспроизведение сессии, получил бы функцию, которая молча ничего не делает, и код никак не смог бы заметить это несоответствие.

Лучше оставить одну сборку и поменять то, когда что загружается.

Как разбить сборку, которую нельзя разбить

Здесь стало интересно. Моя сборка для тега script — это IIFE, один самовыполняющийся бандл; такой формат нужен, когда на странице нет загрузчика модулей. esbuild поддерживает разбиение кода только при выводе в ESM. Для IIFE splitting: true ничего не делает.

Но разбиение кода и динамический импорт — разные вещи. Динамический import() прекрасно работает внутри обычного скрипта; type="module" нужен только для инструкций import/export. Подвох в том, что esbuild разрешает на этапе сборки всё, что может:

js
// esbuild resolves this and folds the module back into the bundle
const mod = await import('./heavy');

// esbuild cannot resolve this, so it survives as a real runtime import
const mod = await import(someUrl);

Спецификатор должен быть значением, известным только во время выполнения. В этом весь трюк, и поэтому никто не замечает, что такая возможность есть: все пишут вариант с литералом, видят, как модуль встраивается обратно в бандл, и делают вывод, что IIFE разбить нельзя.

Остаётся один вопрос: с какого URL импортировать во время выполнения? Сторонний скрипт не знает, откуда его загрузили, — если только не спросит:

js
const BASE = document.currentScript.src;   // https://bugdump.com/sdk/latest.js
const url = new URL('./bugdump-replay.js', BASE).href;
const { record } = await import(url);

С document.currentScript нужно быть осторожным. Он задан, только пока выполняется код верхнего уровня скрипта. Прочитаете его внутри обработчика клика — получите null. Поэтому он один раз сохраняется в константу на уровне модуля, и весь остальной код берёт его оттуда.

Результат той же сборки из трёх файлов:

Файл

В gzip

Когда загружается

Основной

28 КБ

На каждой странице

Чанк скриншотов

62 КБ

Кто-то делает скриншот DOM

Чанк воспроизведения

27 КБ

Тариф включает воспроизведение сессии

Основной файл уменьшился со 117 до 28 КБ в gzip. Ничего при этом не удалено.

Баг, который вскрыло разбиение

Пока я подключал чанк воспроизведения, нашлось место, которое давно и незаметно работало неправильно.

Воспроизведение сессии — платная функция. Старый код запускал запись сразу при инициализации, потом спрашивал у API BugDump, включает ли тариф проекта воспроизведение, и вызывал stop(), если нет. Формально всё работало. Но выходило, что каждый клиент без воспроизведения сессии скачивал 82 КБ rrweb, запускал запись DOM и тут же её выключал.

Когда от того же ответа стала зависеть загрузка, это исправило обе проблемы сразу. Теперь чанк запрашивается только после того, как сервер ответил «да».

Компромисс здесь настоящий: запись для воспроизведения начинается на один запрос к серверу позже, чем раньше. Я на него пошёл, потому что запись держит скользящее окно с периодическими полными снимками, а первые несколько сотен миллисекунд изменений DOM — не то, на что опирается баг-репорт. Сборщики консоли, сети и действий лёгкие, остаются в основном файле и по-прежнему работают с первого момента.

В чём я ошибался насчёт defer

Я считал, что блокирующий тег script лечится атрибутом defer. Это стандартный совет. И для этой задачи он неверный.

Я собрал страницу, похожую на продакшен, ограничил сеть до 2 Мбит/с с задержкой 100 мс и измерил три варианта. Медиана трёх прогонов:

Тег script

domInteractive

DOMContentLoaded

Без атрибута

290 мс

290 мс

defer

56 мс

287 мс

async

58 мс

58 мс

defer освобождает парсер — domInteractive резко падает. Но отложенный скрипт по спецификации выполняется до события DOMContentLoaded, так что DCL всё равно ждёт окончания загрузки. 287 мс против 290 — это не выигрыш.

async освобождает и то и другое, потому что асинхронный скрипт может выполниться, как только загрузится, без гарантий порядка. Для самодостаточного виджета это ровно то, что нужно: на странице ничему не требуется, чтобы он выполнился в определённый момент.

Многие приложения вешают собственный запуск на DOMContentLoaded. Если вы встроили сторонний скрипт с defer и думаете, что он больше не блокирует страницу, — вы перенесли затраты, а не убрали их.

Теперь мой сниппет выглядит так:

HTML
<script src="https://bugdump.com/sdk/latest.js" async data-api-key="your-api-key"></script>

С этим связана одна оговорка: если вы инициализируете SDK вручную из инлайн-скрипта, при async его может ещё не быть на странице. Оберните вызов в обработчик load — это событие срабатывает только после того, как асинхронные скрипты выполнились.

Что я сказал бы себе раньше

Сначала измеряйте, потом стройте теории. Я мог неделю гадать, какие части тяжёлые. Metafile ответил одной командой, и ответ — 72% в двух необязательных библиотеках — сразу указал на решение.

Выбирайте решения во время выполнения, а не на этапе сборки. Каждый раз, когда вы ловите себя на том, что проектируете матрицу вариантов сборки, проверьте, известно ли на этапе сборки то, по чему вы ветвитесь. У меня — нет.

Фича-флаг, который приходит после загрузки, — не фича-флаг. Если сервер уже знает, положена ли клиенту функция, этот ответ должен решать, загружать ли байты, а не только как себя вести.

Проверяйте советы, в том числе свои. defer — хороший совет для многих скриптов. Для этого — нет, и я узнал об этом только потому, что измерил, а не поверил общепринятому мнению, включая своё.


Новый SDK уже работает. Если вы загружаете его с bugdump.com, он у вас уже есть; добавьте async в свой тег, когда будет удобно. А тем, кто раздаёт SDK со своего сервера, стоит учесть, что теперь это три файла вместо одного и они должны лежать в одной папке, — подробности в справочнике по настройке виджета.

Читайте также