488 lines
60 KiB
JavaScript
488 lines
60 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function paragraph(text) {
|
||
return '<p>' + text + '</p>';
|
||
}
|
||
|
||
function heading(text) {
|
||
return '<h2>' + text + '</h2>';
|
||
}
|
||
|
||
function codeBlock(lines) {
|
||
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
|
||
}
|
||
|
||
function figure(src, alt, caption) {
|
||
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||
}
|
||
|
||
function orderedList(items) {
|
||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
}
|
||
|
||
function dataTable(caption, headers, rows) {
|
||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
|
||
}
|
||
|
||
function sourceList(items) {
|
||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function plainText(content) {
|
||
return content
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replaceAll(' ', ' ')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('&', '&')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function bodyText(content) {
|
||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||
}
|
||
|
||
function createRevision(meta, bodyParts, sources) {
|
||
if (sources.length < 2) {
|
||
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
|
||
}
|
||
|
||
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
|
||
const proseLength = bodyText(contentHtml).length;
|
||
|
||
if (proseLength < 5000 || proseLength > 15000) {
|
||
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength);
|
||
}
|
||
|
||
return { ...meta, contentHtml, proseLength };
|
||
}
|
||
|
||
const htmlLivingStandardSnapshot = {
|
||
title: 'WHATWG HTML: immutable snapshot 2022-03-01',
|
||
url: 'https://github.com/whatwg/html/commit/9d42d2ca8523758893cdc8d7de6e0815e4178b81',
|
||
note: 'первичный снимок HTML Standard от 27 февраля 2022 года. Он задаёт алгоритм update the rendering и подчёркивает, что шаги зависят от user agent; он не устанавливает числовой бюджет и не обещает одинаковую последовательность внутренних событий во всех браузерах.',
|
||
};
|
||
|
||
const animationTimingNote = {
|
||
title: 'W3C Timing control for script-based animations, Working Group Note от 22 сентября 2015 года',
|
||
url: 'https://www.w3.org/TR/2015/NOTE-animation-timing-20150922/',
|
||
note: 'датированная W3C Working Group Note от 22 сентября 2015 года, доступная к марту 2022-го. Она описывает requestAnimationFrame как запрос к user agent запланировать animation frame update; это не Recommendation. Fixture не реализует этот API и не измеряет частоту кадров.',
|
||
};
|
||
|
||
const performanceTimelineDraft = {
|
||
title: 'W3C Performance Timeline Level 2, Working Draft от 19 августа 2021 года',
|
||
url: 'https://www.w3.org/TR/2021/WD-performance-timeline-2-20210819/',
|
||
note: 'датированный первичный W3C draft о performance entries и их получении. Он не описывает labels fixture, не задаёт teaching boundary и не превращает synthetic units в настоящий trace или данные Performance panel.',
|
||
};
|
||
|
||
const commonSources = [htmlLivingStandardSnapshot, animationTimingNote, performanceTimelineDraft];
|
||
|
||
const fixtureCommand = [
|
||
'# Проверяется локальная учебная модель, а не страница в Chrome.',
|
||
'node web/scripts/upgrade-2022-03.mjs --verify-fixture',
|
||
'',
|
||
'# PASS fixture: 16/16 assertions',
|
||
'# В output: changed, jankBoundary, noOp, error и rollback.',
|
||
].join('\n');
|
||
|
||
const routeExample = [
|
||
'// Fixture хранит этот контракт внутри script; пример показывает его вход.',
|
||
'const update = {',
|
||
' target: "save-card",',
|
||
' property: "className",',
|
||
' from: "card is-pending",',
|
||
' to: "card is-ready",',
|
||
'};',
|
||
'',
|
||
'// changed route: js-mutation → style → layout → paint → composite',
|
||
'// units: 1 + 2 + 4 + 3 + 1 = 11 synthetic units',
|
||
'// boundary 10 ⇒ teaching label jank-boundary, not milliseconds.',
|
||
'',
|
||
'node web/scripts/upgrade-2022-03.mjs --verify-fixture',
|
||
].join('\n');
|
||
|
||
const rollbackExample = [
|
||
'// В модели обратимое действие — отдельный контракт, не Ctrl+Z браузера.',
|
||
'const apply = { property: "className", from: "card is-pending", to: "card is-ready" };',
|
||
'const rollback = { property: "className", from: "card is-ready", to: "card is-pending" };',
|
||
'',
|
||
'// После rollback result.dom.className снова "card is-pending".',
|
||
'// Отмена проходит тот же порядок стадий и получает свой frameId.',
|
||
'// Никаких DOM API и actual Performance recording этот пример не создаёт.',
|
||
].join('\n');
|
||
|
||
const diagnosticExample = [
|
||
'// Сначала читается изменение, затем его одна frame-запись.',
|
||
'const question = {',
|
||
' mutation: "className: card is-pending → card is-ready",',
|
||
' frameId: "teaching-frame-07",',
|
||
' inspectOrder: ["js-mutation", "style", "layout", "paint", "composite"],',
|
||
'};',
|
||
'',
|
||
'// В реальной записи ищут соответствующий interaction и события этой записи.',
|
||
'// Эти labels не являются именами событий Performance panel.',
|
||
].join('\n');
|
||
|
||
const frameOrder = Object.freeze(['js-mutation', 'style', 'layout', 'paint', 'composite']);
|
||
const baseDom = Object.freeze({ id: 'save-card', className: 'card is-pending', text: 'Сохранить' });
|
||
const boundary = 10;
|
||
|
||
function copyDom(dom) {
|
||
return { id: dom.id, className: dom.className, text: dom.text };
|
||
}
|
||
|
||
function stage(name, units, reason) {
|
||
return Object.freeze({ name, units, unit: 'synthetic-work-units', reason });
|
||
}
|
||
|
||
/**
|
||
* Учебная модель одного render-frame. Она не создаёт DOM, браузер, trace,
|
||
* Performance panel, requestAnimationFrame, сеть, таймер, CPU profile или
|
||
* Web Vitals. `units` — спроектированные целые числа для проверки контракта,
|
||
* а не milliseconds, duration, FPS или измерение реального устройства.
|
||
*/
|
||
function planRenderFrame(input, currentDom = baseDom, frameId = 'teaching-frame-07') {
|
||
const initial = copyDom(currentDom);
|
||
const acceptedProperty = input?.property === 'className';
|
||
const acceptedTarget = input?.target === currentDom.id;
|
||
const declaredFromMatches = input?.from === currentDom.className;
|
||
const hasStringTarget = typeof input?.to === 'string' && input.to.length > 0;
|
||
|
||
if (!acceptedProperty || !acceptedTarget || !declaredFromMatches || !hasStringTarget) {
|
||
return Object.freeze({
|
||
kind: 'error',
|
||
frameId: null,
|
||
dom: Object.freeze(initial),
|
||
stages: Object.freeze([]),
|
||
totalUnits: 0,
|
||
boundary: Object.freeze({ limit: boundary, result: 'not-evaluated' }),
|
||
diagnostic: Object.freeze({
|
||
reason: 'invalid-update-contract',
|
||
expected: 'target save-card, property className, matching from and non-empty to',
|
||
}),
|
||
traceClaim: 'not-a-browser-trace',
|
||
});
|
||
}
|
||
|
||
if (input.to === currentDom.className) {
|
||
return Object.freeze({
|
||
kind: 'no-op',
|
||
frameId: null,
|
||
dom: Object.freeze(initial),
|
||
stages: Object.freeze([]),
|
||
totalUnits: 0,
|
||
boundary: Object.freeze({ limit: boundary, result: 'not-evaluated-no-change' }),
|
||
diagnostic: Object.freeze({ reason: 'same-className-no-frame-planned' }),
|
||
traceClaim: 'not-a-browser-trace',
|
||
});
|
||
}
|
||
|
||
const stages = Object.freeze([
|
||
stage('js-mutation', 1, 'apply-declared-className-change'),
|
||
stage('style', 2, 'resolve-style-after-className-change'),
|
||
stage('layout', 4, 'place-card-after-style-change'),
|
||
stage('paint', 3, 'prepare-card-pixels'),
|
||
stage('composite', 1, 'combine-prepared-card-layer'),
|
||
]);
|
||
const totalUnits = stages.reduce((total, item) => total + item.units, 0);
|
||
|
||
return Object.freeze({
|
||
kind: 'changed',
|
||
frameId,
|
||
dom: Object.freeze({ ...initial, className: input.to }),
|
||
stages,
|
||
totalUnits,
|
||
boundary: Object.freeze({
|
||
limit: boundary,
|
||
result: totalUnits > boundary ? 'jank-boundary' : 'within-teaching-boundary',
|
||
explanation: 'synthetic-work-units only; not a duration or dropped-frame observation',
|
||
}),
|
||
diagnostic: Object.freeze({
|
||
mutation: 'className ' + input.from + ' → ' + input.to,
|
||
performancePanelQuestion: 'find one real recording interaction, then inspect its browser-reported work without mapping labels by name',
|
||
inspectOrder: frameOrder,
|
||
}),
|
||
traceClaim: 'not-a-browser-trace',
|
||
});
|
||
}
|
||
|
||
function runBrowserRenderingFixture() {
|
||
const forwardInput = Object.freeze({
|
||
target: 'save-card',
|
||
property: 'className',
|
||
from: 'card is-pending',
|
||
to: 'card is-ready',
|
||
});
|
||
const forward = planRenderFrame(forwardInput);
|
||
const rollback = planRenderFrame(
|
||
Object.freeze({
|
||
target: 'save-card',
|
||
property: 'className',
|
||
from: 'card is-ready',
|
||
to: 'card is-pending',
|
||
}),
|
||
forward.dom,
|
||
'teaching-frame-08',
|
||
);
|
||
const noOp = planRenderFrame(
|
||
Object.freeze({
|
||
target: 'save-card',
|
||
property: 'className',
|
||
from: 'card is-pending',
|
||
to: 'card is-pending',
|
||
}),
|
||
);
|
||
const error = planRenderFrame(
|
||
Object.freeze({
|
||
target: 'save-card',
|
||
property: 'textContent',
|
||
from: 'Сохранить',
|
||
to: 'Готово',
|
||
}),
|
||
);
|
||
const assertions = Object.freeze({
|
||
changedRouteAccepted: forward.kind === 'changed',
|
||
changedResultUpdatesDeclaredDomProperty: forward.dom.className === 'card is-ready',
|
||
changedRouteKeepsText: forward.dom.text === 'Сохранить',
|
||
changedFrameHasStableId: forward.frameId === 'teaching-frame-07',
|
||
frameStagesHaveExpectedOrder: JSON.stringify(forward.stages.map((item) => item.name)) === JSON.stringify(frameOrder),
|
||
frameStagesAreAllNamed: forward.stages.every((item) => item.reason.length > 0),
|
||
syntheticUnitsAreExplicit: forward.stages.every((item) => item.unit === 'synthetic-work-units'),
|
||
syntheticTotalIsDeterministic: forward.totalUnits === 11,
|
||
jankBoundaryIsDefinedAndCrossed: forward.boundary.limit === 10 && forward.boundary.result === 'jank-boundary',
|
||
boundaryDoesNotClaimMilliseconds: forward.boundary.explanation.includes('not a duration'),
|
||
noOpDoesNotPlanFrame: noOp.kind === 'no-op' && noOp.stages.length === 0 && noOp.frameId === null,
|
||
noOpKeepsDom: noOp.dom.className === baseDom.className,
|
||
errorRouteRejectsUnknownProperty: error.kind === 'error' && error.diagnostic.reason === 'invalid-update-contract',
|
||
errorRouteDoesNotMutateDom: error.dom.className === baseDom.className && error.stages.length === 0,
|
||
rollbackReturnsBaseDom: rollback.kind === 'changed' && rollback.dom.className === baseDom.className,
|
||
rollbackGetsSeparateFrameAndSameOrder: rollback.frameId === 'teaching-frame-08' && JSON.stringify(rollback.stages.map((item) => item.name)) === JSON.stringify(frameOrder),
|
||
});
|
||
|
||
return Object.freeze({
|
||
kind: 'teaching-browser-render-frame-fixture-v1',
|
||
boundary: Object.freeze({ unit: 'synthetic-work-units', limit: boundary, browserObservation: 'not-collected', trace: 'not-collected', webVitals: 'not-collected' }),
|
||
forward,
|
||
rollback,
|
||
noOp,
|
||
error,
|
||
assertions,
|
||
});
|
||
}
|
||
|
||
const practiceArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2022-03-practice-browser-rendering',
|
||
title: 'Медленный рендер без гадания: связать DOM-изменение с одним кадром',
|
||
categories: ['Frontend', 'Производительность'],
|
||
cover: '/assets/editorial/2022/browser-rendering-timeline-2022.svg',
|
||
excerpt: 'Практический маршрут для симптома «страница тормозит»: назвать одно DOM-изменение, собрать одну запись и проверить, где в кадре начинается работа — без подмены модели настоящим trace.',
|
||
readingMinutes: 12,
|
||
},
|
||
[
|
||
paragraph('Проблема «медленный рендер» обычно скрывает четыре разные работы: JavaScript меняет DOM, движок пересчитывает style, раскладывает boxes, готовит paint и собирает результат. Пока всё это называют одним словом, исправление выбирают наугад: переносят обработчик, меняют CSS или добавляют debounce. Цена — дорогая правка, после которой пользовательский сценарий не стал понятнее, а следующая запись Performance panel всё ещё не отвечает, какое действие создало тяжёлый кадр.'),
|
||
paragraph('Начинать стоит не с цветов в панели, а с одного наблюдаемого изменения. Например: нажатие «Сохранить» меняет у карточки <code>className</code> с <code>card is-pending</code> на <code>card is-ready</code>. У этого изменения есть исходный DOM-result, место в обработчике и ожидаемый новый result. Если такой контракт записан, его можно связать с одной interaction в реальной записи и просмотреть события выбранного кадра по порядку. Если контракта нет, trace остаётся длинной лентой, в которой всё похоже на причину.'),
|
||
heading('Сначала назвать границу, а не оптимизацию'),
|
||
paragraph('Полезная единица работы — «одна DOM-мутация в одном пользовательском действии». Не «вся страница», не «React медленный» и не «CSS тяжёлый». Для выбранной мутации выписываем target, property, old value, new value и способ повторить действие. В статье target — <code>save-card</code>, property — <code>className</code>. Это не универсальная форма для любого приложения. Она нужна, чтобы reviewer мог отличить исходную работу от соседней анимации, сетевого ответа или рендера другого компонента.'),
|
||
paragraph('Дальше важно не превратить имена стадий в обещание браузера. HTML Standard описывает update the rendering как алгоритм user agent; его подробности и планирование не дают автору права требовать одинаковый внутренний список событий у всех движков. Поэтому ниже есть учебная модель. Она говорит, какой вопрос задать о результате изменения: была ли JS-мутация, потребовалась ли работа style/layout/paint/composite, где образовалась граница диагностики. Она не говорит, как Chrome, Firefox или Safari обязаны назвать одно событие в trace.'),
|
||
dataTable(
|
||
'Контракт одного изменения до открытия Performance panel',
|
||
['Поле', 'Пример', 'Зачем нужно', 'Чего не доказывает'],
|
||
[
|
||
['Действие', 'нажатие «Сохранить»', 'повторить один сценарий', 'что все другие пути быстры'],
|
||
['DOM before', '<code>card is-pending</code>', 'зафиксировать исходное состояние', 'что style уже вычислен'],
|
||
['DOM after', '<code>card is-ready</code>', 'проверить результат обработчика', 'что был фактический paint'],
|
||
['Граница записи', 'одна interaction и один frame range', 'сузить ленту событий', 'причину без чтения деталей'],
|
||
['Следующий вопрос', 'какая работа следует за mutation', 'выбрать один объект проверки', 'готовое архитектурное решение'],
|
||
],
|
||
),
|
||
heading('Учебный контракт кадра'),
|
||
paragraph('Fixture пакета не открывает браузер. Она работает с локальными object и array, не вызывает DOM API, <code>requestAnimationFrame</code>, сеть, таймер, profiler или Performance API. Для валидной смены класса она строит пять labels: <code>js-mutation</code>, <code>style</code>, <code>layout</code>, <code>paint</code> и <code>composite</code>. У каждого label есть synthetic work units. Это проектные значения, специально выбранные для теста порядка и границы. Они не являются milliseconds, CPU, FPS, LCP, INP, Web Vitals или данными настоящей записи.'),
|
||
codeBlock(fixtureCommand),
|
||
paragraph('В forward route сумма равна 11 synthetic units: 1 + 2 + 4 + 3 + 1. Учебная граница равна 10, поэтому fixture отмечает <code>jank-boundary</code>. Это слово здесь не означает dropped frame и не является измерением задержки. Оно означает ровно одно: заранее описанное правило модели пересечено. Такая фиксация полезна для примера, потому что тест может проверить условие, а текст не обещает скорость устройства, которой никто не измерял.'),
|
||
figure(
|
||
'/assets/editorial/2022/browser-rendering-timeline-2022.svg',
|
||
'Учебная timeline одного изменения: нажатие меняет className карточки с is-pending на is-ready. Затем показаны пять стадий модели — JS mutation, style, layout, paint и composite — с synthetic units 1, 2, 4, 3, 1. Сумма 11 пересекает учебную jank-boundary 10. Рядом явно написано, что это не milliseconds и не trace браузера.',
|
||
'Timeline задаёт порядок вопроса для диагностики. Она не рисует и не измеряет настоящий кадр Performance panel.',
|
||
),
|
||
heading('Как связать изменение с реальной записью'),
|
||
paragraph('В настоящем проекте маршрут короче, чем кажется. Откройте страницу в согласованном состоянии, выполните только выбранное действие и остановите recording. Затем найдите interaction по времени и выделите узкий диапазон вокруг неё. Сначала вернитесь к коду: действительно ли обработчик меняет тот DOM-result, который записан в контракте? Затем читайте подробности выбранного диапазона, а не всю ленту. Если изменение оказалось другим, не переносите вывод с учебной схемы: обновите контракт или возьмите новый scenario.'),
|
||
orderedList([
|
||
'<strong>Симптом.</strong> После конкретного действия экран отвечает неровно, но «медленный рендер» не указывает место в коде.',
|
||
'<strong>Причина.</strong> В одну причину смешаны DOM mutation, style, layout, paint и JavaScript до них.',
|
||
'<strong>Проверка входа.</strong> Запишите target, property, before, after и один способ повторить действие. Убедитесь в DevTools Elements, что after действительно появился.',
|
||
'<strong>Проверка записи.</strong> Соберите одну interaction, выделите её диапазон и найдите работу, связанную с этим действием. Не называйте учебные labels буквальными именами trace events.',
|
||
'<strong>Действие.</strong> Выберите один следующий объект: handler, CSS rule, geometry read или paint boundary. Подготовьте одну обратимую правку.',
|
||
'<strong>Повтор.</strong> Снова выполните тот же контракт. Если DOM result изменился, старую запись нельзя использовать как доказательство нового поведения.',
|
||
]),
|
||
heading('Где часто теряется причина'),
|
||
paragraph('Первый сбой — брать запись после серии кликов. В ней соседствуют несколько mutations, timers, network callbacks и события интерфейса. Даже если внутри есть длинный участок, привязать его к одной строке кода уже трудно. Второй сбой — смотреть только итоговую шкалу и не проверять DOM after. Тогда мы не знаем, рисовали ли именно ожидаемый элемент или обработчик вообще выбрал другую ветку. Третий — считать, что style, layout и paint всегда существуют как отдельные и одинаково подписанные карточки. Это удобная ментальная модель, но не API контракта между приложением и браузером.'),
|
||
paragraph('Сильнее работает обратная формулировка: «после className X → Y я хочу понять, какая работа возникла в выбранной записи и можно ли убрать её без изменения result». Она запрещает лечить симптом удалением видимого эффекта. Если карточка должна стать <code>is-ready</code>, действие не считается успешным, пока after-state не сохранён. Производительность и функциональный result проверяются вместе, а не в разных спорящих задачах.'),
|
||
heading('Ограничения модели'),
|
||
paragraph('Учебный порядок из пяти labels не раскрывает все случаи. Например, изменение может не требовать layout, браузер может отложить часть работы, а compositing зависит от свойств и движка. Модель также не содержит изображений, шрифтов, iframe, анимации, cache, процессора или расширений. Поэтому она не отвечает на вопрос «какой CSS property всегда дешёвый» и не годится для сравнения устройств. Её единственная роль — удержать связь между declared DOM result, порядком проверки и обратимым действием.'),
|
||
paragraph('Источники ниже существуют до марта 2022 года: immutable HTML snapshot от 27 февраля, датированная W3C Working Group Note для animation timing и W3C draft Performance Timeline 2021 года. Они помогают не выдать модель за стандартную шкалу или trace. Ни один из них не задаёт synthetic units и boundary fixture. Перед переносом на приложение зафиксируйте версию браузера, сценарий и способ записи отдельно: это уже свойства реального исследования, которых в пакете намеренно нет.'),
|
||
heading('Следующий проверяемый шаг'),
|
||
paragraph('Возьмите один реальный button или input, составьте таблицу before/after и сделайте единственную запись вокруг одного действия. Результатом следующего шага должен стать не «страница быстрее», а проверяемый артефакт: ссылка на handler, DOM-result и один выделенный диапазон trace. Затем внесите обратимое изменение и повторите тот же сценарий. Если after-state сохранён, а связанная работа стала понятнее, диагностика уже стала дешевле — без придуманной production-метрики.'),
|
||
],
|
||
commonSources,
|
||
);
|
||
|
||
const mechanismArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2022-03-mechanism-browser-rendering',
|
||
title: 'Style, layout и paint: не три виновника, а границы одного кадра',
|
||
categories: ['Frontend', 'Производительность'],
|
||
cover: '/assets/editorial/2022/browser-rendering-pipeline-2022.svg',
|
||
excerpt: 'Причинная модель для DOM-изменения: отделить JavaScript, style, layout, paint и composite, чтобы не выдавать учебную схему за внутренний контракт браузера.',
|
||
readingMinutes: 13,
|
||
},
|
||
[
|
||
paragraph('Проблема возникает, когда на карточке меняется класс, а команда спорит, «это JavaScript» или «это CSS». Оба ответа могут быть неполными. JavaScript может только записать className; дальше браузер решает, нужна ли работа со style, геометрией, пикселями и слоями. Цена неверной модели заметна сразу: удаляют часть handler, хотя DOM-result должен остаться, либо переписывают style rule, который не относится к выбранному действию. В итоге меняется код, но причина в Performance panel остаётся неподтверждённой.'),
|
||
paragraph('Нужна простая причинная граница: изменение значения — это вход; visual result — это выход; между ними есть работа, которую нужно наблюдать на одной записи. Нельзя честно назвать одинаковый конвейер для каждого браузера и свойства. Но можно не смешивать вопросы. Что изменил handler? Какой result стал виден? Какая работа находится после этого действия в выбранном диапазоне? Какое минимальное обратимое действие проверит гипотезу? Такой порядок даёт техническую речь без лозунга «избегайте layout». '),
|
||
heading('Пять labels модели и их ответственность'),
|
||
paragraph('В модели <code>js-mutation</code> применяет объявленное изменение <code>className</code>. <code>style</code> означает, что нужно определить применимые стили после нового класса. <code>layout</code> представляет расчёт положения карточки после style. <code>paint</code> представляет подготовку её пикселей. <code>composite</code> представляет сборку подготовленного результата. Это не протокол браузера и не словарь Performance panel. Это пять вопросов, которые удобно держать рядом с DOM contract, чтобы не назвать одну широкую область причиной до исследования.'),
|
||
dataTable(
|
||
'Причинная модель кадра: что проверяем после DOM mutation',
|
||
['Label модели', 'Вход', 'Результат модели', 'Вопрос к записи', 'Неправильный вывод'],
|
||
[
|
||
['<code>js-mutation</code>', 'handler и old/new className', 'обновлённый DOM object', 'какой код сделал mutation?', '«всё после клика — JavaScript»'],
|
||
['<code>style</code>', 'новый selector state', 'resolved style в модели', 'есть ли связанная работа со style?', '«class всегда означает одну цену»'],
|
||
['<code>layout</code>', 'style, влияющий на геометрию', 'placement карточки', 'какая геометрия стала нужна?', '«layout всегда отдельная карточка»'],
|
||
['<code>paint</code>', 'визуальное представление', 'prepared pixels', 'какой визуальный участок обновился?', '«paint равен CSS rule»'],
|
||
['<code>composite</code>', 'prepared result', 'собранный frame result', 'какая финальная работа видна?', '«это автоматически дешёвый этап»'],
|
||
],
|
||
),
|
||
paragraph('Слово «модель» здесь делает важную работу. HTML Standard говорит о том, что user agent обновляет rendering, но не обещает авторскому коду доступ к одной неизменной внутренней очереди. Trace format Chromium описывает, как события следа представляются в записи, а не контракт «после className всегда идёт именно такой набор работ». Поэтому между документом, записью и проектным решением есть граница: стандарт и source помогают назвать предмет; запись показывает поведение выбранной версии; решение выбирает изменение и способ его отменить.'),
|
||
figure(
|
||
'/assets/editorial/2022/browser-rendering-pipeline-2022.svg',
|
||
'Учебная pipeline-схема: DOM contract className is-pending → is-ready входит в JS mutation, затем проходит через style, layout, paint и composite. Под каждым блоком указаны вопрос к выбранному диапазону записи и synthetic work units. Пунктирная рамка предупреждает, что labels не являются обязательными именами браузерных событий.',
|
||
'Pipeline разделяет вопросы о причине. Она не утверждает, что любой браузер отдаёт пять одинаковых trace-событий.',
|
||
),
|
||
heading('Почему порядок важнее суммы'),
|
||
paragraph('Fixture ставит units 1, 2, 4, 3 и 1. Их сумма 11 пересекает teaching boundary 10. Числа специально не похожи на duration: это не миллисекунды, не CPU time и не FPS. Важен порядок. Если мы видим, что договорённая mutation не произошла, бессмысленно обсуждать paint. Если mutation есть, но DOM after не тот, сначала исправляется функциональный путь. Если result правильный, можно перейти к следующему вопросу и ограничить гипотезу. Сумма в такой модели нужна только для теста определённой ветки, а не для рейтинга производительности.'),
|
||
codeBlock(routeExample),
|
||
paragraph('Валидный route проходит строго через пять labels. В fixture проверяется exact order и то, что у каждой стадии есть причина. Это полезно как защита от случайной правки самой модели: нельзя переставить paint перед layout и продолжать рассказывать тот же учебный маршрут. Но fixture не проверяет движок. Её нельзя запускать вместо Performance panel, сравнивать с отчётом Lighthouse или приклеивать к CI как замер страницы. Она проверяет только контракт статьи: что заявленные вход, стадийность, boundary и result не противоречат друг другу.'),
|
||
heading('Jank boundary — условие остановки, а не диагноз'),
|
||
paragraph('Название <code>jank-boundary</code> здесь намеренно ограничено. Оно срабатывает, когда 11 условных единиц больше 10. Оно не говорит, что пользователь увидел рывок, что кадр пропущен или что устройство не успело выполнить работу. В настоящем исследовании такие выводы требуют настоящей записи, условий запуска и наблюдения конкретного браузера. В учебной модели boundary отвечает на иной вопрос: есть ли заранее определённый случай, в котором автор обязан не продолжать оптимизацию на словах, а перейти к следующей проверке.'),
|
||
paragraph('Это похоже на type check. Компилятор не измеряет удобство интерфейса, но останавливает конкретное несоответствие. Здесь ограничение останавливает расплывчатый вывод: «после клика что-то много происходило». Мы получаем ровно: «для declared change модель прошла определённую границу; теперь надо собрать реальный evidence на этой границе». Такая дисциплина не делает браузер предсказуемее, зато не позволяет из красивой схемы сделать выдуманный benchmark.'),
|
||
heading('Две ветки, которые должны быть пустыми'),
|
||
paragraph('No-op route важен не меньше changed route. Если <code>to</code> совпадает с текущим <code>className</code>, fixture возвращает <code>no-op</code>, не выдаёт frameId и оставляет stages пустыми. В модели это означает: новое действие не объявлено, поэтому конвейер не планируется. Это не утверждение, что реальный браузер никогда не делает никакой работы рядом с одинаковой записью свойства. Это защита контракта: статья не должна изображать кадр, если сама заявляет отсутствие DOM result.'),
|
||
paragraph('Error route столь же полезен. Если передан <code>textContent</code> вместо разрешённого <code>className</code>, либо target/from не совпадают с контрактом, модель возвращает <code>invalid-update-contract</code> и также не меняет DOM object. Ошибка не превращается в «нулевой быстрый кадр». Она означает, что у автора нет основания строить причинную историю. В реальной задаче это момент вернуться к handler, selector или тесту UI, а не искать удачный цвет в диаграмме.'),
|
||
heading('Маршрут: симптом → причина → проверка → действие'),
|
||
orderedList([
|
||
'<strong>Симптом.</strong> Один клик меняет интерфейс неровно, а команда называет причину то JS, то CSS.',
|
||
'<strong>Причина.</strong> Не записаны mutation и result, поэтому разные стадии и соседняя работа попали в одну историю.',
|
||
'<strong>Проверка контракта.</strong> Сверьте target, property, from и to. Если это не className <code>save-card</code>, fixture честно идёт в error route.',
|
||
'<strong>Проверка порядка.</strong> Для changed route удерживайте цепочку mutation → style → layout → paint → composite как порядок вопросов, не как имена trace events.',
|
||
'<strong>Проверка реальности.</strong> В Performance panel выделите только interaction и сопоставьте её с кодом. Если доменный result другой, начните новый сценарий.',
|
||
'<strong>Действие.</strong> Поменяйте один owner: handler, правило style или geometry read. Сохраните способ rollback до повторной записи.',
|
||
]),
|
||
heading('Обратимость не должна ломать визуальный result'),
|
||
paragraph('Проверка улучшения без отмены легко обманывает. Можно убрать className change и увидеть меньше работы, но карточка больше не сообщает готовность. Поэтому fixture создаёт второй frame: rollback меняет <code>card is-ready</code> обратно в <code>card is-pending</code>, получает отдельный <code>teaching-frame-08</code> и проходит тот же порядок labels. Assertion требует, чтобы final DOM равнялся base DOM. Это не browser undo; это минимальная проверка, что мы умеем вернуть исходный функциональный контракт.'),
|
||
codeBlock(rollbackExample),
|
||
paragraph('В приложении rollback может быть feature flag, локальная правка selector или отдельный commit. Форма не важна, пока она возвращает ожидаемый DOM result и может быть повторена. Важно не смешивать rollback с отсутствием изменения: no-op ничего не проверяет, потому что не создаёт нового result. Обратимое изменение создаёт result, наблюдает его, а затем осознанно возвращает старый. Именно поэтому оно пригодно для исследования, а не только для безопасного релиза.'),
|
||
heading('Ограничения и историческая рамка'),
|
||
paragraph('Март 2022 — подходящая рамка для разговора о DevTools trace и frame update, но не повод использовать будущие интерфейсные метрики или поздние UI Performance panel. Здесь нет INP, interaction breakdown, современных DevTools labels и обещаний о RAIL. Ссылки зафиксированы до заданного месяца: WHATWG commit от 27 февраля 2022 года, W3C Working Group Note 2015 года и W3C Performance Timeline draft августа 2021-го. Они первичны или официальны, но не превращают эту схему в нормативную декомпозицию render pipeline.'),
|
||
paragraph('Следующий проверяемый шаг: добавьте в задачу ровно одну строку «mutation, before, after, owner, rollback». После первой реальной записи оставьте ссылку на выделенный range и короткую гипотезу уровня «проверить geometry read в handler», а не «убрать layout». Если следующий trace не подтверждает связь, вернитесь к контракту. Это быстрее, чем защищать оптимизацию, которая была выбрана до того, как появилось доказательство.'),
|
||
],
|
||
commonSources,
|
||
);
|
||
|
||
const fieldArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2022-03-field-browser-rendering',
|
||
title: 'Разбор одного тяжёлого кадра: от DOM result к обратимой проверке',
|
||
categories: ['Производительность', 'Frontend'],
|
||
cover: '/assets/editorial/2022/browser-rendering-diagnosis-rollback-2022.svg',
|
||
excerpt: 'Полевой маршрут для Performance panel: не лечить «медленный рендер» по цвету, а сузить один DOM-result, одну interaction, один вопрос к trace и обратимую правку.',
|
||
readingMinutes: 13,
|
||
},
|
||
[
|
||
paragraph('В записи Performance panel легко найти широкий участок работы и ещё легче назвать его «проблемой рендера». Но из такой фразы нельзя получить безопасную правку. Неизвестно, какой пользовательский шаг начался раньше, какой DOM-result был нужен и что сломается, если просто убрать часть кода. Цена — оптимизация, которая делает график спокойнее, но оставляет кнопку в старом состоянии, убирает сообщение об ошибке или переносит работу в другой момент без объяснения.'),
|
||
paragraph('Полевой разбор начинается с малого: один input, один DOM-result и одна запись. Для примера пользователь нажимает «Сохранить», а карточка меняет <code>className</code> с <code>card is-pending</code> на <code>card is-ready</code>. Мы не утверждаем, что такой сценарий произошёл в реальном продукте. Это учебный case. Его задача — показать маршрут, где каждое утверждение можно заменить артефактом своего проекта: DOM snapshot, ссылка на handler, выбранный range trace и обратимая правка.'),
|
||
heading('Сначала зафиксировать то, что нельзя потерять'),
|
||
paragraph('До записи сформулируйте result в одном предложении: «после submit карточка остаётся той же, но className становится is-ready». Сохраните before/after в Elements или в UI-test expectation. Это удерживает границу функциональности. Если следующая правка убирает visual state, она не проходит, даже если trace выглядит лучше. Производительность не является отдельным экраном без результата; пользователь нажал кнопку именно ради изменения интерфейса.'),
|
||
dataTable(
|
||
'Полевой лист для одного исследования',
|
||
['Артефакт', 'Что записать', 'Критерий готовности', 'Типичная ловушка'],
|
||
[
|
||
['Сценарий', 'одно действие, исходный экран', 'другой человек повторяет его', 'записать длинную серию кликов'],
|
||
['DOM contract', 'target/property/before/after', 'after виден после действия', 'считать код handler доказательством результата'],
|
||
['Recording range', 'одна interaction и узкий диапазон', 'range соотносится со сценарием', 'читать всю timeline сразу'],
|
||
['Гипотеза', 'один owner и один вопрос', 'можно опровергнуть следующей записью', 'объявить виновным весь CSS или framework'],
|
||
['Rollback', 'как вернуть old result', 'после отмены DOM равен before', 'путать отмену с no-op'],
|
||
],
|
||
),
|
||
paragraph('Fixture вводит ровно те же поля, но оставляет их учебными. Changed route получает frame id, пять labels и total 11 synthetic work units. Boundary 10 даёт <code>jank-boundary</code>. Никаких duration, CPU, frame rate или реального UI в ней нет. Поэтому нельзя скопировать число 11 в performance ticket. Можно скопировать структуру вопроса: почему после этой mutation в выбранной записи находится работа, какой owner способен её поменять и как проверить, что DOM result не потерян.'),
|
||
figure(
|
||
'/assets/editorial/2022/browser-rendering-diagnosis-rollback-2022.svg',
|
||
'Диагностическая схема: сначала проверяется DOM result className is-pending → is-ready. Затем выбирается одна interaction и порядок вопросов JS mutation, style, layout, paint, composite. При пересечении учебной boundary создаётся гипотеза об одном owner и обратимое изменение. Ветка no-op останавливает расследование; rollback возвращает is-pending отдельным кадром. Все units отмечены как synthetic, не trace и не milliseconds.',
|
||
'Схема показывает, когда надо остановиться: при неверном DOM result, no-op или invalid contract нельзя делать performance verdict.',
|
||
),
|
||
heading('Читать запись от действия к коду'),
|
||
paragraph('Открыв Performance panel, сначала сузьте время: начните recording на уже подготовленном экране, выполните одно действие и остановите его сразу после visual result. Выберите interaction и найдите связанный участок на main thread. Затем вернитесь в код и спросите, что именно меняет DOM. Не делайте обратное — не берите самый широкий блок и не ищите ему удобное объяснение. У широкого блока могут быть unrelated timers, обработка сети, расширение браузера или соседняя animation. Без связи со сценарием у него нет владельца для правки.'),
|
||
paragraph('После связи переходите по одному вопросу. Если handler читает геометрию после записи style, проверяем этот read. Если DOM result появляется раньше, а видимая работа следует позже, проверяем CSS rule и область update. Если mutation вообще не соответствует before/after, не продолжаем investigation: это другой сценарий. Такой порядок не обещает быстрый результат, но экономит время на спорах. Каждый шаг либо сужает причину, либо честно возвращает к отсутствующему артефакту.'),
|
||
codeBlock(diagnosticExample),
|
||
heading('Три остановки, которые защищают от ложного вывода'),
|
||
paragraph('Первая остановка — error route. В fixture неизвестное property, target или from возвращают <code>invalid-update-contract</code>, stages пусты и DOM не меняется. Это означает: модель не знает, что исследует. В проекте аналогом будет handler, который не является владельцем нужного result, или тест, который не воспроизводит действие. Нельзя назвать такой маршрут «быстрым»: он просто не прошёл входную проверку.'),
|
||
paragraph('Вторая остановка — no-op. Если old value равен new value, model возвращает <code>same-className-no-frame-planned</code>. Реальный браузер может иметь другую сопутствующую работу, но конкретный contract не создал нового visual result. Поэтому recording надо пересобрать: возможно, действие уже было выполнено, state пришёл из cache или выбран неверный элемент. Продолжать искать layout в таком case — значит исследовать не то, что пользователь сделал.'),
|
||
paragraph('Третья остановка — teaching boundary. Она не говорит «нашли jank», а запрещает довольствоваться суммой. Когда 11 больше 10, требуется одно ограниченное действие и повтор. Сумма не выбирает действие сама. Владелец может быть handler, selector, geometry read или код, который ставит className. Без связи с реальной записью она остаётся условной веткой fixture. Это ограничение полезно тем, что не позволяет превратить диаграмму в результат расследования.'),
|
||
heading('Обратимое действие: одна гипотеза, один owner'),
|
||
paragraph('Хорошая первая правка маленькая и отменяемая. Например, временно вынести один geometry read из handler, изменить локальное правило или отключить один visual effect за feature toggle. Нельзя заранее обещать, что это уменьшит layout или paint: сначала надо посмотреть новую запись. У правки есть owner, expected DOM after и путь rollback. Если change не сохраняет result, она не проходит функциональный gate. Если после change связь с записью неясна, evidence недостаточен для сильного вывода.'),
|
||
paragraph('Fixture проверяет обратимость явно. Forward переводит карточку в <code>is-ready</code>; rollback — обратно в <code>is-pending</code>. У rollback отдельный frame id и тот же учебный порядок стадий. Итоговый DOM должен совпасть с base DOM. Этот test не говорит, что браузер отменяет frame или что в реальном интерфейсе нет побочных эффектов. Он удерживает более скромный контракт: автор умеет вернуть наблюдаемое состояние и не выдаёт «удалить изменение» за оптимизацию.'),
|
||
codeBlock(rollbackExample),
|
||
heading('Маршрут: симптом → причина → проверка → действие'),
|
||
orderedList([
|
||
'<strong>Симптом.</strong> В конкретном сценарии интерфейс отвечает неровно, но trace содержит слишком много событий.',
|
||
'<strong>Причина.</strong> Нет связи между user action, DOM result и выбранным range; слова style/layout/paint стали общими ярлыками.',
|
||
'<strong>Проверка result.</strong> Зафиксируйте target, property, before и after. Если after не тот, расследование заканчивается до Performance panel.',
|
||
'<strong>Проверка range.</strong> Повторите один action, выделите одну interaction и сопоставьте её с handler. Отделите этот range от соседних callbacks.',
|
||
'<strong>Проверка гипотезы.</strong> Назовите один owner и один вопрос, который можно опровергнуть новой записью. Не переносите synthetic labels на реальные event names.',
|
||
'<strong>Действие и rollback.</strong> Внесите маленькую обратимую правку, повторите сценарий и проверьте и DOM after, и новый range.',
|
||
]),
|
||
heading('Что положить в результат исследования'),
|
||
paragraph('Хороший результат помещается в несколько строк: версия браузера и условия записи; шаг действия; DOM before/after; ссылка на handler; выбранный range; один наблюдаемый факт; одна гипотеза; commit или toggle для rollback. Здесь нет места фразе «ускорили рендер», пока нет измерения, метода и сравниваемого варианта. Даже если реальная запись стала лучше, сначала нужно назвать, что именно изменилось и при каких условиях. Это язык системного практика 2022 года: достаточно строгий, чтобы другой инженер продолжил проверку, и достаточно скромный, чтобы не придумать эффект.'),
|
||
paragraph('Исторические источники помогают обозначить границы. WHATWG snapshot от 27 февраля 2022 года показывает, что rendering обновляет user agent, а не приложение по фиксированному авторскому списку. W3C Working Group Note объясняет назначение animation frame callback, но fixture не вызывает <code>requestAnimationFrame</code>. Performance Timeline draft описывает работу с performance entries, но учебный result не является entry или trace. На этом материале нельзя обосновать актуальный интерфейс DevTools или поздние performance guidance; для настоящей записи всегда проверяйте версию конкретного браузера.'),
|
||
heading('Следующий проверяемый шаг'),
|
||
paragraph('Выберите один button на своей странице и проведите маршрут один раз без оптимизации. Итогом будут не числа, а четыре артефакта: DOM before/after, ссылка на mutation, один range и одна отменяемая гипотеза. Запустите fixture пакета, чтобы увидеть, что no-op, error и rollback не маскируются под успешный кадр. Затем проведите реальную запись по тому же контракту. Если она не подтверждает связь, это полезный результат: нужно менять сценарий или вопрос, а не публиковать удобную причину.'),
|
||
],
|
||
commonSources,
|
||
);
|
||
|
||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle].map(({ proseLength, ...revision }) => revision);
|
||
|
||
function verifyFixture() {
|
||
const fixture = runBrowserRenderingFixture();
|
||
const failed = Object.entries(fixture.assertions)
|
||
.filter(([, passed]) => passed !== true)
|
||
.map(([name]) => name);
|
||
|
||
if (failed.length > 0) {
|
||
console.error('FAIL fixture: ' + failed.join(', '));
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
|
||
console.log('PASS fixture: ' + Object.keys(fixture.assertions).length + '/' + Object.keys(fixture.assertions).length + ' assertions');
|
||
}
|
||
|
||
if (process.argv.includes('--verify-fixture')) {
|
||
verifyFixture();
|
||
}
|
||
|
||
if (process.argv.includes('--print-revisions')) {
|
||
console.log(JSON.stringify(revisions));
|
||
}
|