Skip to content

Vue Query ​

Wszystkie dane pochodzące z serwera przechodzą przez TanStack Vue Query w wersji 5. Pinia pełni w projekcie inną rolę, opisaną w sekcji Store'y.

Konfiguracja globalna ​

js
// src/boot/packages/vue-query.js
defaultOptions: {
  queries: {
    refetchOnWindowFocus: false,
    retry: false,
    staleTime: 1000 * 60 * 5, // 5 minut
  },
}

Unieważnianie przy zmianie języka ​

js
// src/App.vue
const invalidateAllQueries = useDebounceFn(() => {
  queryClient.invalidateQueries();
}, 50);

watch(
  () => ({ locale: locale.value, contentLocale: contentLocale.value }),
  invalidateAllQueries,
);

Zmiana języka interfejsu albo języka treści unieważnia cały cache. Dlatego parametrów lang i content_lang nie umieszcza się w kluczach zapytań.

Klucze zapytań ​

Fabryki kluczy znajdują się w pliku src/modules/core/api/key-utils.js:

js
// src/modules/core/api/key-utils.js
export const TABLE_PREFIX = "table";
export const INFINITE_LIST_PREFIX = "infinite-list";

export function tableKey(resource, params) { ... }         // ["table", resource, params?]
export function infiniteListKey(resource, params) { ... }  // ["infinite-list", resource, params?]

export function createEntityKeys(resource) {
  return {
    detail: (id) => [resource, id],
    table: () => tableKey(resource),
  };
}

W projekcie występują trzy kształty kluczy:

KształtPrzykładŹródło
[resource, id]["/blog/articles", 12]entityKeys.detail(id)
["table", resource, params]["table", "/blog/articles", { page: 1, per_page: 10 }]useCoreTable
["infinite-list", resource, params]["infinite-list", "/media/files", { per_page: 12 }]useCoreInfiniteQuery

Każdy moduł deklaruje klucze w pliku api/keys.js, przyjmując za identyfikator ścieżkę zasobu:

js
// src/modules/blog/api/keys.js
import { createEntityKeys } from "modules/core/api/key-utils";

export const articleKeys = createEntityKeys("/blog/articles");
export const categoryKeys = createEntityKeys("/blog/categories");

Znaczenie prefiksu table

Metoda entityKeys.table() zwraca klucz pozbawiony parametrów, czyli ["table", "/blog/articles"]. Unieważnienie takiego klucza obejmuje, dzięki dopasowaniu po prefiksie, wszystkie warianty stron, sortowań i filtrów jednocześnie.

Query — pobieranie danych ​

js
// src/modules/blog/api/queries/article.queries.js
import { getArticle } from "modules/blog/api/endpoints/article";
import { articleKeys } from "modules/blog/api/keys";

export function useArticleDetailQuery(id, options = {}) {
  return useQuery({
    queryKey: computed(() => articleKeys.detail(toValue(id))),
    queryFn: async () => {
      const { data } = await getArticle(toValue(id));

      return {
        ...data.model,
        section_groups: transformContentBuilderData(data.model.section_groups),
      };
    },
    enabled: computed(() => !!toValue(id)),
    ...options,
  });
}

W każdym zapytaniu powtarzają się cztery elementy:

ElementPo co
queryKey opakowany w computedZmiana id przełącza zapytanie na inny rekord
toValue(id)Composable przyjmuje zarówno wartość, jak i ref czy getter
enabledWstrzymuje żądanie do czasu pojawienia się identyfikatora — istotne na ekranie tworzenia rekordu
...options na samym końcuPozwala wywołującemu nadpisać dowolną opcję

Funkcja queryFn stanowi właściwe miejsce na transformację odpowiedzi — w powyższym przykładzie do postaci oczekiwanej przez formularz.

Rozwinięcie ...options zawsze na końcu

Umieszczenie go wcześniej sprawia, że wartości domyślne nadpisują to, co przekazał wywołujący, a opcja przestaje działać bez zgłoszenia jakiegokolwiek błędu.

Zastosowanie na stronie ​

vue
<!-- src/modules/blog/pages/articles/ArticleEditPage.vue -->
<script setup>
import { useArticleDetailQuery } from "modules/blog/api/queries/article.queries";

const route = useRoute();
const id = computed(() => Number(route.params.id));
const query = useArticleDetailQuery(id);
const breadcrumbs = useCoreBreadcrumbs("blog.articles.edit");
</script>

<template>
  <base-form-page :query="query" :title="..." :breadcrumbs="breadcrumbs">
    <template #default="{ data }">
      <blog-article-form :current-values="data" />
    </template>
  </base-form-page>
</template>

Mutacja — zapis danych ​

Zgodnie z konwencją projektu jedna mutacja save obsługuje zarówno tworzenie, jak i aktualizację rekordu, a rozstrzyga o tym obecność identyfikatora:

js
// src/modules/blog/api/mutations/article.mutations.js
import { createArticle, updateArticle } from "modules/blog/api/endpoints/article";
import { articleKeys } from "modules/blog/api/keys";

export function useArticleSaveMutation(id, options = {}) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (values) => {
      const _id = toValue(id);
      return _id ? updateArticle(_id, values) : createArticle(values);
    },
    onSuccess: () => invalidateCrudKeys(queryClient, articleKeys, toValue(id)),
    ...options,
  });
}

Funkcja invalidateCrudKeys ​

js
// src/modules/core/utils/query.js
export function invalidateCrudKeys(queryClient, modelKeys, id) {
  const keys = [modelKeys.table()];
  if (id) keys.push(modelKeys.detail(id));
  return Promise.all(keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })));
}

Funkcja unieważnia listę zawsze, natomiast szczegóły rekordu wyłącznie przy edycji. Pochodzi z katalogu modules/core/utils, jest więc auto-importowana.

Mutacje rzadko wywoływane są bezpośrednio

Formularz przekazuje mutację do useFormSubmit, które obsługuje mutateAsync, spinner, przekierowanie oraz błędy walidacji. Opisują to Formularze.

Renderowanie stanów zapytania ​

Zamiast ręcznych warunków v-if="isLoading" stosowany jest komponent BaseQueryState z pliku src/modules/core/components/ui/BaseQueryState.vue:

vue
<!-- dowolny komponent prezentujący dane z zapytania -->
<base-query-state :query="query">
  <template #default="{ data, isFetching }">
    <!-- dane -->
  </template>
</base-query-state>

Logika komponentu obejmuje cztery przypadki:

SytuacjaRenderowana treść
isLoadingspinner wraz z tekstem labels.please_wait (slot loading)
błąd przy braku danychBaseErrorState z przyciskiem ponowienia (slot error, scope { error, retry })
błąd przy istniejących danychBaseQueryStaleBanner nad danymi oraz normalna treść
daneslot domyślny ze scope { data, isFetching }

Różnica między isFetching a isLoading

Wartość isLoading oznacza pierwsze pobranie, kiedy dane jeszcze nie istnieją. Natomiast isFetching przekazywane w scope slotu sygnalizuje odświeżanie w tle przy już wyświetlonych danych i nadaje się do subtelnego wskaźnika, a nie pełnego spinnera.

Do rodziny komponentów stanu należą również BaseInfiniteQueryState, BaseQueryStaleBanner, BaseErrorState, BaseEmptyState oraz BaseStateOverlay.

W praktyce BaseQueryState rzadko używany jest bezpośrednio, ponieważ wywołuje go BaseFormPage, opisany w Layoutach i stronach.

Listy nieskończone ​

Do list przewijanych, takich jak siatka mediów, służy useCoreInfiniteQuery z pliku src/modules/core/composables/infinite-query.js:

js
// komponent siatki mediów
const { query, filters, hasActiveFilters, resetFilters } = useCoreInfiniteQuery({
  resource: "/media/files",
  perPage: 12,
  filterSchema: { search: { defaultValue: null } },
  additionalParams: computed(() => ({ directory: currentDirectory.value })),
});
OpcjaDomyślnieOpis
resource—Ścieżka API, jako wartość albo ref
perPage12Rozmiar strony
filterSchema{}Analogicznie jak w useCoreTable, opisanym w Tabelach
persistence"query""query" — URL, "storage" — sessionStorage, dowolna inna wartość — wyłącznie pamięć
storageKeynullKlucz wymagany przy persistence: "storage"
additionalParams{}Dodatkowe parametry, mogą być reaktywne
immediatetrueOkreśla, czy pobranie następuje od razu

Stronicowanie wyliczane jest na podstawie obiektu pagination:

js
// src/modules/core/composables/infinite-query.js
initialPageParam: 1,
getNextPageParam: (lastPage) => {
  const pagination = lastPage?.pagination;
  if (!pagination || pagination.current_page >= pagination.last_page) return undefined;
  return pagination.current_page + 1;
},

Wybór między tabelą a listą nieskończoną

Ekrany CRUD z paginacją, sortowaniem i akcjami wierszy korzystają z useCoreTable. Composable useCoreInfiniteQuery przeznaczony jest do siatek oraz list przewijanych.

Made by Moonbite