Перейти к содержимому
FastOffice

Миллион строк

Цена трёх жестов на предельной книге — прокрутки, протяжки и масштаба: кадр p50 и p95 и обращения к ядру числами, а не обещанием.

цена трёх жестов на предельной книге — прокрутки, протяжки и масштаба: кадр p50 и p95 и обращения к ядру числами, а не обещанием
открыть в новой вкладке ↗Работающий пример. Ниже — его код целиком, файл за файлом.

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

Страница, которую открывает браузер.

index.html
<!doctype html>
<!--
Миллион строк — НАСТОЯЩИЙ МАЛЕНЬКИЙ ПРОЕКТ.
Не «страница витрины», а тот проект, который встраивающий копирует к себе
целиком. Галерея примеров ЗАПУСКАЕТ эти файлы и ПОКАЗЫВАЕТ их же.
РАЗМЕТКА ПАНЕЛИ ОБЪЯВЛЕНА ЗДЕСЬ, А НЕ СОБИРАЕТСЯ КОДОМ. Меняются в ней ровно
три места — числа, вердикт и род жеста, — и меняет их `src/main.tsx`. Панель,
которую код перерисовывал бы целиком, теряла бы галочку «мерить цену жеста»
на каждом кадре: она часть доказательства, а не украшение.
МАСШТАБ МЕНЯЕТСЯ ОРГАНОМ ПРОДУКТА, А НЕ СВОИМ. Внизу
стоит НАСТОЯЩАЯ строка состояния из `@f1/sheets-ui` — тот же ползунок, те же
«−»/«+» по остановкам Excel, та же команда `view.zoom.set:`. Второй ползунок
«для примера» показывал бы цену не того, чем пользуются.
-->
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Миллион строк — @f1</title>
</head>
<body>
<div data-example="stress" class="flex h-screen w-screen flex-col bg-base-100 text-base-content">
<header
class="flex flex-wrap items-center gap-3 border-b border-base-300 px-4 py-2"
data-stress-panel
data-stress-verdict="жеста ещё не было"
>
<div class="me-auto">
<div class="text-sm font-semibold" id="book"></div>
<div class="text-xs opacity-70">
Три жеста, три цены: прокрутите лист колесом, протяните выделение мышью и измените
масштаб — ползунком внизу или Ctrl+колесом. Ctrl+End уводит в конец книги. Числа
объявляет сам лист (свойство onGesture), страница их только показывает.
</div>
<!-- Предел полосы называет сам лист, и только вместе с жестом: он
важен ровно тогда, когда по полосе едут. -->
<div id="reach"></div>
</div>
<!-- Числа появляются ПОСЛЕ жеста, и у каждого рода они СВОИ: прокрутка,
протяжка и масштаб стоят разного, а одна строка «последний жест»
показывала бы цену того, о чём не спрашивали. До жеста в его строке
стоит «мерить нечего», а не нули и прочерки: они читались бы как
«быстро». -->
<div id="readings">
<div class="text-sm opacity-70" data-stress-empty>жеста ещё не было — мерить нечего</div>
</div>
<span class="badge badge-ghost" data-stress-badge>жеста ещё не было</span>
<span id="kind"></span>
<label class="label cursor-pointer gap-2 text-xs">
<input type="checkbox" class="checkbox checkbox-sm" checked data-stress-meter />
мерить цену жеста
</label>
</header>
<div id="app" class="flex min-h-0 flex-1 flex-col"></div>
<!-- Строка состояния ПРОДУКТА: масштаб живёт здесь, как в Excel. Рисует её
Preact — это компонент пакета `@f1/sheets-ui`, и монтирует его хозяин
страницы в СВОЙ узел. -->
<div id="status"></div>
</div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

Точка входа: здесь лист создаётся и сюда же приходят события.

src/main.tsx
import { render } from "preact";
import { createSheetsCore, type SheetsCore } from "@f1/preset-sheets-core";
import { GESTURE_BUDGET, type GridGesture, type GridGestureKind } from "@f1/grid-ui";
import { DEFAULT_STATUS_INDICATORS, StatusBar } from "@f1/sheets-ui";
import { ZOOM_DEFAULT } from "@f1/sheets";
import { BOOK, transport } from "./book.js";
import { hostAsks } from "./host.js";
import "./styles.css";
/**
* СТРЕСС: ПРЕДЕЛЬНАЯ КНИГА И ЧИСЛА РЯДОМ.
*
* Обычно на такое отвечают общими словами про эффективную работу с
* большими данными. У нас цена жеста давно меряется в наших проверках, но
* их читает тот, кто их запустил, а пример открывает любой — и до этого среза
* ему предлагалось верить на слово.
*
* ЧЕСТНОСТЬ ЗДЕСЬ — ГЛАВНОЕ ТРЕБОВАНИЕ, а не украшение. Правила такие:
*
* · ЧИСЛА ИЗМЕРЯЮТСЯ, а не рисуются. Пока жеста не было, стоит «жеста ещё не
* было» — не нули и не прочерки, которые читаются как «быстро».
* · ПОРОГИ БЕРУТСЯ ИЗ ОДНОГО ОБЪЯВЛЕНИЯ (`GESTURE_BUDGET`), того же, по
* которому краснеют наши проверки. Своя копия числа означала бы, что пример вправе
* быть довольным тогда, когда проверка красная.
* · ВЫХОД ЗА БЮДЖЕТ ПОКАЗЫВАЕТСЯ, а не прячется: вердикт «мимо бюджета» —
* такая же законная надпись, как «в бюджете». Пример, который умеет только
* зеленеть, — это реклама, а не замер.
*
* ТРИ ЖЕСТА, А НЕ ОДИН. Масштаб — такой же показатель, как прокрутка, и
* предельная книга обязана показывать и его. Масштаба здесь не было ни
* органом, ни числом, и это был не пропуск ПОКАЗА: собранный лист не передавал
* сетке масштаба вовсе, поэтому Ctrl+колесо объявляло жест, который ничего не
* менял. Теперь цена каждого рода живёт СВОЕЙ строкой и не затирается
* следующим жестом: прокрутка, протяжка и масштаб стоят разного, и одна строка
* «последний жест» отвечала бы не на тот вопрос. Рядом с ценой стоит МАСШТАБ,
* при котором она снята, — потому что прокрутка при 25 % и при 100 % это
* разная работа: в то же окно попадает вчетверо больше ячеек.
*
* ОРГАН МАСШТАБА — ПРОДУКТОВЫЙ. Внизу настоящая строка состояния из
* `@f1/sheets-ui`: тот же ползунок, те же остановки Excel у «−»/«+», и
* объявляет она ту же команду `view.zoom.set:`, которую исполняет собранный
* лист. Свой ползунок рядом мерил бы не то, чем пользуются.
*
* ПРИМЕР ТОЛЬКО СЛУШАЕТ. Цена жеста — СВОЙСТВО виджета (`onGesture`), и о
* внутренностях листа проект не знает ничего: он подписался и показывает
* присланное. Ни одного обращения к `document` за пределами своих узлов здесь
* нет — а раньше пример слушал прокрутку на захвате у документа и искал в нём
* чужой узел, потому что сетка не объявляла наружу ни жеста, ни его цены.
*
* ЖЕСТ ДЕЛАЕТ ЧЕЛОВЕК, А НЕ КНОПКА. Прежние кнопки «прокрутить» и «в конец
* книги» двигали полосу листа снаружи — то есть страница дёргала продукт за
* его внутренние органы и называла это жестом. Настоящий жест — колесо,
* Ctrl+End, протяжка мышью и ползунок масштаба.
*/
const { locale } = hostAsks();
const ru = new Intl.NumberFormat("ru-RU");
const ms = (value: number) => `${value.toFixed(1)} мс`;
const panel = document.querySelector<HTMLElement>("[data-stress-panel]")!;
const badge = document.querySelector<HTMLElement>("[data-stress-badge]")!;
const meter = document.querySelector<HTMLInputElement>("[data-stress-meter]")!;
const readings = document.getElementById("readings")!;
const reachSlot = document.getElementById("reach")!;
const kindSlot = document.getElementById("kind")!;
const statusHost = document.getElementById("status")!;
const mount = document.getElementById("app")!;
document.getElementById("book")!.textContent =
`${ru.format(BOOK.rows)} × ${ru.format(BOOK.cols)} — предельный лист`;
const KIND_TITLE: Record<GridGestureKind, string> = {
scroll: "прокрутка",
drag: "протяжка",
zoom: "масштаб",
};
/* Порядок строк постоянный: цена, переезжающая с места на место, не читается —
глаз ищет её там же, где видел в прошлый раз. */
const KINDS: readonly GridGestureKind[] = ["scroll", "drag", "zoom"];
interface Measured {
readonly gesture: GridGesture;
readonly atZoom: number;
}
let measured = new Map<GridGestureKind, Measured>();
let last: GridGestureKind | null = null;
let zoom = ZOOM_DEFAULT;
function withinBudget(gesture: GridGesture): boolean {
const cost = gesture.cost;
return (
cost !== null &&
cost.p95Ms < GESTURE_BUDGET.frameP95Ms &&
cost.worstMs < GESTURE_BUDGET.worstFrameMs &&
gesture.windowRequests <= GESTURE_BUDGET.requestsPerScroll
);
}
function reading(anchor: string, title: string, value: string, limit: string, ok: boolean): string {
return `
<div class="stat px-3 py-1" data-stress-reading="${anchor}">
<div class="stat-title text-xs">${title}</div>
<div class="stat-value text-xl ${ok ? "" : "text-error"}" data-stress-value="${anchor}">${value}</div>
<div class="stat-desc">бюджет ${limit}</div>
</div>`;
}
function row(kind: GridGestureKind): string {
const seen = measured.get(kind);
const head = `<div class="w-20 shrink-0 text-xs font-semibold">${KIND_TITLE[kind]}</div>`;
const cost = seen?.gesture.cost ?? null;
if (!seen || cost === null) {
return `<div class="flex items-center gap-2" data-stress-gesture="${kind}">
${head}
<div class="text-xs opacity-60" data-stress-empty="${kind}">жеста ещё не было</div>
</div>`;
}
return `<div class="flex items-center gap-2" data-stress-gesture="${kind}">
${head}
<div class="stats stats-horizontal shadow-none">${[
reading(`${kind}.p50`, "кадр p50", ms(cost.p50Ms), ms(GESTURE_BUDGET.frameP95Ms), cost.p50Ms < GESTURE_BUDGET.frameP95Ms),
reading(`${kind}.p95`, "кадр p95", ms(cost.p95Ms), ms(GESTURE_BUDGET.frameP95Ms), cost.p95Ms < GESTURE_BUDGET.frameP95Ms),
reading(`${kind}.worst`, "худший кадр", ms(cost.worstMs), ms(GESTURE_BUDGET.worstFrameMs), cost.worstMs < GESTURE_BUDGET.worstFrameMs),
/* «Запросов к ядру» — про ШОВ КОНТРАКТА, а не про исполнителя: считаются
обращения, ушедшие за шов. Кто за ним стоит — заглушка сегодня, wasm
завтра, — на это число не влияет. */
reading(
`${kind}.requests`,
"запросов к ядру",
String(seen.gesture.windowRequests),
`${GESTURE_BUDGET.requestsPerScroll} за жест`,
seen.gesture.windowRequests <= GESTURE_BUDGET.requestsPerScroll,
),
].join("")}</div>
<!-- МАСШТАБ, ПРИ КОТОРОМ СНЯТА ЦЕНА: без него число несравнимо само с
собой — при 25 % в то же окно попадает вчетверо больше ячеек. -->
<div class="text-xs opacity-70 tabular-nums" data-stress-at="${kind}">при ${seen.atZoom} %</div>
</div>`;
}
/**
* ПОКАЗАТЬ ВСЁ ИЗМЕРЕННОЕ, а вердиктом назвать ПОСЛЕДНИЙ жест.
*
* Разбирать событие на отдельные состояния незачем: оно приезжает одним
* значением, и хранить его частями значило бы уметь показать половину одного
* жеста рядом с половиной другого.
*/
function show(gesture: GridGesture | null): void {
if (gesture) {
measured.set(gesture.kind, { gesture, atZoom: zoom });
last = gesture.kind;
}
const latest = last ? (measured.get(last) ?? null) : null;
const cost = latest?.gesture.cost ?? null;
const ok = latest !== null && withinBudget(latest.gesture);
const verdict = !meter.checked
? "замер выключен"
: cost === null
? "жеста ещё не было"
: ok
? "в бюджете"
: "мимо бюджета";
panel.setAttribute("data-stress-verdict", verdict);
badge.textContent = verdict;
badge.className = `badge ${cost === null ? "badge-ghost" : ok ? "badge-success" : "badge-error"}`;
readings.innerHTML =
measured.size === 0
? `<div class="text-sm opacity-70" data-stress-empty>${
meter.checked ? "жеста ещё не было — мерить нечего" : "замер выключен — лист едет без него"
}</div>`
: `<div class="flex flex-col gap-1">${KINDS.map(row).join("")}</div>`;
/* Род ПОСЛЕДНЕГО жеста — рядом с ценой: «прокрутка», «протяжка» и «масштаб»
стоят разного, и число без имени жеста ни о чём не говорит. */
kindSlot.innerHTML = last
? `<span class="badge badge-ghost" data-stress-kind="${last}">${KIND_TITLE[last]}</span>`
: "";
/* ЧЕСТНО О ПРЕДЕЛЕ БРАУЗЕРА: он отдаёт элементу не больше 2^24 пикселей
высоты, и на предельном листе колесом достаётся не вся книга. Оба числа —
от самого листа: что он попросил и что дал браузер. Подпись «миллион
строк» над листом, который колесом кончается раньше, была бы обманом, и
обнаруживать его не должен посетитель.
С МАСШТАБОМ ЭТО ЧИСЛО ЕДЕТ: распорка живёт в экранных пикселях, то есть в
координатах листа, умноженных на масштаб, — при 25 % книга подходит к
пределу элемента вчетверо ближе, чем при 100 %. */
const reach = latest?.gesture.reach.rows ?? null;
if (!reach) {
reachSlot.innerHTML = "";
return;
}
reachSlot.innerHTML =
reach.given < reach.declared * 0.999
? `<div class="text-xs text-warning" data-stress-reach="clamped">колесом достижимо ${Math.round(
(reach.given / reach.declared) * 100,
)}% книги: лист просит ${ru.format(Math.round(reach.declared))} px, браузер отдаёт ${ru.format(
reach.given,
)} px (предел элемента)</div>`
: `<div class="text-xs opacity-70" data-stress-reach="full">полоса получила всё, что просил лист (${ru.format(
reach.given,
)} px) — колесом достижима вся книга</div>`;
}
/**
* СТРОКА СОСТОЯНИЯ ПРОДУКТА — И ОНА ТОЖЕ НИЧЕГО НЕ ПРИМЕНЯЕТ.
*
* Ползунок ОБЪЯВЛЯЕТ команду `view.zoom.set:<процент>`, исполняет её собранный
* лист, и новый масштаб приезжает обратно событием `onZoomChange` — только
* после этого полоса показывает другое число. Короткого пути «сам показал, сам
* применил» здесь нет намеренно: он и есть второе состояние масштаба.
*/
const paintStatus = () => {
render(<StatusBar visible={new Set(DEFAULT_STATUS_INDICATORS)} zoom={zoom} />, statusHost);
};
/**
* ЗАМЕР МОЖНО ВЫКЛЮЧИТЬ — И ЭТО ЧАСТЬ ДОКАЗАТЕЛЬСТВА, а не настройка. Цена
* измерения обязана быть видимой: сняли галочку — виджет не получает
* подписчика, не заводит ни цикла кадров, ни чтения раскладки, и лист едет
* ровно так же, как у того, кому эти числа не нужны.
*/
let core: SheetsCore | null = null;
function build(): void {
core?.destroy();
measured = new Map();
last = null;
show(null);
core = createSheetsCore({
mount,
transport,
locale,
rows: BOOK.rows,
cols: BOOK.cols,
/* Масштаб отдаётся сборке НАЧАЛЬНЫМ значением, а держит его она: органов у
масштаба несколько, состояние обязано быть одно. */
zoom,
onZoomChange: (percent) => {
zoom = percent;
paintStatus();
},
/* НЕ ПОДПИСАЛСЯ — НЕ ПЛАТИТ: при снятой галочке свойства нет вовсе. */
...(meter.checked ? { onGesture: show } : {}),
});
}
meter.addEventListener("change", build);
paintStatus();
build();
import.meta.hot?.dispose(() => core?.destroy());

Книга примера: данные, с которыми лист открывается.

src/book.ts
import { createMockTransport } from "@f1/contract";
import { SHEET_BOUNDS } from "@f1/sheets";
/**
* КНИГА РАЗМЕРОМ С ПРЕДЕЛ ЛИСТА — тот самый, который мы заявляем.
*
* Транспорт контракта — единственное место, где лист разговаривает с ядром
* (инвариант №1: считает ядро, оболочка только показывает). В продукте здесь
* стоит `wasm` или мост хоста, и меняется РОВНО эта строка.
*/
export const BOOK = SHEET_BOUNDS;
/**
* Значение находится для ЛЮБОЙ строки, поэтому на дне миллиона строк лист не
* окажется пустым — иначе стресс мерил бы рисование пустоты.
*/
export const transport = createMockTransport({
cellAt: (row, col) =>
col === 0 ? `строка ${row + 1}` : col === 1 ? `${((row * 7 + col) % 97) / 4}` : `${(row + 1) * (col + 1)}`,
/* Край книги ОБЪЯВЛЕН: книга-генератор края не имеет, а мерить его по
загруженному окну значило бы выдать размер экрана за размер книги. */
usedRange: { rowEnd: BOOK.rows - 1, colEnd: BOOK.cols - 1 },
});

Сторона хозяина страницы: что приложение отвечает листу и что от него получает.

src/host.ts
import { applyLocaleAttributes, resolveLocale, setLocale, type Locale } from "@f1/i18n";
/**
* ЧТО ХОЗЯИН СТРАНИЦЫ ДАЁТ ЛИСТУ — тема и язык.
*
* У встраивающего это уже есть своё: тему он знает из настроек, язык — из
* профиля пользователя. Здесь и то и другое читается из адреса
* (`?theme=dark&locale=ar`) ровно затем, чтобы на любое состояние можно было
* дать ссылку и чтобы его проверял обход: настройка, доступная только щелчком,
* недоступна ни ссылке, ни автоматической проверке.
*
* ПРИЗНАКИ СТАВЯТСЯ НА КОРЕНЬ ДОКУМЕНТА — потому что это делает ХОЗЯИН, а не
* лист. Лист читает тему оттуда, где стоит (`:host-context`), а язык получает
* СВОЙСТВОМ сборки: два листа на одной странице вправе говорить на разном.
*
* НАПРАВЛЕНИЕ ПИСЬМА — ИНТЕРФЕЙСА, А НЕ ДОКУМЕНТА. `dir="rtl"` зеркалит хром
* страницы; «Лист справа налево» из Excel — свойство КНИГИ, и сетка от
* арабского языка интерфейса не переворачивается. Это не пропуск, а решение
* (`locale.ts`).
*/
export function hostAsks(): { readonly theme: "light" | "dark"; readonly locale: Locale } {
const params = new URLSearchParams(location.search);
const theme = params.get("theme") === "dark" ? "dark" : "light";
const locale = resolveLocale(params.get("locale") ?? undefined, "ru");
document.documentElement.setAttribute("data-theme", theme);
/* Язык — один на процесс: словарь модульный, и ставит его хозяин страницы. */
setLocale(locale);
applyLocaleAttributes(document.documentElement, locale);
return { theme, locale };
}

Оформление страницы вокруг листа.

src/styles.css
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ.
Собственное оформление лист приносит в теневые корни сам, но утилиты
раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без
него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл
замера у сетки без размеров). У потребителя вне репозитория это
`@fastoffices/formula-kit` из его node_modules. */
@import "@fastoffices/formula-kit"; /* см. примечание выше */
html,
body {
height: 100%;
margin: 0;
}

Из каких пакетов собран пример.

package.json
{
"name": "f1-example-stress",
"private": true,
"type": "module",
"description": "Предельная книга и цена ТРЁХ жестов рядом с ней — прокрутки, протяжки и масштаба: кадр p50/p95 и запросы за шов контракта числами, а не обещанием.",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@f1/preset-sheets-core": "*",
"@f1/grid-ui": "*",
"@f1/sheets": "*",
"@f1/sheets-ui": "*",
"@f1/contract": "*",
"@f1/i18n": "*",
"preact": "^10.0.0"
},
"devDependencies": {
"vite": "^7.0.0"
}
}

Ассистент документации

Ответ собран из документации и может быть неточным — сверяйтесь с источниками.