316 lines
37 KiB
JavaScript
316 lines
37 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
const p = (text) => '<p>' + text + '</p>';
|
||
const h2 = (text) => '<h2>' + text + '</h2>';
|
||
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
|
||
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + escapeHtml(alt) + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((cell) => '<th scope="col">' + cell + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
|
||
|
||
function cloneFixed(value) {
|
||
return JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function deepFreeze(value) {
|
||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||
Object.values(value).forEach(deepFreeze);
|
||
Object.freeze(value);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function plainText(html) {
|
||
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function bodyText(html) {
|
||
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||
}
|
||
|
||
const REFERENCES = deepFreeze({
|
||
navigation: { title: 'W3C Navigation Timing Level 2', version: 'Working Draft, 25 February 2026', url: 'https://www.w3.org/TR/2026/WD-navigation-timing-2-20260225/' },
|
||
performance: { title: 'W3C Performance Timeline', version: 'Candidate Recommendation Draft, 21 May 2025', url: 'https://www.w3.org/TR/2025/CRD-performance-timeline-20250521/' },
|
||
resource: { title: 'W3C Resource Timing', version: 'Candidate Recommendation Draft, 20 April 2026', url: 'https://www.w3.org/TR/2026/CRD-resource-timing-20260420/' },
|
||
});
|
||
|
||
function sources(entries) {
|
||
return '<ul>' + entries.map(({ key, use, boundary }) => {
|
||
const ref = REFERENCES[key];
|
||
return '<li><a href="' + ref.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(ref.title) + '</a> — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница применимости: ' + escapeHtml(boundary) + '</li>';
|
||
}).join('') + '</ul>';
|
||
}
|
||
|
||
const FIXED_CRITICAL_PATH_CASES = deepFreeze({
|
||
fast: { ttfb: 80, html: 140, blocking: 1 },
|
||
slowServer: { ttfb: 420, html: 150, blocking: 1 },
|
||
slowScript: { ttfb: 90, html: 150, blocking: 4 },
|
||
invalid: { ttfb: -1, html: 0, blocking: 0 },
|
||
});
|
||
|
||
export function createCriticalPathPlan(id = 'fast') {
|
||
return FIXED_CRITICAL_PATH_CASES[id] ? deepFreeze(cloneFixed(FIXED_CRITICAL_PATH_CASES[id])) : undefined;
|
||
}
|
||
|
||
export function assessCriticalPathPlan(input) {
|
||
if (!input || typeof input !== 'object' || input.ttfb < 0) return { status: 'stop', cause: 'invalid-measurement', action: 'проверить единицы и начало замера' };
|
||
if (input.ttfb > 300) return { status: 'check-server', cause: 'server-wait', action: 'разделить TTFB и время загрузки HTML' };
|
||
if (input.blocking > 2) return { status: 'check-render-blocking', cause: 'blocking-resources', action: 'найти CSS и script, блокирующие путь' };
|
||
return { status: 'baseline', cause: 'no-dominant-signal', action: 'проверить ресурсы и главный поток' };
|
||
}
|
||
|
||
export function runCriticalPathFixture() {
|
||
const checks = [
|
||
['fast', 'baseline'],
|
||
['slowServer', 'check-server'],
|
||
['slowScript', 'check-render-blocking'],
|
||
['invalid', 'stop'],
|
||
].map(([id, expected]) => ({ id, expected, actual: assessCriticalPathPlan(createCriticalPathPlan(id)).status }));
|
||
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual), checks });
|
||
}
|
||
|
||
function revision(meta, parts, referenceEntries) {
|
||
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
|
||
const proseLength = bodyText(contentHtml).length;
|
||
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
|
||
return deepFreeze({ ...meta, contentHtml, proseLength });
|
||
}
|
||
|
||
const refs = [
|
||
{ key: 'navigation', use: 'Определяет навигационные временные метки и связь между этапами загрузки документа.', boundary: 'Рабочий черновик стандарта не сообщает значения метрик вашего браузера и не заменяет полевой сбор.' },
|
||
{ key: 'performance', use: 'Определяет PerformanceEntry, getEntries и PerformanceObserver как основу чтения метрик.', boundary: 'Спецификация не задаёт пороги качества и не гарантирует одинаковый набор entry во всех движках.' },
|
||
{ key: 'resource', use: 'Определяет записи ресурсов, размеры и render-blocking status для сетевого слоя страницы.', boundary: 'Не описывает серверную архитектуру, пользовательский опыт целиком или причинность конкретной задержки.' },
|
||
];
|
||
|
||
const practice = revision({
|
||
slug: 'editorial-2027-06-practice-performance-capstone',
|
||
title: 'Медленная первая загрузка: отделяем TTFB от блокирующих ресурсов',
|
||
categories: ['Frontend', 'Производительность'],
|
||
cover: '/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg',
|
||
excerpt: 'Практический разбор критического пути: что измерить, прежде чем менять JavaScript и CSS.',
|
||
readingMinutes: 14,
|
||
}, [
|
||
p('Пользователь видит пустой экран, а команда получает только одну цифру «страница грузится 2,4 секунды». Цена ошибки — оптимизировать bundle, когда 1,6 секунды ушло до первого байта HTML, или ускорить сервер, когда браузер ждёт блокирующий CSS. Эти причины требуют разных владельцев и разных изменений.'),
|
||
p('Разделим путь на три наблюдения: время до первого байта, получение HTML и блокировку от ресурсов. Учебный локальный сервер намеренно задаёт задержку, поэтому результат легко повторить. В браузере дополнительно прочитаем Navigation Timing и Resource Timing, но не будем выдавать одну метрику за пользовательский опыт.'),
|
||
h2('Критический путь начинается до JavaScript'),
|
||
p('Браузер не может построить документ, пока не получил HTML. После этого parser встречает CSS, обычные script и изображения, которые меняют порядок работы. TTFB (time to first byte) отвечает только за ожидание первого байта ответа. Он не включает весь HTML и тем более не описывает время до полезного пикселя.'),
|
||
table('Разделяем время загрузки', ['Участок', 'Метрика', 'Что проверяем'], [
|
||
['До ответа', 'responseStart − requestStart', 'сервер, сеть, proxy'],
|
||
['HTML', 'responseEnd − responseStart', 'размер и передача документа'],
|
||
['Ресурс', 'responseEnd − startTime', 'CSS, script, font, image'],
|
||
['Главный поток', 'long task или task duration', 'парсинг и выполнение JS'],
|
||
]),
|
||
figure('/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg', 'Критический путь страницы от запроса до главного содержимого: серверная задержка, HTML, CSS и JavaScript.', 'Схема разделяет участки времени. Рядом с каждой задержкой нужен собственный замер, иначе исправление выбирается по догадке.'),
|
||
h2('Локальный сервер с двумя режимами'),
|
||
p('Вход примера — query-параметр <code>mode</code>. Для <code>slow</code> сервер ждёт 300 миллисекунд перед заголовками, для <code>fast</code> отвечает сразу. Клиент измеряет время до получения тела. Ожидаемый результат: медленный режим имеет больший TTFB, а не «плохой JavaScript». Запуск безопасен: сервер слушает только loopback.'),
|
||
code([
|
||
"import { performance } from 'node:perf_hooks';",
|
||
"import { createServer as createHttpServer } from 'node:http';",
|
||
'',
|
||
"const server = createHttpServer((request, response) => {",
|
||
" const delay = new URL(request.url, 'http://local').searchParams.get('mode') === 'slow' ? 300 : 0;",
|
||
' setTimeout(() => {',
|
||
" response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });",
|
||
" response.end('<main>ready</main>');",
|
||
' }, delay);',
|
||
'});',
|
||
'',
|
||
"server.listen({ host: '127.0.0.1', port: 0 }, async () => {",
|
||
' const address = server.address();',
|
||
' const port = address.port;',
|
||
' for (const mode of [\'fast\', \'slow\']) {',
|
||
' const started = performance.now();',
|
||
" const response = await fetch('http://127.0.0.1:' + port + '/?mode=' + mode);",
|
||
' await response.text();',
|
||
" console.log(mode, Math.round(performance.now() - started), response.status);",
|
||
' }',
|
||
' server.close();',
|
||
'});',
|
||
].join('\n')),
|
||
p('Ожидаемый вывод — две строки со статусом <code>200</code>, причём <code>slow</code> примерно на 300 миллисекунд больше. Точное число зависит от машины, поэтому сравниваем режимы в одном запуске. Так появляется проверяемый симптом: различие находится на серверном ожидании. Для browser-показателей нужен браузерный прогон с тем же HTML и набором ресурсов.'),
|
||
h2('Что читать в Navigation Timing'),
|
||
p('Объект <code>PerformanceNavigationTiming</code> содержит временные точки навигации. Для диагностического сообщения достаточно вывести <code>requestStart</code>, <code>responseStart</code>, <code>responseEnd</code> и <code>domContentLoadedEventEnd</code>. Вычитание даёт длительность участка, но абсолютное число нужно сопоставлять с условиями: cache, сеть, устройство и режим браузера меняют картину.'),
|
||
p('Не называйте <code>domContentLoaded</code> временем готовности экрана. Событие означает завершение разбора DOM и отложенных скриптов, но изображения и отрисовка могут продолжаться. FCP (first contentful paint) и LCP (largest contentful paint) относятся к визуальному результату, поэтому TTFB и DOMContentLoaded нельзя использовать как их замену. Для пользовательского симптома нужен отдельный критерий визуальной готовности.'),
|
||
h2('Блокирующие ресурсы и компромисс'),
|
||
p('Обычный CSS блокирует построение стилей, а синхронный script может остановить parser. Перенести всё в <code>async</code> недостаточно: порядок выполнения изменится, а код, который ожидает DOM или глобальную библиотеку, может сломаться. <code>defer</code> сохраняет порядок отложенных скриптов, но не убирает размер передачи.'),
|
||
p('Поле <code>renderBlockingStatus</code> полезно читать только при поддержке конкретного браузера. Отсутствующее значение означает «нет данных», а не «ресурс точно не блокирует». Поэтому сверяйте запись с HTML-атрибутами и наблюдаемым изменением первого экрана.'),
|
||
table('Выбор действия по наблюдению', ['Наблюдение', 'Действие', 'Цена'], [
|
||
['высокий TTFB', 'профилировать серверный handler', 'нагрузка на backend и зависимости'],
|
||
['большой HTML', 'сжать или уменьшить ответ', 'сложнее шаблон и кэширование'],
|
||
['ранний блокирующий CSS', 'разделить critical и остальное', 'риск рассинхрона стилей'],
|
||
['долгий script', 'разбить работу или отложить', 'сложнее порядок и состояние'],
|
||
]),
|
||
h2('Порядок проверки'),
|
||
ol([
|
||
'Записать пользовательский симптом и выбрать одну страницу с фиксированным URL.',
|
||
'Сравнить TTFB и время получения HTML на холодном и тёплом кэше.',
|
||
'Прочитать navigation entry и выписать длительности без округления до одной общей цифры.',
|
||
'Посмотреть resource entries и найти CSS, script или font, которые начинают работу раньше нужного.',
|
||
'Изменить один участок: сервер, размер HTML или порядок загрузки ресурса.',
|
||
'Повторить тот же прогон и сравнить не только среднее, но и медиану или p95.',
|
||
]),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Node-замер не знает о layout, paint, LCP и работе главного потока браузера. Navigation Timing — временная модель навигации, а не готовый ответ о причине. Сетевые записи могут быть скрыты политикой приватности или кросс-доменными ограничениями. Поэтому локальный пример проверяет только серверный участок.'),
|
||
p('Следующим шагом подключите браузерный прогон к тестовой странице и сохраните четыре ряда: navigation, resources, long tasks и визуальную метрику. Меняйте одну причину за раз. Если TTFB стабилен, а экран задерживает CSS или JS, не переносите работу обратно на сервер.'),
|
||
], refs);
|
||
|
||
const mechanism = revision({
|
||
slug: 'editorial-2027-06-mechanism-performance-capstone',
|
||
title: 'Waterfall без иллюзий: как понять, какой ресурс блокирует страницу',
|
||
categories: ['Frontend', 'Производительность'],
|
||
cover: '/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg',
|
||
excerpt: 'Разбираем waterfall по зависимостям, очереди и блокировке, а не по длине полоски на графике.',
|
||
readingMinutes: 15,
|
||
}, [
|
||
p('В waterfall длинная полоса у шрифта выглядит как причина медленной страницы, но шрифт мог начать загрузку после того, как главный контент уже отрисовался. Цена ошибки — менять порядок ресурсов без понимания зависимости, получить вспышку нестилизованного текста и увеличить количество сетевых запросов.'),
|
||
p('Механика здесь простая: ресурс задерживает страницу только через конкретную зависимость — parser, CSSOM, layout, script или компонент, который ждёт ответ. Воспроизводимый пример покажет, как собрать записи ресурсов в браузере и отфильтровать их по времени старта и статусу блокировки.'),
|
||
h2('Полоса времени не объясняет причинность'),
|
||
p('Waterfall отвечает на вопрос «когда ресурс был активен». Он не отвечает на вопрос «почему экран ждал». Для причинности нужны DOM-порядок, атрибуты <code>rel</code> и <code>async/defer</code>, тип ресурса и событие, которое использовало результат. Две полосы одинаковой длины могут иметь разную цену: одна относится к hero-изображению, другая — к аналитике после загрузки.'),
|
||
table('Читаем запись ресурса', ['Признак', 'Что он даёт', 'Следующая проверка'], [
|
||
['name', 'какой URL запрошен', 'секреты и query удалены?'],
|
||
['initiatorType', 'кто начал запрос', 'parser, script, css или fetch'],
|
||
['startTime', 'когда началась активность', 'какое событие было раньше'],
|
||
['responseEnd', 'когда пришёл ответ', 'кто действительно ждал'],
|
||
['renderBlockingStatus', 'признак блокировки', 'поддерживает ли его браузер'],
|
||
]),
|
||
figure('/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg', 'Матрица проверки ресурса: инициатор, время, статус блокировки, потребитель и действие.', 'Матрица не сортирует ресурсы по размеру. Она связывает строку waterfall с проверяемой зависимостью страницы.'),
|
||
h2('Учебная страница с наблюдателем'),
|
||
p('Это локальный HTML-фрагмент. Вставьте его в страницу, запущенную любым статическим сервером, откройте DevTools и посмотрите консоль. Входом являются browser PerformanceEntry. Результат — компактный список ресурсов, которые начали работу до события DOMContentLoaded, с указанием инициатора. Если браузер не поддерживает поле, выводится <code>unknown</code>, а не придуманное значение.'),
|
||
code([
|
||
"const started = performance.getEntriesByType('resource').map((entry) => ({",
|
||
' name: new URL(entry.name).pathname,',
|
||
' initiator: entry.initiatorType,',
|
||
' start: Math.round(entry.startTime),',
|
||
' end: Math.round(entry.responseEnd),',
|
||
" blocking: entry.renderBlockingStatus ?? 'unknown',",
|
||
'}));',
|
||
'',
|
||
"const navigation = performance.getEntriesByType('navigation')[0];",
|
||
'const domEnd = navigation?.domContentLoadedEventEnd ?? Infinity;',
|
||
'console.table(started.filter((entry) => entry.start < domEnd));',
|
||
].join('\n')),
|
||
p('Поля <code>start</code> и <code>end</code> — миллисекунды относительно начала навигации. Фильтр оставляет ресурсы, начавшие работу до DOMContentLoaded, но это ещё не список причин. Для каждой строки проверьте потребителя: script мог запросить JSON после DOM, а CSS мог повлиять на первый layout. Наблюдатель сокращает поиск, а не заменяет чтение кода.'),
|
||
h2('Как CSS и script меняют порядок'),
|
||
p('CSS, подключённый в head, нужен браузеру для построения стилей и поэтому часто попадает в раннюю часть цепочки. Обычный script без атрибута может остановить parser. <code>defer</code> загружает скрипт параллельно и исполняет его после разбора документа в порядке появления. <code>async</code> отдаёт порядок загрузке и подходит только для независимого кода.'),
|
||
p('Любое изменение атрибута проверяется через зависимость. Если script обращается к DOM-узлу, defer обычно подходит. Если он ожидает библиотеку выше, async может дать race. Если стиль нужен только внутри раскрываемого блока, его можно разделить, но нужно проверить скачок layout и доступность. Критерий решения — не «полоса стала короче», а перестал ли нужный компонент ждать ресурс.'),
|
||
h2('Размер и приоритет не одно и то же'),
|
||
p('Большой ресурс может загружаться низким приоритетом после первого экрана, а маленький CSS блокировать парсер. Проверяйте размер вместе с инициатором, типом, приоритетом и моментом потребления. В Resource Timing есть данные о времени и размерах, но доступ к кросс-доменным значениям зависит от заголовков и политики браузера.'),
|
||
table('Три изменения и их риск', ['Изменение', 'Положительный эффект', 'Что сломать легко'], [
|
||
['defer для независимого script', 'parser не ждёт выполнение', 'код, рассчитывающий на ранний глобальный объект'],
|
||
['preload для критичного ресурса', 'запрос начинается раньше', 'лишний запрос и неправильный тип'],
|
||
['разделение CSS', 'меньше раннего CSS', 'FOUC, layout shift и порядок правил'],
|
||
]),
|
||
h2('Порядок чтения waterfall'),
|
||
ol([
|
||
'Зафиксировать страницу, режим кэша, viewport и браузер, иначе строки несопоставимы.',
|
||
'Отфильтровать resource entries по initiatorType и времени до DOMContentLoaded.',
|
||
'Для каждой ранней строки найти тег, script или CSS-правило, которое её инициировало.',
|
||
'Проверить, кто читает ответ и действительно ли этот потребитель влияет на первый экран.',
|
||
'Изменить один атрибут или порядок загрузки и повторить запись.',
|
||
'Сравнить визуальный результат, long tasks и сетевой waterfall; не принимать сокращение одной полосы как успех.',
|
||
]),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('PerformanceObserver и renderBlockingStatus зависят от поддержки браузера. Сетевые записи скрывают часть кросс-доменных данных, а локальная страница не отражает задержки мобильной сети. Визуальный результат зависит от шрифтов, viewport и устройства. Поэтому наблюдатель полезен как диагностический слой, но не как единственный критерий качества.'),
|
||
p('Следующий шаг — сохранить в тестовом отчёте таблицу «ресурс → инициатор → потребитель → действие». В ней должны быть хотя бы один оставленный ресурс и один ресурс, который вы сознательно отложили. Это помогает объяснить компромисс и не возвращать preload или async при следующем изменении шаблона.'),
|
||
], refs);
|
||
|
||
const field = revision({
|
||
slug: 'editorial-2027-06-field-performance-capstone',
|
||
title: 'Измерение производительности, которому можно верить: фиксируем условия и p95',
|
||
categories: ['Производительность', 'Инженерные практики'],
|
||
cover: '/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg',
|
||
excerpt: 'Как оформить повторяемый замер страницы, чтобы одна удачная попытка не стала решением.',
|
||
readingMinutes: 14,
|
||
}, [
|
||
p('Фраза «после оптимизации стало быстрее» не даёт проверить ни исходное условие, ни размер выигрыша. Цена ошибки — принять удачный прогрев кэша за эффект изменения и откатить полезную работу, когда другой браузер покажет обратный результат. Измерение начинается с описания входа, а не с красивого числа.'),
|
||
p('Соберём маленький локальный стенд: сервер отдаёт страницу с фиксированной задержкой, клиент делает серию запросов и считает медиану и p95. Такой тест не заменяет браузерный прогон, но показывает дисциплину измерения: одинаковый URL, количество повторов, единица времени, выбросы и границы вывода.'),
|
||
h2('Число имеет смысл только вместе с условиями'),
|
||
p('Для сравнения нужны версия сборки, браузер, viewport, режим кэша, сеть, размер данных и порядок действий. Если поменялось сразу пять условий, разность не принадлежит одному изменению. В отчёте сначала фиксируйте входные данные, затем распределение наблюдений и только потом пишите вывод.'),
|
||
table('Минимальный протокол замера', ['Параметр', 'Пример значения', 'Зачем'], [
|
||
['URL и commit', '/catalog, abc123', 'что именно измеряли'],
|
||
['Браузер и viewport', 'Chromium, 1280×800', 'какой клиент выполнял код'],
|
||
['Кэш', 'cold или warm', 'что уже было в storage'],
|
||
['Повторы', '20', 'насколько устойчиво число'],
|
||
['Метрика', 'TTFB, p95', 'какой участок сравниваем'],
|
||
]),
|
||
figure('/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg', 'Цикл замера: зафиксировать условия, выполнить повторения, посчитать распределение, изменить один фактор и сравнить.', 'Цикл возвращает к исходным условиям после каждого изменения. Без этого p95 не показывает, что именно дало разницу.'),
|
||
h2('Локальный p95 на серии запросов'),
|
||
p('Вход кода — число повторов и локальный endpoint. Сервер отвечает после одной и той же задержки, а клиент измеряет round trip. Ожидаемый результат — отсортированный список и p95, близкий к заданной задержке плюс накладные расходы Node. Это рабочий замер локального HTTP-пути, а не оценка интерфейса.'),
|
||
code([
|
||
"import { performance } from 'node:perf_hooks';",
|
||
"import { createServer as createMeasureServer } from 'node:http';",
|
||
'',
|
||
"const server = createMeasureServer((request, response) => {",
|
||
' setTimeout(() => response.end(\'ready\'), 40);',
|
||
'});',
|
||
'',
|
||
'function percentile(values, rank) {',
|
||
' const sorted = [...values].sort((a, b) => a - b);',
|
||
' return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * rank) - 1)];',
|
||
'}',
|
||
'',
|
||
"server.listen({ host: '127.0.0.1', port: 0 }, async function measure() {",
|
||
' const address = server.address();',
|
||
' const port = address.port;',
|
||
' const samples = [];',
|
||
' for (let index = 0; index < 20; index += 1) {',
|
||
' const started = performance.now();',
|
||
" await (await fetch('http://127.0.0.1:' + port)).text();",
|
||
' samples.push(performance.now() - started);',
|
||
' }',
|
||
" console.log({ count: samples.length, median: percentile(samples, 0.5).toFixed(1), p95: percentile(samples, 0.95).toFixed(1) });",
|
||
' server.close();',
|
||
'});',
|
||
].join('\n')),
|
||
p('В результате <code>count</code> равен 20, медиана и p95 находятся около 40 миллисекунд. На загруженной машине числа выше, но их разброс остаётся видимым. Функция percentile здесь намеренно короткая: на малой выборке p95 — один элемент, поэтому отчёт должен сохранять размер серии и сами условия, а не только округлённый итог.'),
|
||
p('Cold и warm cache нельзя смешивать в одной выборке. Если нужен общий отчёт, маркируйте режим каждой строки и сравнивайте одинаковые поднаборы. Иначе p95 может вырасти только потому, что несколько первых запросов скачали ресурсы.'),
|
||
h2('Среднее скрывает хвост'),
|
||
p('Среднее удобно для оценки общей стоимости, но пользователь попадает в хвост распределения. p95 означает, что 95 процентов измерений не превышают значение, а пять процентов превышают. Это не «медленность каждого запроса». При малом числе повторов один выброс сильно меняет p95, поэтому сравнивайте одинаковые объёмы выборки.'),
|
||
p('Для страницы полезно хранить несколько метрик отдельно: TTFB, время до DOMContentLoaded, LCP, long tasks и размер ответа. Нельзя складывать их в один балл без явного решения. LCP зависит от содержимого и браузера, а TTFB — от серверной и сетевой части. Изменение, которое улучшило один участок, может ухудшить другой.'),
|
||
h2('Как не перепутать корреляцию с причиной'),
|
||
table('Сигнал и безопасный вывод', ['Сигнал', 'Можно сказать', 'Нужна дополнительная проверка'], [
|
||
['TTFB выше', 'ответ начал приходить позже', 'backend, сеть и proxy'],
|
||
['p95 вырос', 'хвост стал тяжелее в этих условиях', 'нагрузка и размер выборки'],
|
||
['script появился раньше', 'порядок ресурсов изменился', 'влияние на render и DOM'],
|
||
['LCP изменился', 'визуальный критерий изменился', 'какой элемент стал LCP'],
|
||
]),
|
||
p('Слово «корреляция» полезно только вместе с ограничением. Если TTFB и LCP выросли одновременно, это повод проверить сервер, но не доказательство, что сервер — единственная причина. Свяжите метрики по одному запуску, сохраните network log и посмотрите, какой элемент стал самым поздним крупным контентом.'),
|
||
h2('Порядок замера'),
|
||
ol([
|
||
'Зафиксировать URL, код сборки, браузер, viewport, сеть и режим кэша.',
|
||
'Прогреть или очистить кэш одинаковым способом перед каждым набором.',
|
||
'Сделать одинаковое число повторов и сохранить сырые значения времени.',
|
||
'Посчитать median, p95 и диапазон, указав единицу измерения.',
|
||
'Изменить один фактор и повторить тот же набор.',
|
||
'Записать, какая метрика улучшилась, какая ухудшилась и какие вопросы остались открытыми.',
|
||
]),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Локальный HTTP-сервер не моделирует мобильную сеть, CPU браузера, кэш CDN и работу layout. Маленькая серия не годится для долгих выводов. W3C описывает интерфейсы измерения, но не устанавливает ваш порог приемки. Порог должен учитывать страницу, устройство и стоимость задержки.'),
|
||
p('Следующий шаг — добавить к локальной серии один контролируемый browser-run и сохранить raw JSON вместе с условиями. Проверяйте распределение, а не только среднее. Если результат меняется после очистки кэша или при другом viewport, сначала уточните входные данные, затем выбирайте оптимизацию.'),
|
||
], refs);
|
||
|
||
export const revisions = deepFreeze([practice, mechanism, field]);
|
||
|
||
export function verifyRevisionsAgainstFixture() {
|
||
const fixture = runCriticalPathFixture();
|
||
const articleChecks = revisions.map((item) => {
|
||
const text = bodyText(item.contentHtml);
|
||
return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|затрат|потер)/i.test(text.slice(0, 1100)) && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml);
|
||
});
|
||
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
|
||
}
|
||
|
||
if (process.argv.includes('--verify-fixture')) {
|
||
const result = verifyRevisionsAgainstFixture();
|
||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||
if (!result.accepted) process.exitCode = 1;
|
||
}
|
||
|
||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|