Всем привет я Артур Валиев, пятничная статья, поехали.
Привычка, которая выдаёт диагноз
Я ловлю себя на мысли: мысль появилась - рука тянется не в приложение заметок, а в 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 я когда-то встречал похожую мысль: время одиночек прошло — большие вещи теперь создают команды. Точную цитату уже не вспомню, но сама идея мне очень близка.
И да, ребят: эта статья — прежде всего моё желание что-то сделать и попытка оформить идею, которая давно крутится у меня в голове. Поэтому хотя бы за саму идею помидорами, пожалуйста, не бросайтесь. За техническую часть — пожалуйста, там я к критике готов :)
Я просто заметил за собой одну вещь: все свои заметки я уже давно вижу не как документы, а как сообщения в бесконечном чате с самим собой. Собственно, из этого наблюдения и родилась вся статья.
Спасибо всем, кто дочитал до конца.
Я Артур Валиев. Сегодня пятница — а значит, я пишу.

