useFetch, useAsyncData, $fetch, серверные маршруты, defineEventHandler, обработка ошибок и кэширование
Nuxt 3 предоставляет специальные composables для получения данных, которые работают как на сервере, так и на клиенте. Это решает ключевую проблему SSR: данные должны быть загружены до отправки HTML клиенту.
В обычном Vue SPA загрузка данных простая: компонент монтируется, вызывается onMounted, делается fetch, данные отображаются. В SSR всё сложнее.
При серверном рендеринге сервер должен: выполнить код компонента, загрузить все данные, отрендерить HTML и только потом отправить его клиенту. Проблема: onMounted не выполняется на сервере (там нет DOM). Обычный fetch в onMounted означает, что сервер отдаст пустую страницу, а данные загрузятся уже в браузере — это убивает смысл SSR.
Nuxt решает это через специальные composables useFetch и useAsyncData, которые выполняются как на сервере (до рендеринга), так и на клиенте (при навигации).
$fetch — это ofetch, мощный fetch-клиент с автоматическим парсингом JSON, обработкой ошибок и поддержкой TypeScript. Он авто-импортируется в Nuxt:
<script setup>
// $fetch — прямой запрос, без кэширования и SSR-оптимизации
// Используйте для POST/PUT/DELETE или в обработчиках событий
async function createPost(data) {
try {
const post = await $fetch('/api/posts', {
method: 'POST',
body: data,
headers: {
'Authorization': `Bearer ${useAuthStore().token}`
}
})
console.log('Создан пост:', post)
} catch (error) {
// ofetch автоматически бросает FetchError при non-2xx статусах
console.error('Статус:', error.status, 'Данные:', error.data)
}
}
// $fetch с query params
const users = await $fetch('/api/users', {
query: { page: 1, limit: 10, role: 'admin' }
})
// $fetch с кастомным ответом
const response = await $fetch.raw('/api/upload', {
method: 'POST',
body: formData
})
console.log(response.status, response.headers.get('x-request-id'))
</script>Важно: $fetch в <script setup> на сервере будет выполнен при каждом запросе. В компоненте SSR это нормально, но для данных, нужных при начальном рендере, лучше использовать useFetch или useAsyncData.
useFetch — основной способ получения данных в Nuxt. Он автоматически:
data, pending, error<template>
<div>
<div v-if="pending">Загрузка постов...</div>
<div v-else-if="error">
<p>Ошибка: {{ error.message }}</p>
<button @click="refresh">Повторить</button>
</div>
<ul v-else>
<li v-for="post in posts" :key="post.id">
<h3>{{ post.title }}</h3>
<p>{{ post.body }}</p>
</li>
</ul>
</div>
</template>
<script setup>
// useFetch автоматически определяет ключ кэша по URL
const { data: posts, pending, error, refresh } = await useFetch('/api/posts')
</script>Обратите внимание на await перед useFetch. Это не блокирует рендеринг — Nuxt использует <Suspense> под капотом. Страница рендерится только после загрузки данных.
useFetch с параметрами и опциями:
<script setup>
const route = useRoute()
const page = ref(1)
const limit = ref(10)
// Динамические параметры — запрос повторяется при их изменении
const { data, pending, error, refresh } = await useFetch('/api/posts', {
// query параметры — реактивные, запрос обновится при изменении
query: { page, limit, category: route.query.category },
// Ключ кэша — по умолчанию URL + query, можно указать явно
key: `posts-page-${page.value}`,
// Преобразование данных
transform: (response) => ({
posts: response.data,
total: response.meta.total
}),
// Выполнять только на клиенте
server: false,
// Ленивая загрузка — не блокирует рендеринг страницы
lazy: true,
// Заголовки запроса
headers: useRequestHeaders(['cookie']),
// Обработчик ошибок
onResponseError({ response }) {
if (response.status === 401) {
navigateTo('/login')
}
}
})
</script>useAsyncData более гибкий, чем useFetch. Он принимает функцию вместо URL, что позволяет использовать любой источник данных:
<script setup>
// useAsyncData(key, fetchFunction, options)
// key — уникальный ключ для кэширования и дедупликации
const { data: user, pending, error } = await useAsyncData(
'current-user',
() => $fetch('/api/auth/me')
)
// Несколько источников данных в одном запросе
const { data } = await useAsyncData('dashboard', async () => {
const [stats, recentPosts, notifications] = await Promise.all([
$fetch('/api/stats'),
$fetch('/api/posts?limit=5'),
$fetch('/api/notifications?unread=true')
])
return { stats, recentPosts, notifications }
})
// Доступ к данным
if (data.value) {
console.log(data.value.stats)
console.log(data.value.recentPosts)
}
</script>useAsyncData с зависимостями и ручным обновлением:
<script setup>
const userId = ref(null)
const authStore = useAuthStore()
// Запрос зависит от userId — перезапустится при изменении
const { data: profile, refresh: refreshProfile } = await useAsyncData(
() => `user-profile-${userId.value}`, // Динамический ключ
() => {
if (!userId.value) return null
return $fetch(`/api/users/${userId.value}/profile`)
},
{
watch: [userId], // Перезапускать при изменении
immediate: true, // Выполнить сразу
default: () => null // Значение по умолчанию до загрузки
}
)
// Принудительное обновление
async function updateProfile(data) {
await $fetch(`/api/users/${userId.value}/profile`, {
method: 'PUT',
body: data
})
await refreshProfile()
}
</script>Понимание разницы помогает выбрать правильный инструмент. По сути useFetch — это синтаксический сахар над useAsyncData + $fetch:
// useFetch('/api/posts') — это то же самое что:
useAsyncData('get-/api/posts', () => $fetch('/api/posts'))Когда использовать useFetch:
Когда использовать useAsyncData:
Promise.all)Nuxt позволяет создавать API endpoints прямо в проекте через папку server/api/. Они работают на сервере и не попадают в клиентский бандл:
// server/api/hello.ts
// Доступен как GET /api/hello
export default defineEventHandler((event) => {
return {
message: 'Привет от сервера!',
time: new Date().toISOString()
}
})Роутинг серверных маршрутов основан на именовании файлов:
server/api/
├── hello.ts → GET /api/hello
├── users/
│ ├── index.ts → GET /api/users
│ ├── index.post.ts → POST /api/users (суффикс метода!)
│ ├── [id].ts → GET /api/users/:id
│ ├── [id].put.ts → PUT /api/users/:id
│ └── [id].delete.ts → DELETE /api/users/:id
└── auth/
├── login.post.ts → POST /api/auth/login
└── logout.post.ts → POST /api/auth/logout
Более сложный обработчик с параметрами и телом запроса:
// server/api/users/[id].ts
import { defineEventHandler, getRouterParam, createError } from 'h3'
export default defineEventHandler(async (event) => {
// Параметры маршрута
const id = getRouterParam(event, 'id')
if (!id || isNaN(Number(id))) {
throw createError({
statusCode: 400,
statusMessage: 'Некорректный ID пользователя'
})
}
// Запрос к базе данных (в реальном проекте)
const user = await db.users.findById(Number(id))
if (!user) {
throw createError({
statusCode: 404,
statusMessage: `Пользователь с ID ${id} не найден`
})
}
return user
})// server/api/users/index.get.ts
import { defineEventHandler, getQuery } from 'h3'
export default defineEventHandler(async (event) => {
// Query параметры: /api/users?page=1&limit=10
const query = getQuery(event)
const page = Number(query.page) || 1
const limit = Number(query.limit) || 10
const users = await db.users.findAll({
offset: (page - 1) * limit,
limit,
orderBy: 'createdAt'
})
return {
data: users,
meta: { page, limit, total: await db.users.count() }
}
})// server/api/posts/index.post.ts
import { defineEventHandler, readBody, createError } from 'h3'
export default defineEventHandler(async (event) => {
// Тело запроса
const body = await readBody(event)
// Валидация
if (!body.title || body.title.length < 3) {
throw createError({
statusCode: 422,
statusMessage: 'Заголовок должен содержать минимум 3 символа'
})
}
const post = await db.posts.create({
title: body.title,
content: body.content,
authorId: event.context.auth?.userId // Из middleware аутентификации
})
// Устанавливаем статус ответа
setResponseStatus(event, 201)
return post
})createError создаёт H3Error с нужным статусом. На клиенте эта ошибка попадает в error из useFetch:
// server/api/protected.ts
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'access_token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Не авторизован',
data: { code: 'UNAUTHORIZED' }
})
}
try {
const decoded = verifyJWT(token)
return { userId: decoded.sub, data: 'Секретные данные' }
} catch {
throw createError({
statusCode: 403,
statusMessage: 'Токен недействителен'
})
}
})<!-- Клиент обрабатывает ошибку -->
<script setup>
const { data, error } = await useFetch('/api/protected')
// Обработка ошибок разных типов
if (error.value) {
console.log(error.value.statusCode) // 401
console.log(error.value.statusMessage) // Не авторизован
console.log(error.value.data) // { code: 'UNAUTHORIZED' }
}
</script>Для серверных маршрутов с тяжёлыми вычислениями или внешними запросами можно включить кэширование:
// server/api/stats.ts
import { cachedEventHandler } from 'nitro/runtime'
export default cachedEventHandler(async (event) => {
// Этот код будет выполнен только раз за 60 секунд
// Потом Nitro отдаёт закэшированный ответ
const stats = await computeHeavyStats()
return stats
}, {
maxAge: 60, // Кэш на 60 секунд
swr: true, // Stale-while-revalidate: отдавать старое пока обновляется
name: 'stats-cache',
getKey: (event) => `stats-${getQuery(event).period || 'day'}`
})Ленивые варианты не блокируют рендеринг страницы. Страница показывается сразу, данные догружаются:
<template>
<div>
<!-- Страница рендерится сразу, данные подгружаются -->
<div v-if="pending">Загружаем комментарии...</div>
<CommentList v-else :comments="comments" />
</div>
</template>
<script setup>
// useLazyFetch — не блокирует рендеринг
// pending = true изначально, страница показывается без данных
const { data: comments, pending } = useLazyFetch('/api/comments')
// Нет await — не блокируем!
</script>Nuxt 3 предоставляет мощную систему получения данных, адаптированную для SSR. useFetch — для простых запросов с автоматическим кэшем, useAsyncData — для сложной логики и нескольких источников. Серверные маршруты в server/api/ позволяют создать полноценный backend прямо в Nuxt-проекте. Правильное использование этих инструментов — ключ к быстрым, SEO-friendly приложениям.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.