Свой кеш: ключ из входа, метка вместо списка адресов
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В прошлой статье кеш объяснялся словами: ключ — это вход функции, метка — вместо списка адресов. Слова верные, но механизм за ними по-прежнему спрятан. Соберём его сами: обёртку, которая складывает ключ из аргументов, копит ключи под меткой и по revalidateTag выбрасывает ровно их. По дороге станет видно, откуда берутся две тихие ошибки кеша — чужой ответ из-за замыкания и старые данные после опечатки в имени метки.
Модель с фильтром из прошлой статьи обслуживала одну функцию: ключом служили её сериализованные аргументы, и этого хватало. Настоящий "use cache" работает с любой функцией: как обёртка, которая принимает функцию, возвращает новую и пропускает через неё все вызовы. Такую обёртку и напишем. Главное в ней — единственная дверь: всё, что обёртка знает о вызове, пришло через ...args.
Первая версия берёт в ключ только аргументы, как модель с фильтром. Функций две, хранилище одно. Прогноз: что напечатает вторая строка?
Статья вернула пользователя. Ключ ["1"] у обоих вызовов один, и второй вызов честно нашёл запись — чужую. В модели с фильтром это не было видно, потому что функция там была одна. А хранилище у Next тоже одно на всё приложение: обработчик кеша по умолчанию — это один словарь в памяти процесса, куда пишут все помеченные функции. Значит, в ключе обязано быть и то, чья это запись.
Добавим первое слагаемое — то самое имя, которое модель страницы из прошлой статьи ставила в ключ, не объясняя, зачем оно там. У Next это не имя, а хеш места и сигнатуры функции в коде, и вписывает его компилятор; мы передадим строку руками.
Два ключа — две записи. Теперь ключ отвечает на оба вопроса: какая функция и с чем её позвали. Номер сборки, который Next ставит в ключ самым первым, модели не нужен: он меняется только с выкатом, а модель живёт один запуск.
Ключ из JSON.stringify удобен для модели, но теряет больше, чем кажется. Прогноз по каждой строке: совпадут ключи или нет?
undefined внутри массива превращается в null, а у Map нет собственных перечислимых полей, и любая карта записывается как {}: две разные карты дают один ключ и одну запись. Next сериализует аргументы иначе — через encodeReply из React, тот же формат, которым едут аргументы серверных действий. Там undefined пишется как "$undefined", карта — ссылкой $Q на своё содержимое, дата — строкой с приставкой $D: те же доллары, что в статье «Своя граница: писатель, провод, читатель».
А третья строка верна и для Next: порядок полей остаётся частью ключа, и { a: 1, b: 2 } с { b: 2, a: 1 } дают две записи. Если объект-фильтр собирают в разных местах кода, собирайте его в одном порядке — или передавайте поля отдельными аргументами.
Прошлая статья показала, как отчёт одного пользователя достаётся другому, и пообещала, что у Next так не выйдет: захваченная переменная войдёт в ключ. На нашей обёртке видно, почему выполнить это обещание не может никакая обёртка — только компилятор.
Функцию объявляют внутри компонента, и userId она берёт из замыкания. Каждый вызов reportFor создаёт новую стрелку, то есть новый объект функции. Прогноз: разведёт ли это двух пользователей?
Не развело. Новая стрелка — новый объект, но на объект обёртка не смотрит: имя то же, "report", аргументы те же, ["сентябрь"]. А userId через ...args не проходил вовсе — он лежит в окружении функции, куда снаружи не заглянуть. Это не упущение модели: у обёртки просто нет способа узнать, что функция что-то захватила.
Честная обёртка из самого Next, unstable_cache, так и живёт: в ключ она берёт аргументы и текст функции, а захваченные переменные просит перечислить руками. Текст у двух экземпляров стрелки, кстати, тоже один.
Директива решает задачу раньше, чем код запустится. "use cache" — не вызов, а строка, которую читает компилятор, и он переписывает функцию: поднимает её на уровень модуля, захваченные переменные делает первыми параметрами, а на месте объявления оставляет привязку через bind. Сделаем за него то же самое руками. Прогноз: что окажется в последней строке — сколько ключей и какие?
Во втором ключе стоит двойка: значение из замыкания приехало аргументом и стало частью ключа. Обёртка при этом не поменялась ни на символ — поменялась функция, которую ей дали. Та же привязка стоит за серверными действиями, объявленными внутри компонента. Next в обоих случаях ещё и шифрует привязанные значения, а обёртка кеша расшифровывает их до того, как сложить ключ: в ключ идёт само значение, а не шифровка.
Привязываются переменные объемлющих функций. Переменная уровня модуля после подъёма и так видна напрямую — привязывать её незачем, и в ключ она не входит. Модульный let, который меняется между запросами, кеш не заметит.
Модель меток из главы про мутации хранила у каждой записи список её меток, и revalidateTag перебирал весь кеш в поисках нужной. Работает, но связь там записана задом наперёд. Удобнее обратный указатель: метка → множество ключей. Тогда сброс — взять множество и удалить каждый ключ из него, не глядя на остальные записи.
Остаётся вопрос, откуда cacheTag знает, к какой записи прицепить метку. Ключа он не получает: cacheTag("post:" + id) — и всё. Ответ вы уже видели в статье про правила хуков: на время рендера React выставляет «текущий набор хуков», и useState обращается к нему — о компоненте он аргументом не получает ничего. Здесь то же самое: пока обёртка считает запись, она держит в переменной массив её меток, и cacheTag кладёт метки туда.
Прогноз: что будет лежать в индексе, сколько раз посчитается getPost и какие заголовки напечатает последняя строка?
Три расчёта: два при первом чтении и один после сброса. Метка post:1 вела ровно к одному ключу — его и выбросили; соседняя запись под post:2 не шелохнулась и отдалась из кеша. Перебора нет вовсе: revalidateTag даже не знает, сколько записей в хранилище.
Ошибка в cacheTag — не наша выдумка: настоящий cacheTag вне кешируемой функции бросает «cacheTag() can only be called inside a "use cache" function». Проверка та же — текущей записи нет.
Строка current = outer кажется лишней, пока вызовы плоские. Она понадобится, когда одна кешируемая функция позовёт другую, — и это следующий раздел.
В главе про мутации было сказано: кто из страниц показывает помеченные данные, знает кеш, и знает точнее вас. Проверим это на миниатюре. Список статей собран из тех же getPost, своих меток у него нет. Статью поправили, метку сбросили. Прогноз: что покажет список?
Страница статьи уже с новым заголовком, а список — со старым: на одном сайте две версии одного текста, и ни одной ошибки. Метка post:1 висит на записи getPost, а запись списка про неё не знает. Пока считался список, cacheTag звали внутри getPost, и метки ушли в её собственный массив — строка current = outer вернула списку его пустой.
Лечится одной мыслью: запись, посчитанная внутри другой, отдаёт свои метки наружу. Тогда метки поднимаются по цепочке вызовов, как всплывает событие, и запись списка сама оказывается под post:1. Для этого запись должна помнить свои метки — иначе при попадании в кеш поднимать было бы нечего.
Во множестве post:1 теперь два ключа, и второй туда никто не вписывал руками: в списке нет ни одного cacheTag. Вот и ответ на вопрос, как кеш узнаёт, какие страницы показывают данные. Страница — такая же внешняя запись, и метки всего, что посчитали внутри неё, поднимаются в неё сами.
Обратите внимание, где стоит подъём: после условия, то есть и после расчёта, и при попадании. Почему это важно, разбирается в первой задаче.
В исходниках Next это функция propagateCacheLifeAndTagsToRevalidateStore. Вместе с метками она поднимает и сроки жизни — отсюда вложенный короткий кеш, укорачивающий внешний, из прошлой статьи.
Теперь ошибка, ради которой миниатюру стоило собирать. В кешируемой функции стоит cacheTag("post:" + id), а в действии рука набрала дефис. Страница ниже работает на миниатюре целиком: хранилище, индекс и getPost едут подложкой. Кнопка сохраняет новый заголовок, сбрасывает метку и перерисовывает страницу, как это делает ответ действия. Прогноз: ошибка, пустой экран или новый заголовок?
Ни то, ни другое, ни третье: страница перерисовалась и показала старый заголовок. tagIndex.get("post-1") вернул undefined, ?? [] превратил его в пустой список, и цикл не сделал ни шага. Запись под post:1 цела, и следующий рендер честно достал её из кеша. Экран не пустеет, потому что сброс экрана не касается вообще: он только решает, будет ли следующий расчёт, — а до расчёта дело не дошло.
Хочется сделать сброс строгим: нет такой метки — бросить ошибку. Посмотрим, что такая строгость сделает с правильным кодом. Сервер только что перезапущен, статью ещё никто не открывал. Прогноз?
Упал вызов с верной меткой. Пустое место под меткой — не признак опечатки, а обычное состояние: после выката (номер сборки в ключе, и все записи новые), после истечения срока, на соседнем экземпляре сервера, просто пока страницу никто не открыл. Отличить «такой метки не бывает» от «под ней пока пусто» кеш не может, поэтому настоящий revalidateTag на незнакомое имя не ругается никогда.
У Next нет даже множества ключей. Записи могут лежать в другом процессе или во внешнем хранилище, и перебрать их ключи оттуда, где вызвали сброс, нельзя. Поэтому метка хранит одно число — когда её сбросили, а проверка переносится на чтение: запись, посчитанная раньше этой отметки, считается устаревшей.
Опечатка и здесь проходит молча, да ещё оставляет след: post-1 теперь навсегда в таблице меток, хотя ни одна запись её не носит. Второй аргумент revalidateTag тоже виден в этой схеме. С профилем "max" отметка ставится как «устарело», а не «истекло»: первый запрос после сброса получает старое значение, пока свежее считается в фоне. Значит, старые данные ровно один раз после правильного сброса — норма, а старые данные и на втором заходе — повод искать опечатку.
Раз при выполнении опечатку не поймать, ловить её надо раньше — на компиляции. Next принимает метку как любую строку, поэтому тип сужают сами: все имена меток — в одном типе, а вызовы идут через свою тонкую обёртку.
Диагностика — Аргумент типа ""post-1"" нельзя назначить параметру типа "Tag" — указывает ровно на опечатку: "post-1" не подходит ни под "posts", ни под шаблон post:${string}. Шаблонные строковые типы из части III работают здесь как список допустимых имён, который не надо поддерживать руками. Выполнение при этом прошло — типы, как всегда, живут только до запуска. Помощник tag.post(id) закрывает вторую половину: метка собирается в одном месте, и дефису в ней неоткуда взяться.
Миниатюра честна в главном и упрощена во всём остальном. Чтобы не спутать одно с другим — сверка по частям.
| В миниатюре | В Next 16 | Почему так |
|---|---|---|
имя функции строкой: "getPost" | хеш места и сигнатуры функции, его вписывает компилятор | имена повторяются в разных файлах, место в коде — нет |
JSON.stringify(args) | encodeReply из React — формат аргументов серверных действий | различает undefined и null, везёт содержимое Map и даты |
| ключ: имя плюс аргументы | номер сборки, id функции, аргументы, а в разработке ещё хеш горячей перезагрузки | после выката всё считается заново |
bind(null, userId) руками | подъём функции и привязка захваченных переменных при компиляции | обёртка во время выполнения замыкания не видит |
переменная current | хранилище текущей работы на AsyncLocalStorage | функции асинхронные: пока одна ждёт, считается другая, и одной переменной на всех мало |
current?.push(...entry.tags) | подъём меток и сроков жизни во внешнюю запись | страница оказывается под метками всех своих данных |
Map «метка → множество ключей» и delete | таблица «метка → когда сброшена», проверка при чтении | записи могут лежать не в этом процессе |
| — | revalidatePath("/tasks") — сброс служебной метки _N_T_/tasks | адрес — тоже метка, только её ставит сам Next |
Последняя строка объясняет, почему в главе про мутации revalidatePath и revalidateTag вели себя так похоже: внутри это одна и та же функция сброса. Адрес превращается в служебную метку, а дальше работает механизм, который вы только что собрали.
И отсюда же главное практическое правило этой главы. Ключ — это то, что вошло в функцию, поэтому всё, от чего зависит ответ, должно входить в неё явно: аргументом или захваченной переменной объемлющей функции. Метка — это просто имя множества, поэтому имя должно собираться в одном месте, а не набираться руками дважды.
- Кешируемая функция — обёртка с единственной дверью: всё, что она знает о вызове, пришло через аргументы. Ключ — имя функции плюс сериализованные аргументы; без имени две функции с одинаковым входом делят одну запись.
- Значение из замыкания в аргументы не входит, поэтому обёртка во время выполнения его не видит и отдаёт чужой ответ. Директива решает это на компиляции: функция поднимается наверх, а захваченные переменные привязываются через
bindи становятся частью ключа. - По смыслу метка — множество ключей.
cacheTagпишет в запись, которая считается сейчас, — тот же приём «текущего», что у хуков;revalidateTagудаляет ровно ключи из множества и не трогает соседей. - Метки вложенной записи поднимаются во внешнюю — и при расчёте, и при попадании в кеш. Так страница оказывается под метками всех данных, из которых собрана, и адреса перечислять не нужно.
- Промах по имени метки молчит: пустая метка — обычное состояние после выката или перезапуска, отличить её от опечатки кеш не может. Ловить опечатку приходится типами — имена меток в одном типе и одном помощнике.
- Настоящий Next хранит у метки не ключи, а время сброса и проверяет записи при чтении;
revalidatePathвнутри — та же метка, только служебная.
1/4Две кешируемые функции, getUser(id) и getPost(id), пишут в одно хранилище, а ключ собирается только из аргументов. Сначала вызвали getUser("1"), потом getPost("1"). Что вернёт второй вызов?
2/4Почему директива "use cache" справляется с переменной из замыкания, а обёртка вроде нашей cached — нет?
3/4getList собирает список из вызовов getPost(id), а в getPost стоит cacheTag("post:" + id). У самого getList меток нет. Что станет с записью списка после revalidateTag("post:1", "max")?
4/4В функции стоит cacheTag("post:" + id), а в действии — revalidateTag("post-" + id, "max"). Что увидит читатель после сохранения?
Метки теряются при попадании
Здесь обёртка поднимает метки наверх — но список после сброса всё равно старый. Разница с разделом про подъём меток в одном: сначала читатель открыл страницу статьи, и только потом список. Найдите, почему порядок визитов решает судьбу списка, и почините обёртку.
Подъём стоит только на ветке расчёта. Когда список считается, getPost("1") уже лежит в кеше, обёртка возвращает значение первой же строкой — и метка post:1 до списка не доезжает. Под списком оказывается одна post:2, поэтому сброс post:1 его не задевает.
Теперь последняя строка печатает «Свой рендер, вторая редакция» «Своя сверка». Метки хранятся в записи именно ради этого: попадание в кеш должно отдать наружу то же, что отдал бы расчёт. Иначе результат зависит от того, в каком порядке читатели ходили по сайту, — ошибка, которую не повторишь на своей машине.
Метки для доски задач
Доска собирается из задач, миниатюра с подъёмом меток едет подложкой. Расставьте cacheTag так, чтобы переименование задачи обновляло доску по метке task:2, а добавление новой — по метке tasks. Комментарии справа показывают нужный вывод.
Задаче — её собственная метка, доске — метка списка.
Метки task:1 и task:2 доске ставить руками не нужно: они поднимутся из getTask сами. А вот tasks подъёмом не заменить. Новой задачи в момент расчёта доски ещё не было, её метки никто не видел — изменился сам список, и у списка должно быть своё имя.
Цены из замыкания
Цена зависит только от валюты, но валюта приходит из замыкания — и Боря видит рубли. Перепишите pricesFor в том виде, в каком это сделал бы компилятор, чтобы валюта попала в ключ, а у Ани и Веры с одной валютой запись была общей: расчётов должно быть два.
Функцию поднимаем наверх, валюту делаем первым параметром и привязываем на месте объявления:
Вывод: Аня и Вера получают рубли, Боря — доллары, расчётов два. Обратите внимание, что привязано поле, а не весь user: если поднять функцию с параметром user: User и привязать весь объект, цены тоже станут верными, но расчётов будет три — в ключ попадут имя и id, от которых ответ не зависит. В ключе должно быть ровно то, от чего зависит ответ: меньше — чужие ответы, больше — лишние записи.