← Браузер/History API: SPA-маршрутизация#188 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: async и сетьТермин: Event LoopТермин: Core Web Vitals

History API: SPA-маршрутизация

В традиционных сайтах каждая страница — отдельный HTML-документ. В SPA (Single Page Application) страница одна, а маршрутизация симулируется через JavaScript. History API — это механизм браузера, который позволяет менять URL без перезагрузки страницы.

pushState и replaceState

Два ключевых метода:

// pushState — добавляет новую запись в историю
history.pushState(state, title, url)
// state — объект состояния (до 640 КБ)
// title — устарел, браузеры игнорируют
// url — новый URL (должен быть того же домена)

history.pushState({ page: 'about' }, '', '/about')
// URL изменился на /about, страница НЕ перезагрузилась

// replaceState — заменяет текущую запись (не добавляет в историю)
history.replaceState({ page: 'home' }, '', '/')

Навигация: back, forward, go

history.back()     // кнопка Назад
history.forward()  // кнопка Вперёд
history.go(-2)     // на 2 шага назад
history.go(1)      // на 1 шаг вперёд
history.go(0)      // перезагрузить страницу

Событие popstate

Срабатывает когда пользователь нажимает Назад/Вперёд или вызывается history.go(). НЕ срабатывает на pushState/replaceState:

window.addEventListener('popstate', (event) => {
  console.log('Переход:', location.pathname)
  console.log('Состояние:', event.state)
  renderPage(location.pathname)
})

Hash-роутинг vs History-роутинг

Hash-роутинг: example.com/#/about

  • Работает без серверной настройки
  • Часть после # не отправляется на сервер
  • Событие: hashchange
  • History-роутинг: example.com/about

  • Чистые URL без #
  • Требует настройки сервера: все пути должны возвращать index.html
  • Событие: popstate
  • Предпочтительный вариант для современных SPA
  • Простой роутер

    const routes = {
      '/': () => '<h1>Главная</h1>',
      '/about': () => '<h1>О нас</h1>',
      '/users/:id': ({ id }) => `<h1>Пользователь ${id}</h1>`,
    }
    
    function navigate(path) {
      history.pushState({}, '', path)
      render(path)
    }
    
    function render(path) {
      const handler = matchRoute(path, routes)
      document.getElementById('app').innerHTML = handler ? handler() : '404'
    }
    
    window.addEventListener('popstate', () => render(location.pathname))

    Сохранение состояния

    pushState может сохранять объект состояния (до 640 КБ), который возвращается в event.state при popstate:

    history.pushState({ scrollY: 450, filters: ['js', 'python'] }, '', '/articles')
    
    window.addEventListener('popstate', (e) => {
      if (e.state?.scrollY) window.scrollTo(0, e.state.scrollY)
    })

    Гварды навигации

    Перед переходом можно проверить условия (например, несохранённые изменения):

    function guardedNavigate(path) {
      if (hasUnsavedChanges() && !confirm('Есть несохранённые изменения. Уйти?')) {
        return false
      }
      navigate(path)
      return true
    }

    Интеграция в React/Vue

    React Router и Vue Router используют History API под капотом. useNavigate() в React Router вызывает history.pushState. <router-link> в Vue Router делает то же самое.

    Примеры

    Мини-роутер с матчингом маршрутов, гвардами навигации и симуляцией истории браузера

    // Мини-роутер с поддержкой параметров и гвардов
    
    class MiniRouter {
      constructor() {
        this._routes = new Map()
        this._history = ['/']
        this._historyIndex = 0
        this._guards = []
        this._listeners = []
      }
    
      // Регистрация маршрута (поддержка параметров :param)
      route(pattern, handler) {
        this._routes.set(pattern, handler)
        return this
      }
    
      // Гвард навигации — может отменить переход
      addGuard(guardFn) {
        this._guards.push(guardFn)
      }
    
      // Сопоставление URL с паттерном, извлечение параметров
      _matchRoute(path) {
        for (const [pattern, handler] of this._routes) {
          const paramNames = []
          const regexStr = pattern.replace(/:([^/]+)/g, (_, name) => {
            paramNames.push(name)
            return '([^/]+)'
          })
          const match = path.match(new RegExp(`^${regexStr}$`))
          if (match) {
            const params = {}
            paramNames.forEach((name, i) => { params[name] = match[i + 1] })
            return { handler, params }
          }
        }
        return null
      }
    
      async navigate(path) {
        // Запускаем гварды
        for (const guard of this._guards) {
          const result = await guard(this.getCurrentPath(), path)
          if (result === false) {
            console.log(`[Router] Навигация заблокирована гвардом: ${path}`)
            return false
          }
        }
    
        // Обрезаем «будущую» историю при новом переходе
        this._history = this._history.slice(0, this._historyIndex + 1)
        this._history.push(path)
        this._historyIndex++
    
        const match = this._matchRoute(path)
        const result = match ? match.handler(match.params) : '404 Страница не найдена'
    
        console.log(`[Router] Переход: ${path}`)
        console.log(`[Router] Контент: ${result}`)
    
        this._listeners.forEach(fn => fn(path, result))
        return result
      }
    
      back() {
        if (this._historyIndex <= 0) {
          console.log('[Router] Некуда идти назад')
          return null
        }
        this._historyIndex--
        const path = this._history[this._historyIndex]
        const match = this._matchRoute(path)
        const result = match ? match.handler(match.params) : '404'
        console.log(`[Router] Назад → ${path}`)
        this._listeners.forEach(fn => fn(path, result))
        return result
      }
    
      forward() {
        if (this._historyIndex >= this._history.length - 1) {
          console.log('[Router] Некуда идти вперёд')
          return null
        }
        this._historyIndex++
        const path = this._history[this._historyIndex]
        const match = this._matchRoute(path)
        const result = match ? match.handler(match.params) : '404'
        console.log(`[Router] Вперёд → ${path}`)
        this._listeners.forEach(fn => fn(path, result))
        return result
      }
    
      getCurrentPath() {
        return this._history[this._historyIndex]
      }
    
      onChange(listener) {
        this._listeners.push(listener)
      }
    }
    
    // Конфигурация роутера
    const router = new MiniRouter()
    
    router
      .route('/', () => 'Главная страница')
      .route('/about', () => 'О компании')
      .route('/users', () => 'Список пользователей')
      .route('/users/:id', ({ id }) => `Профиль пользователя #${id}`)
      .route('/users/:id/posts/:postId', ({ id, postId }) =>
        `Пост #${postId} пользователя #${id}`
      )
    
    // Гвард: защита admin-страниц
    router.addGuard((from, to) => {
      if (to.startsWith('/admin')) {
        console.log('[Guard] Доступ к /admin запрещён')
        return false
      }
      return true
    })
    
    // Подписываемся на изменения
    router.onChange((path, content) => {
      console.log(`[App] Рендерим: ${content}`)
    })
    
    // Навигация
    await router.navigate('/')
    await router.navigate('/about')
    await router.navigate('/users/42')
    await router.navigate('/users/42/posts/7')
    await router.navigate('/admin')  // заблокирован гвардом
    
    console.log('\n--- Кнопки назад/вперёд ---')
    router.back()   // /users/42/posts/7
    router.back()   // /users/42
    router.forward()  // /users/42/posts/7

    History API: SPA-маршрутизация

    В традиционных сайтах каждая страница — отдельный HTML-документ. В SPA (Single Page Application) страница одна, а маршрутизация симулируется через JavaScript. History API — это механизм браузера, который позволяет менять URL без перезагрузки страницы.

    pushState и replaceState

    Два ключевых метода:

    // pushState — добавляет новую запись в историю
    history.pushState(state, title, url)
    // state — объект состояния (до 640 КБ)
    // title — устарел, браузеры игнорируют
    // url — новый URL (должен быть того же домена)
    
    history.pushState({ page: 'about' }, '', '/about')
    // URL изменился на /about, страница НЕ перезагрузилась
    
    // replaceState — заменяет текущую запись (не добавляет в историю)
    history.replaceState({ page: 'home' }, '', '/')

    Навигация: back, forward, go

    history.back()     // кнопка Назад
    history.forward()  // кнопка Вперёд
    history.go(-2)     // на 2 шага назад
    history.go(1)      // на 1 шаг вперёд
    history.go(0)      // перезагрузить страницу

    Событие popstate

    Срабатывает когда пользователь нажимает Назад/Вперёд или вызывается history.go(). НЕ срабатывает на pushState/replaceState:

    window.addEventListener('popstate', (event) => {
      console.log('Переход:', location.pathname)
      console.log('Состояние:', event.state)
      renderPage(location.pathname)
    })

    Hash-роутинг vs History-роутинг

    Hash-роутинг: example.com/#/about

  • Работает без серверной настройки
  • Часть после # не отправляется на сервер
  • Событие: hashchange
  • History-роутинг: example.com/about

  • Чистые URL без #
  • Требует настройки сервера: все пути должны возвращать index.html
  • Событие: popstate
  • Предпочтительный вариант для современных SPA
  • Простой роутер

    const routes = {
      '/': () => '<h1>Главная</h1>',
      '/about': () => '<h1>О нас</h1>',
      '/users/:id': ({ id }) => `<h1>Пользователь ${id}</h1>`,
    }
    
    function navigate(path) {
      history.pushState({}, '', path)
      render(path)
    }
    
    function render(path) {
      const handler = matchRoute(path, routes)
      document.getElementById('app').innerHTML = handler ? handler() : '404'
    }
    
    window.addEventListener('popstate', () => render(location.pathname))

    Сохранение состояния

    pushState может сохранять объект состояния (до 640 КБ), который возвращается в event.state при popstate:

    history.pushState({ scrollY: 450, filters: ['js', 'python'] }, '', '/articles')
    
    window.addEventListener('popstate', (e) => {
      if (e.state?.scrollY) window.scrollTo(0, e.state.scrollY)
    })

    Гварды навигации

    Перед переходом можно проверить условия (например, несохранённые изменения):

    function guardedNavigate(path) {
      if (hasUnsavedChanges() && !confirm('Есть несохранённые изменения. Уйти?')) {
        return false
      }
      navigate(path)
      return true
    }

    Интеграция в React/Vue

    React Router и Vue Router используют History API под капотом. useNavigate() в React Router вызывает history.pushState. <router-link> в Vue Router делает то же самое.

    Примеры

    Мини-роутер с матчингом маршрутов, гвардами навигации и симуляцией истории браузера

    // Мини-роутер с поддержкой параметров и гвардов
    
    class MiniRouter {
      constructor() {
        this._routes = new Map()
        this._history = ['/']
        this._historyIndex = 0
        this._guards = []
        this._listeners = []
      }
    
      // Регистрация маршрута (поддержка параметров :param)
      route(pattern, handler) {
        this._routes.set(pattern, handler)
        return this
      }
    
      // Гвард навигации — может отменить переход
      addGuard(guardFn) {
        this._guards.push(guardFn)
      }
    
      // Сопоставление URL с паттерном, извлечение параметров
      _matchRoute(path) {
        for (const [pattern, handler] of this._routes) {
          const paramNames = []
          const regexStr = pattern.replace(/:([^/]+)/g, (_, name) => {
            paramNames.push(name)
            return '([^/]+)'
          })
          const match = path.match(new RegExp(`^${regexStr}$`))
          if (match) {
            const params = {}
            paramNames.forEach((name, i) => { params[name] = match[i + 1] })
            return { handler, params }
          }
        }
        return null
      }
    
      async navigate(path) {
        // Запускаем гварды
        for (const guard of this._guards) {
          const result = await guard(this.getCurrentPath(), path)
          if (result === false) {
            console.log(`[Router] Навигация заблокирована гвардом: ${path}`)
            return false
          }
        }
    
        // Обрезаем «будущую» историю при новом переходе
        this._history = this._history.slice(0, this._historyIndex + 1)
        this._history.push(path)
        this._historyIndex++
    
        const match = this._matchRoute(path)
        const result = match ? match.handler(match.params) : '404 Страница не найдена'
    
        console.log(`[Router] Переход: ${path}`)
        console.log(`[Router] Контент: ${result}`)
    
        this._listeners.forEach(fn => fn(path, result))
        return result
      }
    
      back() {
        if (this._historyIndex <= 0) {
          console.log('[Router] Некуда идти назад')
          return null
        }
        this._historyIndex--
        const path = this._history[this._historyIndex]
        const match = this._matchRoute(path)
        const result = match ? match.handler(match.params) : '404'
        console.log(`[Router] Назад → ${path}`)
        this._listeners.forEach(fn => fn(path, result))
        return result
      }
    
      forward() {
        if (this._historyIndex >= this._history.length - 1) {
          console.log('[Router] Некуда идти вперёд')
          return null
        }
        this._historyIndex++
        const path = this._history[this._historyIndex]
        const match = this._matchRoute(path)
        const result = match ? match.handler(match.params) : '404'
        console.log(`[Router] Вперёд → ${path}`)
        this._listeners.forEach(fn => fn(path, result))
        return result
      }
    
      getCurrentPath() {
        return this._history[this._historyIndex]
      }
    
      onChange(listener) {
        this._listeners.push(listener)
      }
    }
    
    // Конфигурация роутера
    const router = new MiniRouter()
    
    router
      .route('/', () => 'Главная страница')
      .route('/about', () => 'О компании')
      .route('/users', () => 'Список пользователей')
      .route('/users/:id', ({ id }) => `Профиль пользователя #${id}`)
      .route('/users/:id/posts/:postId', ({ id, postId }) =>
        `Пост #${postId} пользователя #${id}`
      )
    
    // Гвард: защита admin-страниц
    router.addGuard((from, to) => {
      if (to.startsWith('/admin')) {
        console.log('[Guard] Доступ к /admin запрещён')
        return false
      }
      return true
    })
    
    // Подписываемся на изменения
    router.onChange((path, content) => {
      console.log(`[App] Рендерим: ${content}`)
    })
    
    // Навигация
    await router.navigate('/')
    await router.navigate('/about')
    await router.navigate('/users/42')
    await router.navigate('/users/42/posts/7')
    await router.navigate('/admin')  // заблокирован гвардом
    
    console.log('\n--- Кнопки назад/вперёд ---')
    router.back()   // /users/42/posts/7
    router.back()   // /users/42
    router.forward()  // /users/42/posts/7

    Задание

    Реализуй createRouter(routes) где routes — объект вида { "/path": handlerFn }. Методы: navigate(path) вызывает нужный обработчик и добавляет в историю, back() возвращается назад, forward() идёт вперёд, getCurrentPath() возвращает текущий путь, onNavigate(callback) регистрирует обработчик переходов. История симулируется через массив.

    Подсказка

    history.splice(currentIndex + 1) обрежет всё после текущего индекса. После navigate() currentIndex = history.length - 1. back() делает currentIndex-- перед рендером. forward() делает currentIndex++. getCurrentPath() возвращает history[currentIndex].

    Загружаем среду выполнения...
    Загружаем AI-помощника...