Контекст в Vue/Nuxt: осознать, не терять и беречь

925f1b834540c00656b100e5d02cbe71.jpg

Привет, Хабр! В процессе нашей в Азбуке миграции на Nuxt CAPI, а потом и Nuxt 3, я очень много переосмыслял работу с контекстом. Как он сохраняется, на что влияет, и как можем повлиять мы. В какой-то момент я понял, что по данной тематике крайне мало публикаций, а большинство разработчиков даже не знают, что этот контекст существует — и поэтому сами не замечают, как его теряют.

Что? Какой контекст? В setup нет this. Как его можно потерять? На что это влияет? Давайте про это поговорим.

Контекст в Vue — окружение компонента

Вы никогда не задумывались, куда пропадают watch? Вот вы их ставите, допустим, на элемент в pinia, покидаете страницу -, а они перестают срабатывать. Можно подумать, что Vue делает какую-то магию при сборке или еще что-то, но факт остаётся фактом.

А если нет this, то откуда берётся inject? Давайте разберемся с двумя функциями, одну из которых вы не найдёте в документации, с неё и начнём.

getCurrentInstance

Для начала скажу, что если эта функция возвращает не null — значит, вы еще не потеряли контекст и точно находитесь в компоненте. Уже хорошо.

Данный метод возвращает текущий активный компонент, внутренний инстанс Vue.

Что же мы можем в ней найти:

  • Уникальный идентификатор компонента (uid)

  • Удалённые parent и root

  • Корневую ноду компонента

  • Входные данные компонента по типу props, slots и т.д.

  • Состояние компонента (isMounted и пр.)

Именно на данном методе завязываются хуки жизненного цикла, по типу onMounted и прочие. И именно поэтому рекомендуется создавать всё, что вам нужно, в начале setup -, но об этом позже.

А также proxy. proxy содержит уже публичный компонент, который вы, например, сможете получить при использовании render функции, с обработанными слотами, датой, и даже forceUpdate. Фактически, это тот самый this, который мы потеряли в setup.

Если говорить про прямой пример использования, который в том числе используют авторы библиотек — в proxy можно что-нибудь положить. Например, представим, что вы вызываете composable (далее: композябра), и хотите что-то положить исключительно в текущий компонент для других композябр поблизости — без использования provide.

Берёте proxy, и кладёте туда то, что нужно:

export function setProductToInstance(product: Ref) {
    const instance = getCurrentInstance()?.proxy;
    if (!instance) return;
    // @ts-expect-error Типа нет, но он нам нужен
    instance.__avProduct = product;
}

export function getProductFromInstance(): Ref | null {
    const proxy = getCurrentInstance()?.proxy;
    if (proxy && '__avProduct' in proxy) return proxy.__avProduct as Ref;
    return null;
}

Готово! Вы восхитительны! Однако учтите — потеряв этот контекст, вы его уже не вернете (а жаль — см. PR #5472).

getCurrentScope

В отличие от предыдущего метода, этот уже документирован — и означает не компонент, а его реактивное окружение — или Effect Scope, которое хранит в себе все зависимости, на которые у окружения есть подписка (теперь вы знаете, на чём работает watchEffect).

Отвечая на свой же вопрос из начала раздела — если бы Vue не удалял watch/computed, то у нас была бы утечка памяти, и они бы постоянно заново создавались при открытии компонента (или в лучшем случае продолжали бы работать после его анмаунта, но не создавались заново).

Так что, как только вы создаёте watch/computed, Vue ищет текущее окружение и привязывает их к нему. После того, как происходит unmount компонента, он останавливает данный effect (метод stop), после чего он приобретает состояние .detached true и .active false. Все зависимости, включая watch и computed, перестают работать, теряя свою реактивность.

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

Кроме того, внутри он содержит информацию обо всём, что он отслеживает — этого нет в типизации и документации, но, если вам интересно, выведите результат getCurrentScope в консоль браузера в компоненте, где что-нибудь регистрируете, и посмотрите, какие зависимости у него есть.

Также у нас есть под рукой хук onScopeDispose, который отловит уничтожение окружения. В компоненте он вам не нужен — у вас есть onMounted -, но вы можете создавать собственные окружения с помощью метода effectScope!

TL; DR

  • getCurrentInstance — объект со внутренней инфой о компоненте. Без него не будут работать хуки жизненного цикла и некоторые композябры

  • getCurrentScope — содержит все реактивные «подписки» компонента, которые в последствии Vue отключит при уничтожении компонента

Как потерять этих ребят — и чем это грозит

Есть один гарантированный способ потерять эти компоненты — асинхронщина. Один await и Vue забудет про то, что у вас были эти методы.

Дело в том, что под капотом у Vue находится константа с текущим инстансом компонента и окружением. После выполнения вашего кода в setup, Vue переходит на следующий компонент — при этом не дожидаясь выполнения того, что вы там делаете (и Suspense не поможет).

Потеря инстанса сулит вам и композябрам сторонних библиотек отсутствие доступа к proxy (и всему, что они в нём используют) — избежать этого можно, инициализируя их ДО вызова await и его функций. Кроме того, вы не сможете использовать совершенно никакие хуки жизненного цикла — Vue будет выдавать предупреждение об этом в консоль.

Потеря окружения же куда более страшна. Давайте представим такой код:

export default defineComponent({
  async setup() {
    const store = useStore();

    watch(() => store.myItem, () => {});

    store.myData = await (await fetch('https://foo.com')).json();

    //Отслеживаем последующие обновления
    watch(() => store.myData, () => {});
  }
});

Поначалу будет казаться, что всё хорошо — оба watch работают! А потом вы уходите со страницы, и где-нибудь на другой тоже меняете myItem и myData.

Первый watch работать перестал, а вот второй… Продолжил работу и выполняет ваш код.

А если после этого создать новый компонент — зарегистрируется новый watch. И после анмаунта у вас будет их уже два. И так по новой.

У данной проблемы есть три решения:

  1. Не делать так — выполнять всё, что нужно, до вызова await

  2. Сохранять scope и восстанавливать его

  3. Использовать script setup

Сохраняем и восстанавливаем scope

export default defineComponent({
  async setup() {
    const scope = getCurrentScope();
    const store = useStore();

    watch(() => store.myItem, () => {});

    store.myData = await (await fetch('https://foo.com')).json();

    scope.run(() => {
      //Отслеживаем последующие обновления
      watch(() => store.myData, () => {})
    });
  }
});

Используем script setup

const store = useStore();

watch(() => store.myItem, () => {});

store.myData = await (await fetch('https://foo.com')).json();

//Отслеживаем последующие обновления
watch(() => store.myData, () => {}); //Утечки не будет!
setupMyWatcher(); //Утечка возможно будет!

Обратите внимание на код выше — при вызове watch в том же теле (или чего-то другого), утечки не будет. Vue использует чёрную магию, подставляя функцию, которой даже в API нет, при компиляции (код украден из документации Nuxt):

const __instance = getCurrentInstance() // Generated by Vue compiler
getCurrentInstance() // Works!
await someAsyncOperation() // Vue unsets the context
__restoreInstance(__instance) // Generated by Vue compiler
getCurrentInstance() // Still works!

Однако давайте представим, что внутри setupMyWatcher у нас есть еще какой-нибудь await. Туда компилятор уже лезть не будет — так как неизвестно, какой инстанс там уже может быть.

Возможности Vue в script setup в этом плане не безграничны — и лучше избегать такого кода. Если окружение (scope) вы восстановить кое-как сможете (например, передав его в аргументах, или вызвав функцию вместе с ним), то восстановить инстанс своими силами не получится.

Подробнее о том, что делает Vue в script setup, расписала команда Nuxt тут.

Контекст в хуках жизненного цикла

Те же самые правила. В onMounted (к примеру) у вас будет доступен ваш getCurrentInstance/getCurrentScope, и пропадёт после первого await.

Контекст в методах

Представим такой код.

let watchSetUp = false;

const setupWatch = () => {
  if (watchSetupUp) return;

  watch(myData, () => {});
  watchSetupUp = true;
}

Казалось бы, всё хорошо — даже защиту предусмотрели! А вот и нет: после того, как компонент закончил инициализацию, getCurrentScope вернёт null (либо, в лучшем случае, глобальное окружение рут компонента -, но не локального) -, а значит, та же самая ситуация с утечкой.

Либо восстанавливаем через run, либо, опять же, так не делаем.

Создаём утечку памяти на SSR

Начнём с того, что такой вызов, как выше, будет провоцировать утечку памяти еще и на SSR. Из-за того, что компонент создаётся не в браузере, а на сервере, эта утечка будет копиться в вашей памяти, и вам будет потом очень грустно смотреть на графики и логи.

Но на SSR есть еще один способ создать утечку (или уязвимость, смотря как стараться), о котором не все знают.

//composables/my-file.ts

export const myRef = ref('');
export const myComposable = computed(() => {});

Сам по себе этот код не создаст утечку памяти, однако, думаю, несложно догадаться, что никакого scope тут нет. В браузере нам, в целом, плевать — код выполняется один раз, уничтожать его не нужно, это у нас что-то глобальное (не забывайте, что кэш computed тоже будет глобальный!).

А вот на сервере:

  • Присваивание новых значений в ref (или reactive) может провоцировать появление новых зависимостей, которые не очищаются

  • Результат myComposable и myRef будет одинаковый для всех пользователей

То есть представим, что мы в myComposable вызываем хранилище с данными пользователя. computed, по своей натуре, кэшируется и обновляется только при обновлении его зависимостей — то есть стора.

А теперь представим ситуацию, когда:

  1. На сайт приходят два пользователя

  2. Записываем данные первого пользователя в хранилище

  3. Записываем данные второго пользователя в хранилище

  4. Выполняем сложные операции

  5. У первого пользователя операция выполнилась быстрее, рендерим ему HTML, в нём указываем email и логин пользователя

Вуаля! У первого пользователя вывелись данные второго.

Решение у этой проблемы простое.

//composables/my-file.ts

// export const myRef = ''; //Вот это убираем, используем только store/provide
export const myComposable = () => computed(() => {});

И вызываем myComposable в нужном вам setup. Минус в том, что у вас не будет общего кэша — только в рамках компонента.

Если вам позарез нужен этот кэш, то можно:

  • Написать свою обертку, которая будет работать только на клиенте

  • Стараться не вызывать этот код на SSR и быть готовым к последствиям

  • Использовать геттер в сторе pinia

Едем дальше!

Контекст в Nuxt — глобальное (и не только) окружение

Думаю, практически каждый разработчик Nuxt использует его методы -, а как же иначе? Столько удобных штук: useCookie, useHead, useState.

Так вот, в случае с Nuxt сильно повышается вероятность того, что вы используете какие-нибудь методы не в корне setup, а, скажем, в useFetch/useAsyncData. Которых, к тому же, в компоненте может быть больше одной.

Типичный код, который я не один раз видел:

await useAsyncData(async () => {});
await useAsyncData(async () => {
  await something();
  useMyComposable(args); //Внутри используется что-то от Nuxt
});

При работе на клиенте всё хорошо — там Nuxt весьма умён и кладёт свой контекст в глобальный window. На SSR же вторая асинкдата вернёт вам фатальную ошибку (Nuxt instance unavailable) вне script setup, а также в script setup, если это будет любая внутренняя функция, вызывающаяся после await (внутри неё самой или же внутри useAsyncData).

Можно сказать — ну надо же делать всё в корне. Надо-то оно надо, но в отличие от Vue мы уже не можем просто не использовать watch/computed, нам нужно управлять куками, доставать и сетать заголовки, и много что ещё — и делать это после await.

Извернуться всегда можно, но это сильно усложнит код, включая его декомпозицию. Nuxt берёт свой контекст (useNuxtApp) из getCurrentInstance ().proxy. Его мы потеряли. Восстановить нельзя. Как же быть?

Восстанавливаем вручную

Потому что Nuxt — солнышки, которые всё учли.

У нас есть два способа, и для обоих вам всё же придётся воспользоваться началом сетапа.

const app = useNuxtApp();

await useAsyncData(async () => {});
await useAsyncData(async () => {
  await something();
  callWithNuxt(app, () => useMyComposable(args)); //Так
  app.runWithContext(() => useMyComposable(args)); //Или так
});

Оба способа восстановят контекст Nuxt, а в случае вызова app.runWithContext он даже восстановит вам scope — подставив вместо scope Vue своё глобальное окружение, таким образом, чтобы вы избежали утечек памяти на SSR (но на клиенте это не поможет!).

Разумеется, после вызова следующего await контекст снова потеряется, и его придётся восстанавливать уже в ваших внутренних функциях каждый раз при вызове await. Разве это жизнь?

Используем Async Context

И тут нам на помощь приходит тот, от кого вообще обычно ничего не ждешь — Node.JS (в Bun тоже работает). Эта функция будет работать хорошо на последних версиях Node 18, 20 и 22 (если хотите, см. документацию Node).

В чём же соль: открываем Nuxt Config и вбиваем

export default defineNuxtConfig({
    experimental: {
        asyncContext: true,
    },
});

После этого… контекст Nuxt больше не будет теряться после вызова callWithNuxt/runWithContext.

Более того: представим, что мы не обленились и даже runWithContext не хотим использовать. Такое тоже возможно: вместо defineComponent используйте defineNuxtComponent. Вуаля — в setup контекст Nuxt не будет теряться без каких-либо махинаций. В script setup такая опция недоступна из-за ограничений Vue — Nuxt не может туда запихать такое, поэтому пользуемся первым методом, и при желании, в комбинации со вторым.

У Async Context имеется один серьезный недостаток — увеличенное потребление ОЗУ, особенно на проектах с огромными сторами в Pinia. Однако вам больше не придётся ловить эту ошибку при использовании defineNuxtComponent или после первого вызова runWithContext во всех методах внутри него.

Инфографика

Так как это всё весьма сложно для осознания, попытался визуализировать происходящее.

defineComponent setup

Место

Что доступно

До какой поры

Как починить

Корень setup

Всё

До первого await

onMounted и другие хуки

Всё

До первого await

Тело функции/computed/watch

Всё

До окончания инициализации компонента

Не создавать новые computed и пр., использовать только то, что в корне setup

После await

Ничего

scope.run для watch/computed, app.runWithContext или defineNuxtComponent для Nuxt

script setup

Место

Что доступно

До какой поры

Как починить

Корень setup

Всё

До первого await

onMounted и другие хуки

Всё

До первого await

Тело функции/computed/watch

Всё

До окончания инициализации компонента

Не создавать новые computed и пр., использовать только то, что в корне setup

После await (в корне)

Всё, кроме как после await во вложенных функциях

scope.run для watch/computed, app.runWithContext для Nuxt

После await внутри useAsyncData

Ничего

Резюме

  • Много нужного для Nuxt и других библиотек хранится в getCurrentInstance — там же хранится компонент

  • То, что нужно для очищения реактивности, хранится в getCurrentScope

  • instance и scope теряются после первого await в defineComponent

  • В script setup Vue их восстанавливает, но безвозвратно теряет после await в любой внешней функции

  • getCurrentInstance нельзя восстановить, scope — можно через .run

  • Нужное Nuxt на клиенте не теряется никогда

  • Нужное Nuxt на SSR можно восстановить через app.runWithContext

    • Если использовать experimental.asyncContext и последние Node, то контекст Nuxt больше не будет теряться во всём, что вы вызываете внутри — даже после внутренних await на любой вложенности

Надеюсь, данная статья поможет тем, кто пытается понять, почему у них происходят утечки памяти на клиенте или SSR. Я мог ошибиться в поведении в том или ином месте, так как эта тема плохо документирована и является сложной для понимания, даже несмотря на то, что я много работал над решением всех этих проблем.

Спасибо всем, кто читал, и буду рад ответить на вопросы и/или внести корректировки!

© Habrahabr.ru