Всем привет я Артур Валиев, пятничная статья, поехали.

Привычка, которая выдаёт диагноз

Я ловлю себя на мысли: мысль появилась - рука тянется не в приложение заметок, а в Telegram, в свой собственный чат. Официальный, без турбо-режимов и модификаций. Просто "Избранное". И дело не в том, что там удобнее хранить - дело в том, что там приятнее писать. Сообщение влетает в ленту с лёгким пружинным отскоком, курсор уже стоит там, где нужно, клавиатура не проседает, ничего не тормозит даже на пятилетнем телефоне. Это не функция - это ощущение. И именно это ощущение обычные note-taking приложения так и не смогли воспроизвести.

Почему это не "просто анимации"

Инженеры Telegram годами доводили до одержимости то, что большинство продуктов считает косметикой: собственный рендер-движок поверх Skia, ручная растеризация стикеров и текста, интерполяция между кадрами вместо системных дефолтных transition'ов, spring-физика вместо ease-in-out кривых из учебника. Результат — интерфейс, который не ощущается как интерфейс. Ты не ждёшь, пока экран отрисуется, не видишь дрожание при быстром скролле тысяч сообщений, не чувствуешь paint lag при открытии чата с историей в годы.

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

Почему классические заметки перестали существовать в привычном виде

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

Папки, цветные стикеры, отдельные экраны "выбери цвет" — всё это было спроектировано для мира, где запись мысли была осознанным, неторопливым действием. А сегодня запись мысли соревнуется за внимание с уведомлением от банка и сторис друга. В этой гонке выигрывает не тот интерфейс, что красивее организован, а тот, что откликается быстрее человеческой мысли. Мессенджеры выиграли эту гонку случайно — они просто оптимизировались под другую задачу (переписку), но результат оказался идеальным и для мышления вслух с самим собой. Мне очень привычно было пользоваться xiaomi заметками но время расставило все за себя.

Поэтому "обычные заметки" не умерли из-за плохого дизайна. Они умерли, потому что перестали быть самым быстрым способом зафиксировать мысль — а Telegram, сам того не желая, стал им.

Сама мечта

Мечта простая и наглая одновременно: приложение для заметок, которое ощущается ровно так же, как открыть Избранное в Telegram. Тот же рендер без единого рывка на десятках тысяч записей. Те же пружинные анимации появления сообщения — не декоративные, а психологически важные: они дают мозгу подтверждение "записано" быстрее, чем успевает сформироваться сомнение "а точно сохранилось?". Тот же мгновенный отклик клавиатуры, тот же плавный инерционный скролл, та же незаметность интерфейса — когда ты не осознаёшь, что смотришь на UI, потому что он не создаёт трения.

Разница с самим Telegram — не в движке, а в цели. Никакого социального шума, никаких чужих сообщений, вытесняющих твои мысли вниз ленты. Только твой собственный чат с самим собой, но с настоящим полнотекстовым поиском, тегами и структурой, которых Избранному в Telegram никогда не додумались дать — просто потому, что это не было их задачей.

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

Техническая часть: как это сделать по-настоящему быстро

Продолжаю мечту с прошлого раза, но теперь — конкретно, с кодом и обоснованием каждого решения. Беру за основу ваш стек в TaskFlow-Ai (React 19 + Vite + better-sqlite3), потому что там уже видно и правильные, и неправильные паттерны рядом.

Виртуализация ленты — иначе 10 000 заметок убьют рендер

Telegram не рендерит все сообщения чата в DOM/View-иерархию — он держит в памяти только видимую область плюс небольшой буфер, остальное переиспользует.

QuickNotes.tsx рендерит notes.map(...) без виртуализации — на 50 заметках незаметно, на 5000 браузер начнёт тормозить на каждом ре-рендере списка.

// Плохо (текущий QuickNotes.tsx) — рендерит ВСЕ заметки сразу
{sortedNotes.map(note => <NoteCard key={note.id} note={note} />)}

// Хорошо — виртуализация окна
import { useVirtualizer } from '@tanstack/react-virtual';

function NotesFeed({ notes }: { notes: Note[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: notes.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72, // средняя высота "сообщения"
    overscan: 8,            // буфер за пределами экрана
  });

  return (
    <div ref={parentRef} style={{ overflow: 'auto', height: '100%' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map(row => (
          <div
            key={row.key}
            style={{ position: 'absolute', top: 0, transform: `translateY(${row.start}px)` }}
          >
            <NoteCard note={notes[row.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Почему именно так: DOM-узел стоит памяти и layout-пересчёта. React отлично справляется с diff'ом данных, но не спасает от того, что браузер обязан посчитать layout для каждого реального элемента в дереве. Виртуализация — это не оптимизация React, это обход ограничения самого браузера.

Анимация появления — только transform/opacity, никогда height/margin

Пружинный "отскок" сообщения в Telegram — это не украшение, а GPU-композитный слой, который не трогает layout вообще.

// Плохо — анимация height/margin триггерит layout на каждый кадр
.note-enter { max-height: 0; margin-bottom: 0; }
.note-enter-active { max-height: 200px; margin-bottom: 12px; transition: all 0.3s; }

// Хорошо — только композитные свойства + spring вместо ease
import { motion } from 'framer-motion';

<motion.div
  initial={{ opacity: 0, scale: 0.92, y: 12 }}
  animate={{ opacity: 1, scale: 1, y: 0 }}
  transition={{ type: 'spring', stiffness: 500, damping: 32 }}
>
  <NoteCard note={note} />
</motion.div>

Почему: opacity и transform браузер может анимировать на GPU-слое отдельно от основного потока — main thread свободен, jank не возникает даже под нагрузкой. height/margin/top заставляют пересчитывать layout каждый кадр (reflow) — это именно то дребезжание, которое чувствуется как "тормозит". Spring вместо ease-in-out — потому что пружина реагирует на реальную скорость взаимодействия (можно прервать анимацию свайпом на середине), а фиксированная кривая — нет.

Оптимистичный рендер — сообщение появляется до ответа сервера

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

const sendNote = async (text: string) => {
  const tempId = generateUUID();
  const optimisticNote: Note = { id: tempId, content: text, createdAt: new Date().toISOString(), pending: true, ...defaults };

  setNotes(prev => [...prev, optimisticNote]); // мгновенно в UI

  try {
    const saved = await sqliteApi.createNote(optimisticNote);
    setNotes(prev => prev.map(n => n.id === tempId ? { ...saved, pending: false } : n));
  } catch {
    setNotes(prev => prev.map(n => n.id === tempId ? { ...n, failed: true } : n));
  }
};

Почему: Задержка между действием и визуальным откликом — главный источник ощущения "тормозит", даже если сеть быстрая. 100 мс round-trip к серверу человек не замечает как задержку сети, но замечает как лаг интерфейса. Оптимистичное обновление убирает эту связь: UI = мгновенная функция от ввода, синхронизация — фоновый процесс.

// Плохо (текущий код)
useEffect(() => {
  loadMessages();
  const interval = setInterval(loadMessages, 3000);
  return () => clearInterval(interval);
}, []);
// + JSON.stringify сравнение на каждый вызов

// Хорошо — для заметок (single-user, локальные данные) вообще не нужен polling
const addNote = (note: Note) => {
  setNotes(prev => [...prev, note]);   // сразу в state
  sqliteApi.createNote(note);          // асинхронно в фоне, без ожидания
};
// Если нужна синхронизация между вкладками/устройствами — BroadcastChannel или SSE,
// а не интервальный опрос
const channel = new BroadcastChannel('notes_sync');
channel.onmessage = (e) => setNotes(prev => mergeById(prev, e.data));

Почему: Polling — это компромисс для многопользовательского чата, где нужно узнать о чужих сообщениях без WebSocket. Для заметок себе такого компромисса вообще не нужно — единственный писатель это ты сам, значит state обновляется локально в момент действия, без всякого "подожди 3 секунды и сравни JSON".

Полнотекстовый поиск — FTS5 вместо LIKE '%...%' по JSON-блобу

Сейчас таблица notes хранит всё в одной колонке data TEXT (database.ts:51-55). Поиск по ней означал бы SELECT * FROM notes + JSON.parse на каждой строке в JS — O(n) с тяжёлым парсингом на каждый ввод символа.

-- Виртуальная FTS5-таблица, индексирующая контент отдельно
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
  title, content, note_id UNINDEXED
);

-- Триггеры синхронизации с основной таблицей
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
  INSERT INTO notes_fts(rowid, title, content, note_id)
  VALUES (new.rowid, json_extract(new.data,'$.title'), json_extract(new.data,'$.content'), new.id);
END;
// Поиск — миллисекунды даже на 100k записей, ранжирование по релевантности встроено
const search = (query: string) => {
  return db.prepare(`
    SELECT notes.* FROM notes_fts
    JOIN notes ON notes.rowid = notes_fts.rowid
    WHERE notes_fts MATCH ?
    ORDER BY rank
    LIMIT 50
  `).all(query + '*');
};

Почему: FTS5 строит инвертированный индекс (слово → список документов) один раз при записи, а не при каждом поиске. Поиск превращается из "прочитать и распарсить всё" в "посмотреть в готовый индекс" — асимптотически другая задача: O(n) на запись заменяется на O(log n) на чтение вместо O(n) на каждый чих поиска.

Почему под капотом это всё — компромисс: C++ против рантаймов

Ключевая честная мысль: FTS5 быстрый не потому, что вы позвали его из JS — он быстрый, потому что сам движок SQLite написан на C и скомпилирован в машинный код. better-sqlite3 — это тонкая synchronous-биндинга поверх этого C-кода, а не переизобретение B-tree на JavaScript. То есть даже в вашем "быстром" примере скорость дают C, а не JS вокруг него. И вот это — не частный случай, а системный паттерн.

Сборщик мусора — главный источник непредсказуемого лага

В C++ время жизни объекта известно на этапе компиляции (RAII, стек, unique_ptr). Освобождение памяти происходит синхронно и предсказуемо — ноль пауз "стоп-мир".

// C++: объект уничтожается детерминированно, в конце области видимости
{
    ChatMessage msg = loadMessage(id); // стек или контролируемая куча
    render(msg);
} // деструктор вызван ЗДЕСЬ, точка известна на компиляции
// JS/V8: память освобождается ПОТОМ, когда решит GC — не вы
let msg = loadMessage(id);
render(msg);
// объект висит в куче, пока V8 не запустит minor/major GC —
// а он может выбрать это сделать посреди анимации скролла

Почему это важно именно для анимацй: V8 периодически останавливает выполнение JS ("stop-the-world" фаза mark-sweep), и эта пауза может составить 5-50 мс — ровно один-два пропущенных кадра при 60fps. Ты не можешь это запланировать, ты не можешь это исключить программно, ты можешь только надеяться, что GC не сработает в момент, когда пользователь свайпает ленту из 10 000 сообщений. В C++ такого класса проблем физически не существует — нет паузы, потому что нет фазы "остановить всё и пройтись по куче".

Память объекта: плотная структура vs куча указателей

// C++: массив сообщений — это непрерывный блок памяти
struct Message { int64_t id; char text[256]; int64_t timestamp; };
std::vector<Message> messages; // все элементы лежат подряд в RAM
// JS: массив объектов — это массив УКАЗАТЕЛЕЙ на разбросанные по куче объекты
const messages = [
  { id: 1, text: "...", timestamp: 123 }, // объект A где-то в куче
  { id: 2, text: "...", timestamp: 456 }, // объект B в другом месте кучи
];

Почему это важно: когда процессор проходит по std::vector<Message>, он читает данные последовательно из кэша L1/L2 — это самый быстрый доступ к памяти, который вообще есть в архитектуре компьютера (prefetcher угадывает следующий блок заранее). Когда JS-движок проходит по массиву объектов, он на каждой итерации прыгает по случайным адресам в куче ("pointer chasing") — почти гарантированный cache miss на каждый элемент, а это в 10-100 раз медленнее, чем последовательное чтение. При рендере тысяч ячеек чата — это разница между "плавно" и "дёргается".

3. JIT — компиляци

C++ компилируется в машинный код один раз, до запуска. JS-движок сначала интерпретирует байткод, потом — если код "горячий" — компилирует через JIT (TurboFan в V8), а если типы объекта вдруг меняются в рантайме — деоптимизирует обратно в медленный путь.

// Выглядит невинно, а на деле убивает JIT-оптимизацию
function render(note) {
  return note.pinned ? note.color : undefined; // note.color иногда string, иногда undefined
}
// V8 строит "hidden class" под форму объекта; если форма скачет —
// inline cache сбрасывается, метод снова интерпретируется медленно

Почему это важно: производительность JS-приложения нестабильна во времени — первые кадры после запуска или после смены формы данных заведомо медленнее, потому что движку нужно заново "прогреться". У Telegram Desktop (чистый C++/Qt) и у нативного рендер-слоя Telegram Android (свой Canvas-движок поверх Java, но критичные пути — на C++/JNI) такой проблемы нет в принципе: код одинаково быстрый с первого кадра. Единственное что вызывает беспокойство в коде это Qt.

Однопоточность против реальных потоков

JS в браузере — один главный поток на весь рендер, layout, обработку событий и вашу логику разом. Хочешь параллелизм — изволь сериализовать данные в Web Worker через structured clone (это копирование!) и обратно.

// "Параллелизм" в браузере — это копирование данных туда-обратно
worker.postMessage(hugeNotesArray); // клонирование всего массива в другой поток
worker.onmessage = (e) => setNotes(e.data); // клонирование обратно
// C++: потоки читают ОБЩУЮ память напрямую, без копирования
std::thread renderThread([&messages]() {
    for (auto& msg : messages) render(msg); // прямой доступ, никакого клонирования
});

Зачем вообще такая фигня: декодирование стикеров, разбор истории чата, индексация текста в Telegram Desktop идёт в отдельных настоящих потоках с общей памятью — без налога на сериализацию. В браузере параллелизм формально есть, но цена входа (копирование данных) часто съедает весь выигрыш на объёмах, характерных для истории переписки.

Так почему анимации Telegram ощущаются "безумными"

Складывая всё вместе: нет непредсказуемых GC-пауз → кадр всегда укладывается в 16 мс. Данные лежат плотно в памяти → рендер тысяч ячеек не упирается в кэш-промахи. Код скомпилирован заранее → нет разогрева и деоптимизаций посреди скролла. Рендеринг идёт напрямую в GPU-слой через собственный движок (Skia на Desktop, кастомный Canvas + аппаратные слои на Android), минуя DOM, CSS layout engine и виртуальный DOM diffing целиком — тогда как любое React/CSS-приложение обязано пройти через reflow/repaint/composite пайплайн браузера, который сам по себе — универсальный, а значит не заточенный под конкретно вашу ленту сообщений.

Честная оговорка

Это не значит, что весь стек TaskFlow-Ai нужно переписывать на C++ — SQLite/FTS5 и так C под капотом, а разумно написанный React закрывает 90% разрыва для приложения заметок такого масштаба. Разница становится критичной только на объемах и частоте кадров, которые Telegram обслуживает миллиардам сообщений в день — для личных заметок это скорее потолок амбиций, чем практическая необходимость прямо сейчас.

Чтобы выжать максимум из текущего стека без оверхенда, лучше всего подсмотреть готовые механики у тех, кто уже решил эти проблемы:

  • В UI-слое (React):

    • Виртуализация и Layout: Изучи исходники @tanstack/react-virtual или ustatic/react-window. Обрати внимание, как они динамически рассчитывают размеры элементов и удерживают DOM-дерево плоским.

    • Плавные анимации: Посмотри, как в Framer Motion или React Spring анимации выносятся на GPU через transform и opacity в обход тяжелого React-ререндера.

  • В логике и рендере данных:

    • Оптимистичный UI: Отличный референс — документация и примеры TanStack Query (React Query) в секции Optimistic Updates, либо логика синхронизации в Linear.app (их инженеры много писали о том, как мгновенно отображать изменения до ответа сервера).

  • В работе с локальной базой:

    • Быстрый локальный поиск: Загляни в репозитории проектов вроде Logseq или Obsidian (комьюнити-плагины), чтобы увидеть, как они готовят SQLite/FTS5 для мгновенной индексации связей между заметками (backlinks) и полнотекстового поиска прямо на клиенте.

В целом я восхищаюсь той безумной скоростью, до которой Telegram довёл свой продукт, и тем уровнем оптимизации, который стоит за этой плавностью. А главное — значительная часть решений лежит в открытом доступе. Так зачем заново изобретать велосипед, если можно изучать подходы людей, которые годами решали ровно эти проблемы?

Мне вообще кажется, что эпоха «разработчика-одиночки, который должен придумать всё сам», давно закончилась. Сейчас сильная разработка — это умение опираться на чужой опыт, читать исходники, разбирать готовые решения и собирать из них что-то своё. Где-то на Stack Overflow я когда-то встречал похожую мысль: время одиночек прошло — большие вещи теперь создают команды. Точную цитату уже не вспомню, но сама идея мне очень близка.

И да, ребят: эта статья — прежде всего моё желание что-то сделать и попытка оформить идею, которая давно крутится у меня в голове. Поэтому хотя бы за саму идею помидорами, пожалуйста, не бросайтесь. За техническую часть — пожалуйста, там я к критике готов :)

Я просто заметил за собой одну вещь: все свои заметки я уже давно вижу не как документы, а как сообщения в бесконечном чате с самим собой. Собственно, из этого наблюдения и родилась вся статья.

Спасибо всем, кто дочитал до конца.

Я Артур Валиев. Сегодня пятница — а значит, я пишу.