Motyw
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łt | Przykł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:
| Element | Po co |
|---|---|
queryKey opakowany w computed | Zmiana id przełącza zapytanie na inny rekord |
toValue(id) | Composable przyjmuje zarówno wartość, jak i ref czy getter |
enabled | Wstrzymuje żądanie do czasu pojawienia się identyfikatora — istotne na ekranie tworzenia rekordu |
...options na samym końcu | Pozwala 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:
| Sytuacja | Renderowana treść |
|---|---|
isLoading | spinner wraz z tekstem labels.please_wait (slot loading) |
| błąd przy braku danych | BaseErrorState z przyciskiem ponowienia (slot error, scope { error, retry }) |
| błąd przy istniejących danych | BaseQueryStaleBanner nad danymi oraz normalna treść |
| dane | slot 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 })),
});| Opcja | Domyślnie | Opis |
|---|---|---|
resource | — | Ścieżka API, jako wartość albo ref |
perPage | 12 | Rozmiar strony |
filterSchema | {} | Analogicznie jak w useCoreTable, opisanym w Tabelach |
persistence | "query" | "query" — URL, "storage" — sessionStorage, dowolna inna wartość — wyłącznie pamięć |
storageKey | null | Klucz wymagany przy persistence: "storage" |
additionalParams | {} | Dodatkowe parametry, mogą być reaktywne |
immediate | true | Okreś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.