Интеллектуальная страница 404 в Angular

Интеллектуальная страница 404 в Angular

От автора: опечатки в URL-адресах — это прямой путь к странице 404. Но можем ли мы улучшить пользовательский опыт в этой ситуации? Например, можем ли мы предоставить пользователю правильный путь? Сегодня я покажу, как сделать прогноз правильного местоположения на странице Angular 404 без машинного обучения и магии.

Пример приложения построен с использованием Angular 7.1.0 и Angular CLI 7.1.2 . Но код должен работать с любой версией Angular без каких-либо серьезных проблем.

Для чего?

Чем плохи стандартные страницы «Not found»? Обычно это не помогает найти правильный адрес, который ищет пользователь ресурса. Они могут содержать ссылку на домашнюю страницу или список навигации. Но найти контент, который нужен в данный момент, может быть сложно. Как мы можем решить эту проблему? Будет здорово, если мы посмотрим на существующую карту сайта или список маршрутов и найдем то, что может иметь в виду пользователь.

Если мы говорим о современных JS-фреймворках и, в частности, об Angular, у нас уже есть все ссылки в приложении в виде конфигурации маршрутизатора. Это решает проблему поиска источника.

Что?

Хорошо, когда мы можем проанализировать ссылку, предоставленную пользователем? Лучшим местом для этого может быть роутер guard. Если мы хотим передать данные в компонент 404, мы можем использовать резольвер guard. Честно говоря, я не нашел реальных случаев использования резольвера в приложениях, над которыми работал, поэтому очень рад, что нашел для них этот вариант использования.

Ну, единственный недостающий элемент решения — как понять, что хотел увидеть пользователь? Вы можете подумать о нейронных сетях. Мы могли бы обучить модель на основе некоторого набора данных, например, аналитики. Но это может привести к большим расходам и потребовать двух дополнительных шагов: собрать аналитику и обучить модель, используя ее. Есть еще одно решение, которое может сработать -  алгоритм расстояния Левенштейна. Что это такое? Расстояние Левенштейна — это ряд операций (вставка, перемещение, удаление символа), которые необходимо выполнить для преобразования одной строки в другую. Это довольно просто и не займет много строк кода в TypeScript. Я могу сказать, что вы уже встречали этот алгоритм. Хотите знать, где? Подумайте, например, об опечатках, обрабатываемых инструментами CLI git. Если вы выполните опечатку в команде git, он попытается предложить вам правильный вариант:

git cone git: 'cone' is not a git command. See 'git --help'. The most similar command is clone

Боле того, тот же алгоритм используется в Angular CLI:

ng ganerate The specified command ("ganerate") is invalid. For a list of available options, run "ng help". Did you mean "generate"?

Как?

Как использовать расстояние Левенштейна, чтобы предложить правильное значение? Нам нужен список правильных значений — в случае инструмента CLI команды, и пути в случае маршрутизации. Давайте назовем это словарь. У нас есть неверный пользовательский ввод. Затем нам нужно вычислить расстояние между пользовательским вводом и каждой записью в нашем словаре. Элемент словаря с наименьшим расстоянием будет возможно тем, что нужно пользователю. Это означает, что мы можем отсортировать словарь по расстоянию Левенштейна до недопустимого значения. И все готово.

В моем примере я хочу передать предложенный путь компоненту “Page not found” и вывести сообщение с правильным URL-адресом. Вы можете попробовать поработать с этим демо. Попробуйте ввести неверный путь после /#/ и посмотреть, что получится. Я использовал стратегию определения местоположения хэша только для демонстрационных целей, так как в демонстрационном приложении я хочу обрабатывать ошибку 404 вместо страниц GitHub.

На скриншоте выше я сделал ошибку и попытался перейти на страницу «hame». С помощью резолвер guard я показал пользователю действительную ссылку — «/home». Вы можете найти исходный код в репозитории на GitHub.

Реализация

Это была теория, но позвольте мне объяснить, как добиться этого. Приложение было создано с помощью Angular CLI. Затем я сгенерировал три компонента home, about и contact. Во время создания приложения я выбираю параметр routing для генерации модуля маршрутизации. Первый кусок головоломки — нам нужен словарь. Я решил создать карту путей — объект с ключами, чтобы иметь удобочитаемое имя и строку пути в качестве значения. И сохранить эту карту в файле app-paths.ts. В этом случае я могу использовать эту карту в определениях маршрутизатора и в преобразователе.

export const paths = { home: 'home', about: 'about', contact: 'contact'
}

Затем внутри app-routing.module.ts я использовал его для определения маршрутизатора:

const routes: Routes = [ { path: '', pathMatch: 'full', redirectTo: paths.home }, { path: paths.home, component: HomeComponent }, { path: paths.about, component: AboutComponent }, { path: paths.contact, component: ContactComponent }, { path: '**', resolve: { path: PathResolveService }, component: NotFoundComponent }
];

Все остальное в этом модуле стандартное, единственное, что было необходимо в моем конкретном случае — это параметр useHash: true для модуля маршрутизатора. Часть маршрутизации, на которой мы сейчас сосредоточимся, — это путь «**». Он будет соответствовать всему, что не было найдено в существующей конфигурации маршрутизатора. Обычно он используется для просмотра страницы 404. Поэтому я также создал NotFoundComponent и добавил свойство path в конфигурацию resolve. Для разрешения данных пути следует использовать PathResolveService.

Во-первых, давайте посмотрим на NotFoundComponent:

@Component({ selector: 'app-not-found', template: ` <h2> 404 - Page not found </h2> <p *ngIf="path">You might want to go to the <a [routerLink]="path">"{{ path }}" page</a></p> `
})
export class NotFoundComponent implements OnInit { path: string; constructor(private route: ActivatedRoute) {} ngOnInit() { this.route.data.pipe(take(1)) .subscribe((data: { path: string }) => { this.path = data.path; }); }
}

Мы используем снимок активированного маршрута, чтобы получить разрешенный путь с помощью PathResolveService. Этот путь используется в шаблоне компонента, чтобы показать удобное для пользователя сообщение со ссылкой на правильный ресурс.

Теперь перейдем к финальной и самой важной части — распознавателю. Как и любой резолвер guard данных, он может дополнительно реализовать интерфейс Resolve:

@Injectable({ providedIn: 'root'
})
export class PathResolveService implements Resolve<string | null> { resolve( route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): string | null {}
}

Используя RouterStateSnapshot мы можем получить URL, введенный пользователем:

resolve( route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): string | null { const typoPath = state.url.replace('/', ''); const threshold = this.getThreshold(typoPath); const dictionary = Object.values(paths) .filter(path => Math.abs(path.length - typoPath.length) < threshold); if (!dictionary.length) return null; this.sortByDistances(typoPath, dictionary); return `/${dictionary[0]}`;
}

Позвольте мне объяснить, что происходит в методе разрешения. После получения пользовательского ввода мы рассчитываем порог — максимальная длина дельты между вводом и правильным значением из словаря путей. В моем случае я решил использовать три для слов длиной менее пяти символов, иначе 5. Это позволяет фильтровать словарь по значениям, которые трудно распознать как опечатку. Вот реализация метода getThreshold:

getThreshold(path: string): number { if (path.length < 5) return 3; return 5;
}

Затем, если у нас еще есть какая-либо возможная запись, мы сортируем словарь по расстоянию Левенштейна до входного значения. После этого мы возвращаем первое значение из отсортированного словаря. Исходный код метода sortByDistances:

sortByDistances(typoPath: string, dictionary: string[]) { const pathsDistance = {} as { [name: string]: number }; dictionary.sort((a, b) => { if (!(a in pathsDistance)) { pathsDistance[a] = this.levenshtein(a, typoPath); } if (!(b in pathsDistance)) { pathsDistance[b] = this.levenshtein(b, typoPath); } return pathsDistance[a] - pathsDistance[b]; });
}

Мы создали карту pathsDistance для хранения рассчитанных значений расстояний. Делая это, мы рассчитываем расстояние только один раз для каждого элемента в словаре. Затем мы использовали это отображение для сортировки значений. Основная магия хранится в методе levenshtein, который содержит реализацию алгоритма. Я взял его из исходного кода Angular CLI, так как он наиболее эффективен на языке TypeScript:

levenshtein(a: string, b: string): number { if (a.length == 0) { return b.length; } if (b.length == 0) { return a.length; } const matrix = []; // increment along the first column of each row for (let i = 0; i <= b.length; i++) { matrix[i] = [i]; } // increment each column in the first row for (let j = 0; j <= a.length; j++) { matrix[0][j] = j; } // Fill in the rest of the matrix for (let i = 1; i <= b.length; i++) { for (let j = 1; j <= a.length; j++) { if (b.charAt(i - 1) == a.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, // substitution matrix[i][j - 1] + 1, // insertion matrix[i - 1][j] + 1, // deletion ); } } } return matrix[b.length][a.length];
}

Мы создали матрицу размера N на M, где N — длина первой строки, а M — длина столбца. Затем мы перебираем матрицу и подсчитываем количество необходимых операций. После этого последней ячейкой в матрице будет расстояние Левенштейна между строками. И мы закончили.

Заключение

Не обязательно использовать какую-то магию или сложные концепции для решения реальных проблем, с которыми могут столкнуться пользователи. Применение нужных алгоритмов может решить такие проблемы элегантным и простым способом. В следующий раз, когда вы начнете думать о сложном решении, постарайтесь найти что-то уже существующее, что соответствует вашим потребностям.

Автор: Vitalii Bobrov

Источник: https://blog.angularindepth.com/

Редакция: Команда webformyself.