Avatar ·

Прокрутка страницы вверх при смене маршрута в Angular 2+

Всем привет! Только начал изучать Angular и столкнулся с проблемой. При переходе между страницами с помощью `routerLink` браузер сохраняет позицию скролла, и если я был внизу длинной страницы, а затем перехожу на другую страницу, то она открывается тоже внизу. Это очень неудобно для пользователей. Я использую Angular 14 с модульной архитектурой. Вот пример моих маршрутов: ```typescript // app-routing.module.ts const routes: Routes = [ { path: '', component: HomeComponent }, { path: 'products', component: ProductsComponent }, { path: 'about', component: AboutComponent } ]; ``` Переходы делаю через обычный `routerLink` в шаблоне: ```html ``` Пробовал добавить `(click)="window.scrollTo(0,0)"` на каждую ссылку — это работает, но выглядит костыльно. Плюс, если пользователь переходит по истории браузера (кнопки «Назад»/«Вперед»), то скролл всё равно сбрасывается, а нужно сохранять позицию для истории навигации. Подскажите, как правильно реализовать прокрутку вверх при смене маршрута, чтобы: 1. При любом переходе (через ссылку, программно через `router.navigate()`, через адресную строку) страница открывалась сверху. 2. При навигации по истории браузера скролл восстанавливался (т.е. если я был на странице товаров внизу, нажал «Назад» — вернулся на главную сверху, потом «Вперед» — снова оказался внизу товаров). Заранее спасибо! <<>>

Решение: глобальный сервис с подпиской на события роутера

Лучший способ — использовать встроенный сервис Router и подписаться на события навигации. Angular предоставляет NavigationEnd — событие, которое срабатывает после успешного завершения перехода. В этом обработчике мы вызываем window.scrollTo(0, 0).

Чтобы решение работало для всего приложения, создадим отдельный сервис или просто добавим логику в корневой компонент AppComponent.

Вариант 1: Логика в AppComponent (простой)

Подходит для небольших приложений. Подписываемся на события роутера в конструкторе:

// app.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {
  private subscription: Subscription;

  constructor(private router: Router) {}

  ngOnInit() {
    this.subscription = this.router.events
      .pipe(filter(event => event instanceof NavigationEnd))
      .subscribe(() => {
        window.scrollTo(0, 0);
      });
  }

  ngOnDestroy() {
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
  }
}

Вариант 2: Вынос в сервис (рекомендуемый)

Лучше инкапсулировать логику в отдельный сервис, который можно переиспользовать и тестировать:

// scroll-to-top.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';
import { Subscription } from 'rxjs';

@Injectable({
  providedIn: 'root' // сервис будет синглтоном на всё приложение
})
export class ScrollToTopService implements OnDestroy {
  private subscription: Subscription;

  constructor(private router: Router) {
    this.subscription = this.router.events
      .pipe(filter(event => event instanceof NavigationEnd))
      .subscribe(() => {
        window.scrollTo({ top: 0, behavior: 'auto' }); // 'smooth' для плавной прокрутки
      });
  }

  ngOnDestroy() {
    this.subscription?.unsubscribe();
  }
}

Затем просто внедряем сервис в AppComponent:

// app.component.ts
import { Component } from '@angular/core';
import { ScrollToTopService } from './scroll-to-top.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent {
  constructor(private scrollToTopService: ScrollToTopService) {
    // Сервис сам подпишется в конструкторе
  }
}

Решение проблемы с историей браузера (scroll restoration)

По умолчанию браузер восстанавливает позицию скролла при навигации «Назад»/«Вперед». Angular это мешает, так как наш код сбрасывает скролл в 0 после каждого перехода. Чтобы это исправить, нужно использовать Scroll и ViewportScroller из @angular/common.

Angular предоставляет встроенный механизм для управления скроллом через RouterModule.forRoot(routes, { scrollPositionRestoration: 'enabled' }). Это автоматически восстанавливает позицию скролла при переходе по истории и сбрасывает в 0 при обычной навигации.

// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'products', component: ProductsComponent },
  { path: 'about', component: AboutComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes, {
    scrollPositionRestoration: 'enabled' // включает восстановление скролла
  })],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Этот подход решает обе задачи:

  • Прокрутка вверх при обычном переходе по ссылке или программной навигации.
  • Восстановление позиции при использовании кнопок «Назад»/«Вперед».

Важно: Не нужно комбинировать scrollPositionRestoration с ручной подпиской на NavigationEnd, иначе будет конфликт. Используйте только один из методов.

<<>>

Альтернативный подход: использование директивы

Если вы не хотите менять конфигурацию роутера или вам нужно больше контроля (например, плавная прокрутка с задержкой), можно создать директиву, которая будет применяться к элементу <router-outlet>:

// scroll-to-top.directive.ts
import { Directive, HostListener } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';

@Directive({
  selector: '[appScrollToTop]'
})
export class ScrollToTopDirective {
  constructor(private router: Router) {
    this.router.events
      .pipe(filter(event => event instanceof NavigationEnd))
      .subscribe(() => {
        window.scrollTo({ top: 0, behavior: 'smooth' });
      });
  }
}

Применение в шаблоне:

<router-outlet appScrollToTop></router-outlet>

Плюсы:

  • Не нужно менять AppComponent или сервис.
  • Легко добавить/убрать для отдельных частей приложения.
  • Можно передавать параметры (например, @Input() offset: number = 0).

Минусы:

  • Не решает проблему с историей браузера — для этого всё равно нужен scrollPositionRestoration в конфигурации роутера.
  • Если у вас несколько <router-outlet>, придётся вешать директиву на каждый.

Этот вариант подходит, когда нужно быстро добавить функциональность без рефакторинга существующего кода.