← Курс/Nuxt: файловая система и роутинг#246 из 257+25 XP

Nuxt: файловая система и роутинг

Файловый роутинг (pages/)

Nuxt генерирует маршруты автоматически из структуры папки pages/:

pages/
├── index.vue         → /
├── about.vue         → /about
├── contact.vue       → /contact
├── blog/
│   ├── index.vue     → /blog
│   └── [slug].vue    → /blog/vue-3-tutorial
├── users/
│   ├── index.vue     → /users
│   └── [id]/
│       ├── index.vue → /users/42
│       └── edit.vue  → /users/42/edit
└── [...404].vue      → /любой/несуществующий/путь

Динамические маршруты

<!-- pages/users/[id].vue -->
<script setup>
const route = useRoute()
const id = route.params.id  // '42'

// Загрузка данных по параметру
const { data: user } = await useFetch(`/api/users/${id}`)
</script>

<template>
  <div>
    <h1>{{ user?.name }}</h1>
  </div>
</template>

Вложенные маршруты (Nested Routes)

pages/
└── users/
    ├── index.vue      → /users (список)
    └── [id].vue       → /users/:id (детали)

Вложенный layout через <NuxtPage>:

<!-- pages/users.vue — родительский layout -->
<template>
  <div>
    <Sidebar />
    <NuxtPage />  <!-- дочерние маршруты рендерятся здесь -->
  </div>
</template>

layouts/ — лейауты страниц

<!-- layouts/default.vue — применяется ко всем страницам -->
<template>
  <div>
    <Header />
    <main>
      <slot />  <!-- контент страницы -->
    </main>
    <Footer />
  </div>
</template>
<!-- layouts/admin.vue — кастомный layout -->
<template>
  <div class="admin">
    <AdminSidebar />
    <slot />
  </div>
</template>
// pages/admin/index.vue — использование кастомного layout
definePageMeta({ layout: 'admin' })

middleware/ — навигационные guard'ы

// middleware/auth.ts — защита маршрутов
export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated } = useAuth()

  if (!isAuthenticated.value && to.path !== '/login') {
    return navigateTo('/login')  // редирект
  }
})
// Применение к конкретным страницам:
// pages/dashboard.vue
definePageMeta({
  middleware: 'auth'
})

// Или к группе маршрутов:
definePageMeta({
  middleware: ['auth', 'role-check']
})

navigateTo() и useRouter/useRoute

// Программная навигация
const router = useRouter()
const route = useRoute()

// Способ 1: navigateTo() (рекомендуется в Nuxt)
await navigateTo('/about')
await navigateTo({ name: 'users-id', params: { id: 42 } })
await navigateTo('https://example.com', { external: true })

// Способ 2: router.push()
router.push('/about')
router.replace('/about')
router.back()

// Чтение текущего маршрута
console.log(route.path)         // '/users/42'
console.log(route.params.id)    // '42'
console.log(route.query.page)   // '2' (из ?page=2)

definePageMeta

Метаданные страницы (SSR, cache, layout, middleware):

definePageMeta({
  layout: 'admin',
  middleware: ['auth'],
  ssr: false,          // отключить SSR для этой страницы
  title: 'Страница',   // SEO заголовок
  keepalive: true,     // кэшировать компонент страницы
})

Примеры

Реализация файлового роутера — генерация маршрутов из дерева файлов и навигация с middleware

// Реализуем упрощённый файловый роутер Nuxt:
// генерируем маршруты из файловой структуры и обрабатываем навигацию.

// --- Генератор маршрутов из файлов ---
function buildRoutesFromFiles(files) {
  return files.map(filePath => {
    const withoutExt = filePath.replace(/\.vue$/, '')
    const segments = withoutExt.split('/').map(seg => {
      if (seg.startsWith('[...') && seg.endsWith(']'))
        return { type: 'catchAll', param: seg.slice(4, -1) }
      if (seg.startsWith('[[') && seg.endsWith(']]'))
        return { type: 'optional', param: seg.slice(2, -2) }
      if (seg.startsWith('[') && seg.endsWith(']'))
        return { type: 'dynamic', param: seg.slice(1, -1) }
      if (seg === 'index')
        return { type: 'index' }
      return { type: 'static', value: seg }
    })

    // Убираем финальный index
    if (segments.at(-1)?.type === 'index') segments.pop()

    const pathParts = segments.map(s => {
      if (s.type === 'static')   return s.value
      if (s.type === 'dynamic')  return ':' + s.param
      if (s.type === 'optional') return ':' + s.param + '?'
      if (s.type === 'catchAll') return ':' + s.param + '*'
      return ''
    }).filter(Boolean)

    return {
      path: '/' + pathParts.join('/'),
      file: filePath,
      params: segments.filter(s => s.type !== 'static' && s.type !== 'index').map(s => s.param),
      meta: {},
    }
  })
}

// --- Роутер с middleware ---
class NuxtRouter {
  constructor(routes) {
    this.routes = routes
    this.middlewares = {}
    this.currentRoute = null
    this.history = []
  }

  addMiddleware(name, fn) {
    this.middlewares[name] = fn
  }

  matchRoute(url) {
    // Убираем query string
    const [pathname, queryString] = url.split('?')
    const query = {}
    if (queryString) {
      queryString.split('&').forEach(part => {
        const [k, v] = part.split('=')
        if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || '')
      })
    }

    const urlSegs = pathname.split('/').filter(Boolean)

    for (const route of this.routes) {
      const routeSegs = route.path.split('/').filter(Boolean)
      const params = {}
      let matched = true

      // Простое сопоставление (без catch-all для краткости)
      if (routeSegs.length !== urlSegs.length && !route.path.includes('*')) {
        matched = false
      } else {
        for (let i = 0; i < routeSegs.length; i++) {
          if (routeSegs[i].startsWith(':')) {
            params[routeSegs[i].slice(1).replace('?', '')] = urlSegs[i] || null
          } else if (routeSegs[i] !== urlSegs[i]) {
            matched = false
            break
          }
        }
      }

      if (matched) return { route, params, query, fullPath: url }
    }

    return null
  }

  async navigate(to) {
    const from = this.currentRoute
    const match = typeof to === 'string'
      ? this.matchRoute(to)
      : this.matchRoute(to.path + (to.query ? '?' + new URLSearchParams(to.query).toString() : ''))

    if (!match) {
      console.warn(`[Router] Маршрут не найден: ${JSON.stringify(to)}`)
      return false
    }

    // Выполняем middleware
    const middlewareList = match.route.meta.middleware || []
    for (const name of middlewareList) {
      const mw = this.middlewares[name]
      if (!mw) continue

      const result = await mw(match, from)
      if (result && result.redirect) {
        console.log(`[Middleware:${name}] Редирект → ${result.redirect}`)
        return this.navigate(result.redirect)
      }
    }

    this.history.push(this.currentRoute?.fullPath)
    this.currentRoute = match
    console.log(`[Router] Навигация → ${match.fullPath}`, match.params)
    return true
  }

  back() {
    const prev = this.history.pop()
    if (prev) return this.navigate(prev)
  }
}

// === Демо ===
const files = [
  'index.vue',
  'about.vue',
  'users/index.vue',
  'users/[id].vue',
  'users/[id]/edit.vue',
  'blog/index.vue',
  'blog/[slug].vue',
  'admin/index.vue',
  'admin/settings.vue',
]

const routes = buildRoutesFromFiles(files)

console.log('=== Сгенерированные маршруты ===')
routes.forEach(r => console.log(`  ${r.path.padEnd(25)}${r.file}`))

const router = new NuxtRouter(routes)

// Добавляем middleware авторизации
router.addMiddleware('auth', async (to, from) => {
  const isAuthenticated = to.route.path !== '/admin/index'
  if (!isAuthenticated) return { redirect: '/login' }
})

// Навигация маршрута с meta
routes.find(r => r.path === '/admin').meta.middleware = ['auth']

console.log('\n=== Навигация ===')
router.navigate('/about')
router.navigate('/users/42')
router.navigate('/users/42/edit')
router.navigate('/blog/vue-3-guide?page=2')

console.log('\nТекущий маршрут:', router.currentRoute?.route.path)
console.log('Query:', router.currentRoute?.query)

router.back()
console.log('После back():', router.currentRoute?.route.path)