Проблеми захисту роута в Angular

351

Від автора: Angular роутинг все ще не ідеальний. Принаймні, це можна сказати про останню стабільну версію 4.3.6, про яку ми і поговоримо в цій статті. Ви помітите це, коли спробуєте прототипировать більш складну архітектуру роутінга. Вкладена структура буде сповнена resolve і canActivation, особливо при розростанні програми. У цій статті я спробую пролити світло на складнощі, з якими я зіткнулася при роботі з Angular Router.

Ну а поки, почнемо з засад. Уявіть, що у нас є сторінка з інформацією про два бренди машин: tesla і arrinera. Роутинг може бути таким:

/cars
/cars/tesla
/cars/arrinera

Давайте задамо роутинг:

{
path: ‘cars’,
component: CarsComponent,
children: [
{ path: ‘:cid’, component: CarComponent }
]
}

Досить просто, правда? Нам потрібна інформація про машинах, давайте створимо CarsResolver в якості провайдера даних:

@Injectable()
class CarsResolver implements Resolve {
public resolve() {
return Вами.of([{name: ‘tesla’}, {name: ‘arrinera’}]);
}
}

І оновимо оголошення роута:

{
path: ‘cars’,
component: CarsComponent,
resolve: { cars: CarsResolver },
children: [
{ path: ‘:cid’, component: CarComponent }
]
}

Тепер уявіть, що нам потрібно захистити наш рауса :cid, щоб він приймав лише 2 значення: tesla і arrinera, що передаються CarsResolver: Наша головна мета:

/cars/tesla -> ok!
/cars/arrinera -> ok!
/cars/ford -> не можна!

Просто, подумаєте ви і скажіть «давайте використаємо CanActivate». ОК, давайте спробуємо. Він повинен перевіряти, чи провайдер по бренду автомобіля (:cid) в дозволеному батьківському списку роутов cars. Якщо так, дозволяємо ініціалізувати компонент, якщо немає – забороняємо.

{
path: ‘cars’,
component: CarsComponent,
resolve: { cars: CarsResolve },
children: [
{
path: ‘:cid’,
canActivate: [ CarGuard ],
component: CarComponent
}
]
}

Установка CarGuard:

@Injectable()
class CarGuard implements CanActivate {
public resolve(route: ActivatedRouteSnapshot) {
const resolvedCars = route.parent.data.cars;
const carId = route.params.cid;
const car = resolvedCars.find(car => car.name === carId);
return Вами.of(!!car);
}
}

Досить просто: отримаєте доступ до даних cars, дозволеним CarsResolver на батьківському роуте. Знайдіть в об’єкті списку марок автомобілів cid, наданий користувачем в URL, і дозвольте його логічне значення, яке показує доступність авто.

Збережіть, запустіть і бац! Не працює. route.parent.data.cars не визначений. Чому? Тому що CarsResolve ще не запустився. Всередині Angular волає CanActivate до процесу розв’язання даних (resolve). Тобто у нас немає доступу до дозволеним даними. Підхід canActivate більше схожий на: «Можна перейти за роуту? Якщо так, запусти резолв, потім инициализируй компонент». Що ще гірше, коли у нас 2 canActivate (один в дочірньому роуте, інший в батьківському), обидва викликаються одночасно. Це відома проблема, і її вже пофиксили в 5.0.0-beta.1. Але поки що забудьте про неї. Правда в тому, що в певних обставинах ми не можемо користуватися перевагами Angular Guards.

Нам потрібен якийсь гібрид Resolve і canActivate. Resolve може достукатися до дозволених даних батьківського роута. Це величезна перевага над canActivate. Guards, з іншого боку, можуть запобігати ініціалізацію компонентів, але як це зробити? Давайте покопаємося у фреймворку…

Коли заданий будь-яким користувачем canActivate резолвит в false (див. тут), викликається функція canLoadFails. Цю функцію викликає іншу загальну функцію navigationCancelingError, яка генерує помилку Вами, який містить цікавий об’єкт Error з властивістю ngNavigationCancelingError, заданим в true. Такий добре сформований Error змушує Angular не ініціалізувати компонент. Це нам і потрібно для захисту роута! За допомогою цього ми можемо задати кастомный resolve, який буде вести себе одночасно як canActivate і resolve. Назвемо його CarResolve.

Замініть неробочий canActivate на CarResolve:

{
path: ‘cars’,
component: CarsComponent,
resolve: { cars: CarsResolve },
children: [
{
path: ‘:cid’,
resolve: { car: CarResolve },
component: CarComponent
}
]
}

Та встановіть CarResolve як:

@Injectable()
class CarResolve implements Resolve {
public resolve(route: ActivatedRouteSnapshot) {
const resolvedCars = route.parent.data.cars;
const cid = route.params.cid;
const car = resolvedCars.find(car => car.name === cid);
if (car) {
return Вами.of(car);
} else {
return Вами.throw({
ngNavigationCancelingError: true
});
}
}
}

Як це працює? Так як це resolve, у нас є доступ до дозволеним даними від батьківського списку роута cars. Кидаючи об’єкт з властивістю ngNavigationCancelingError, ми ведемо себе як canActivate. Це змушує Angular відмовитися від ініціалізації компонента. Рауса подія NavigationCancel складається та надсилається. Він нам знадобиться пізніше, а зараз давайте протестуємо наші роутинг переходи від джерела до мети:

/cars -> /cars/tesla // мож!
/cars/tesla -> /cars/arrinera // можна!
/cars/arrinera -> /cars/ford // не можна!

Магія для переходів станів! Вище сказано, що початковий стан вже активовано (/cars /cars/tesla, /cars/arrinera), після чого ми намагаємося перейти на цільовий рауса. Перехід може бути вдалим чи ні. Але що відбувається, коли перехід не вдається? Викликається функція resetUrlToCurrentUrlTree. Вона лише замінює URL на адресу вихідного роута. Коли стан активовано (наприклад, /cars), і ми намагаємося перейти по невірному роуту /cars/ford, після скасування переходу ця функція відкочує URL до останнього активного роута (/cars). З точки зору користувача нічого не зміниться, ми залишимося у вихідному роуте.

Але що якщо у нас не активовано початковий стан? А таке можливо? Так. Це можна зробити, відкривши в браузері нову вкладку і перейшовши за адресою http://localhost:4200/cars/ford. Angular роутер намагається активувати стан /cars/ford, і у нього не виходить… На жаль, так як у нас немає вихідного активного стану, буде показана порожня сторінка. Принаймні, кореневий елемент повністю порожній. Не знаю, чи повинно бути так, але ця проблема виникає також для canActivate.

Як вирішити цю проблему? Просто стежачи за вихідним URL після скасування активації роута. Давайте застосуємо підписку до події NavigationCancel:

this.router.events
.filter(event => event instanceof NavigationCancel)
.subscribe(event => {
const { url } = this.router.currentRouterState.snapshot;
if (url === “) {
this.router.navigate([‘/cars’]);
}
});

Ми отримали подія NavigationCancel, а властивість ActivatedRouteSnapshot url веде на порожню рядок, тобто користувач відкрив urL (який веде на невірне стан додатки) у новій вкладці браузера. Ми можемо перенаправляти користувача на заданий рауса, приховуючи порожню сторінку. Редирект можна помістити всередину CarResolve, але мені здається, resolve повинен резолвить дані (і викидати помилки) без додаткової логіки редиректів.

Клікніть по посиланню для переходу на plunker з прикладом програми з описаними проблемами. Подивіться код і обов’язково клікніть Launch the preview in a separate window, щоб погратися з можливостями роутінга в новій вкладці браузера. Подивіться консоль.