Константин Базров
ведущий разработчик NGRSOFTLAB
С появлением веб-приложений пришла потребность в смене URL-адресов с помощью JS. На помощь пришел History API браузера.
Благодаря этому все основные современные фреймворки позволяют программно управлять маршрутизацией с синхронизацией URL-адреса с представлением приложения.
Для маршрутизации во Vue-приложениях можно создать свою собственную интеграцию с History API, но лучше использовать официальную библиотеку от Vue — Vue-Router.
Базовые вещи Использование можно начать хоть с установки с CDN:
<script src="https://unpkg.com/vue-router"></script>
Но мы начнем сразу с «правильного» варианта — с Vue Cli:
yarn global add @vue/cli
# ИЛИ
npm i -g @vue/cli
Создадим проект с помощью VUE CLI с базовым шаблоном — Default ([Vue 2] babel, eslint):
vue create vue-router-test-app
Минимальная конфигурация Добавим роутер:
yarn add vue-router
# или
npm i --save vue-router
Добавим в Main.js минимальную конфигурацию для роутера:
/src/main.js
import Vue from "vue";
import App from "@/App.vue";
import VueRouter from "vue-router";
import HelloWorld from "@/components/HelloWorld";
Vue.use(VueRouter);
const routes = [
{
path: "",
component: HelloWorld,
},
];
const router = new VueRouter({
routes,
mode: "history",
});
Vue.config.productionTip = false;
new Vue({
router,
render: (h) => h(App),
}).$mount("#app");
Роуты представляют собой массив, каждый элемент которого — объект, где требуется указать path
и component
.
Чтобы увидеть изменения надо вывести компонент роутера — routerView
, который отвечает за отображение. Для этого изменим App.vue:
/src/App.vue
<template>
<div id="app">
<router-view />
</div>
</template>
Теперь, зайдем на http://localhost:8080/. Увидим страницу с маршрутом «/», где отображается компонент HelloWorld.vue, вместо тега router-view
, который мы писали в App.vue.
Иерархия путей Добавим маршрут в main.js (массив routes
):
const routes = [
{
path: "",
component: HelloWorld,
},
{
path: "/board",
component: {
render: (h) => h("div", ["Board Page"]),
},
},
];
Зайдем по адресу http://localhost:8080/board. Увидим вторую страницу с отображением рендер-функции.
Параметры (Props) маршрута Поправим дочерний маршрут для маршрута /board в main.js. Для дочерних компонентов надо указывать где в родительском компоненте отображать дочерние — компонентом router-view
. В нашем случае — это в рендер-функция:
import Board from "@/components/Board";
const routes = [
{
path: "",
component: HelloWorld,
},
{
path: "/board",
component: {
render: (h) => h("div", ["Board Page", h("router-view")]),
},
children: [
{
path: '/board/:id',
component: Board,
}
]
},
];
Напомню, что рендер-функция в template-представлении будет выглядеть следующим образом:
<template>
<div>
Board Page
<router-view />
</div>
</template>
Создадим компонент Board.vue с содержимым:
/src/components/Board.vue
<template>
<div>Board with prop id: {{ id }}</div>
</template>
<script>
export default {
computed: {
id() {
return this.$route.params.id;
},
},
};
</script>
Перейдем по адресу http://localhost:8080/board/21 и увидим родительский и дочерний компоненты Board
с передачей параметра id
равным 21.
Параметры маршрута доступны в компоненте по this.$route.params
.
Если хотим более явно отобразить зависимость компонента от входных параметров, используем настройку props: true
при настройке маршрута:
/src/main.js
children: [
{
path: '/board/:id',
component: Board,
props: true,
}
]
А в компоненте Board.vue принять id
как входной параметр компонента:
/src/components/Board.vue
<template>
<div>Board with prop id: {{ id }}</div>
</template>
<script>
export default {
props: {
id: {
type: String,
default: null,
},
},
};
</script>
Метаданные (meta) маршрута /src/main.js
const routes = [
{
path: "",
component: HelloWorld,
meta: {
dataInMeta: "test",
},
},
....
]
Теперь мы можем обратиться к метаданным роута из компонента HelloWorld.vue следующим образом:
this.$route.meta.dataInMeta
.
Глубже (nested children) В дочерние компоненты можно углубляться до бесконечности (до ограничений сервера).
Сделаем дочерний роут для дочернего роута.
/src/main.js
const routes = [
{
path: "",
component: HelloWorld,
},
{
path: "/board",
component: {
render: (h) => h("div", ["Board Page", h("router-view")]),
},
children: [
{
path: "/board/:id",
component: Board,
props: true,
children: [
{
path: "child",
component: {
render: function(h) {
return h("div", ["I'm Child with prop", this.propToChild]);
},
props: {
propToChild: {
type: Number,
required: true,
default: null,
},
},
},
},
],
},
],
},
];
Рендер-функция теперь записана обычной функцией, т.к. нужен контекст компонента.
/src/components/Board.vue
<template>
<div>
Board with prop id: {{ id }}
<router-view :prop-to-child="parseInt(id)" />
</div>
</template>
<script>
export default {
props: {
id: {
type: String,
default: null,
},
},
};
</script>
Передаем дочернему компоненту дочернего компонента параметры через компонент router-view как обычному компоненту. Звучит сложно, но интуитивно понятно. И так, спускаем пропсы в дочернем — дочернему дочернего:
<router-view :prop-to-child="parseInt(id)" />
Пояснение за Path Запись вида path: "child"
означает, что мы обращаемся к пути родителя и продолжаем его путь: {parent-route}/child
Из дочернего компонента можно сослаться на любой другой уровень роута:
/src/main.js (routes):
children: [
{
path: "/first-level",
....
}
]
Эта запись обрабатывает страницу с адресом: http://localhost:8080/first-level.
Шире (несколько router-view) Можно использовать несколько router-view
в 1 компоненте. Для этого в конфигурации маршрутов (routes) пишем вместо component — components, который принимает объект, где ключ — атрибут name
у router-view
. Если указать ключ «default», то такой компонент будет отображаться, если router-view
безымянный (без атрибута name
).
/src/main.js
const routes = [
{
path: "",
component: HelloWorld,
},
{
path: "/board",
component: {
render: (h) => h("div", ["Board Page", h("router-view")]),
},
children: [
{
path: "/board/:id",
component: Board,
props: true,
children: [
{
path: "child",
components: {
default: { render: (h) => h("div", ["I'm Default"]) },
user: { render: (h) => h("div", ["I'm User"]) },
guest: { render: (h) => h("div", ["I'm Guest"]) },
},
},
],
},
],
},
];
/components/Board.vue
<template>
<div>
Board with prop id: {{ id }}
<div>
<label for="is-user">
Is User?
<input v-model="isUser" id="is-user" type="checkbox" />
</label>
<router-view :prop-to-child="parseInt(id)" />
<router-view v-if="isUser" name="user" />
<router-view v-else name="guest" />
</div>
</div>
</template>
<script>
export default {
props: {
id: {
type: String,
default: null,
},
},
data() {
return {
isUser: false,
};
},
};
</script>
Перейдем по адресу: http://localhost:8080/board/23/child и увидим небольшой интерактив с переключением активных router-view.
Страница ошибки 404 Чтобы создать страницу ошибки, достаточно положить в конец списка маршрутов такую конструкцию:
/src/main.js(routes)
{
path: "*",
component: { render: (h) => h("div", ["404! Page Not Found!"]) },
},
Теперь, при переходе по несуществующему пути (например — http://localhost:8080/mistake), будет выведен компонент ошибки.
Лучше писать в таком виде:
/src/main.js
{
path: "/page-not-found",
alias: '*',
component: { render: (h) => h("div", ["404! Page Not Found!"]) },
},
Теперь у нас есть страница с ошибкой, куда мы можем со спокойной совестью переадресовывать пользователей (вдруг когда-нибудь понадобится это делать).
Защита маршрутов Защиту маршрутов осуществляют с использованием метаданных маршрутов и хука beforeEach
роутера.
/src/main.js
import Vue from "vue";
import App from "@/App.vue";
import VueRouter from "vue-router";
import HelloWorld from "@/components/HelloWorld";
import Board from "@/components/Board";
Vue.use(VueRouter);
const routes = [
{
path: "",
component: HelloWorld,
},
{
path: "/board",
component: {
render: (h) => h("div", ["Board Page", h("router-view")]),
},
meta: {
requiresAuth: true,
},
children: [
{
path: "/board/:id",
component: Board,
props: true,
children: [
{
path: "child",
components: {
default: { render: (h) => h("div", ["I'm Default"]) },
user: { render: (h) => h("div", ["I'm User"]) },
guest: { render: (h) => h("div", ["I'm Guest"]) },
},
},
],
},
],
},
{
path: "/auth-required",
component: { render: (h) => h("div", ["Auth required!"]) },
},
{
path: "/*",
component: { render: (h) => h("div", ["404! Page Not Found!"]) },
},
];
const router = new VueRouter({
routes,
mode: "history",
});
const isAuthenticated = () => false;
router.beforeEach((to, from, next) => {
if (to.matched.some((route) => route.meta?.requiresAuth)) {
if (isAuthenticated()) {
next();
} else {
next("/auth-required");
}
} else {
next();
}
});
Vue.config.productionTip = false;
new Vue({
router,
render: (h) => h(App),
}).$mount("#app");
Теперь, при попытке получить доступ к странице, которая требует авторизации, нас перебросит на страницу /auth-required.
Навигация между маршрутами Программная навигация Программная навигация может вызываться из любого места вашего приложения таким образом:
$router.push('/dash/23/child')
Если мы хотим передать параметры, нам нужно использовать другой подход, основанный на использовании имен роутов.
Укажем имя роуту /board/:id
:
...
children: [
{
path: "/board/:id",
name: 'board',
component: Board,
props: true,
children: [
....
Теперь мы можем передавать параметры:
$router.push({ name: 'board', params: { id: 100500 }})
Получим ошибку «Invalid prop: type check failed for prop “id”. Expected String with value “100500”, got Number with value 100500».
Причина в том, что url
— это всегда тип данных String
, а мы передали программно id
с типом Number
. Исправляется это просто: перечислим возможные типы данных в компоненте.
components/Board.vue
props: {
id: {
type: [String, Number],
default: null,
},
},
Компонент routerLink Компонент routerLink
позволяет создавать ссылки внутри сайта, которые преобразуются в «нативные» браузерные ссылки (тег <а>
):
<router-link to='/dash/23/child'> Link </router-link>
К таким ссылкам автоматически могут добавляться классы:
router-link-exact-active
— точное совпадение;router-link-active
— частичное (активен дочерний компонент указанного в атрибуте to
роута).Чтобы не отображать активный класс родительских, достаточно написать атрибут exact
:
<router-link to='/dash/23/child' exact> Link </router-link>
Мы можем переопределить создаваемый элемент:
<router-link tag="button" to='/dash'> Button </router-link>
К сожалению, в таком случае, классы не проставляются.
Также можем передавать объект:
<router-link :to="{ path: '/dash/23' "> Link </router-link>
<router-link :to="{ name: 'board', params: { id: 123 } }"> Link </router-link>
Лучшие практики Этот раздел мы посвятим рефакторингу того, что мы написали выше.
Создаем структуру папок для роутера:
src/router/router.js
src/router/routes.js
Перенесем в router.js все, что касается настроек роутера:
import Vue from "vue";
import VueRouter from "vue-router";
import routes from "/routes";
Vue.use(VueRouter);
const router = new VueRouter({
routes,
mode: "history",
base: process.env.BASE_URL,
});
const isAuthenticated = () => true;
router.beforeEach((to, from, next) => {
if (to.matched.some((route) => route.meta?.requiresAuth)) {
if (isAuthenticated()) {
next();
} else {
next("/auth-required");
}
} else {
next();
}
});
export default router;
Перенесем routes.js все, что касается настроек маршрутов.
И сразу заменим импорты на динамические.
Если у Вас уже прописано много роутов, ручное изменение может потребовать много времени. Поможет регулярка:
^import (\w+) from (".+")$
заменить на
const $1 = () => import(/* webpackChunkName: "$1" */ $2)
Теперь в Chrome Dev Tools во вкладке Network будет видно когда-какой компонент грузится из сети, а раньше все роуты загружались сразу в 1 мега-бандле.
src/router/routes.js
const HelloWorld = () => import(/* webpackChunkName: "HelloWorld" */ "@/components/HelloWorld")
const Board = () => import(/* webpackChunkName: "Board" */ "@/components/Board")
const routes = [
{
path: "",
component: HelloWorld,
},
{
path: "/board",
component: {
render: (h) => h("div", ["Board Page", h("router-view")]),
},
meta: {
requiresAuth: true,
},
children: [
{
path: "/board/:id",
name: "board",
component: Board,
props: true,
children: [
{
path: "child",
components: {
default: { render: (h) => h("div", ["I'm Default"]) },
user: { render: (h) => h("div", ["I'm User"]) },
guest: { render: (h) => h("div", ["I'm Guest"]) },
},
},
],
},
],
},
{
path: "/auth-required",
component: { render: (h) => h("div", ["Auth required!"]) },
},
{
path: "/*",
component: { render: (h) => h("div", ["404! Page Not Found!"]) },
},
];
export default routes;
Продвинутые приемы Под «продвинутостью» подразумевается «приятность» их использования. К таким приемам можно отнести, например, такие темы как:
разбиение прав по уровням доступа; анимацию переходов между страницами; индикацию загрузки при переходе между роутами; изменение тайтлов при переходе между роутами; плавный скролл по странице при переходе назад; и т.п. Итак, обо всем по-порядку.
Разбиение прав по уровням доступа Бывает ситуация, когда у пользователей бывает более двух состояний: не только авторизация, но и другие. Например, платная подписка. С этих пор мы задумываемся про неограниченный уровень разделения прав. Делается это буквально парой десятков строчек кода, но для краткости, удобства и чтобы не изобретать велосипед, мы будем использовать готовую библиотеку. Установим ее:
yarn add vue-router-middleware-plugin
Создадим специальные файлы middleware для проверки прав пользователей:
router/middleware/authMiddleware.js
const isLoggedIn = () => !!window.localStorage.getItem("logged-in")
const authMiddleware = async ({ /* to, from to,*/ redirect }) => {
if (!isLoggedIn()) {
redirect({
name: "login",
});
}
};
export default authMiddleware;
router/middleware/guestMiddleware.js
const isLoggedIn = () => !!window.localStorage.getItem("logged-in");
const guestMiddleware = async ({ /* to, from to,*/ redirect }) => {
if (isLoggedIn()) {
redirect({ name: "main" });
}
};
export default guestMiddleware;
router/middleware/subscribersMiddleware.js
const isSubscribed = () => Promise.resolve(!!window.localStorage.getItem("has-license"))
const subscribersMiddleware = async ({ /* to, from, */ redirect }) => {
if (!await isSubscribed()) {
console.log("isn't subscribed, redirect to license")
redirect({ name: 'license' })
}
}
export default subscribersMiddleware
В последнем листинге, приведен пример асинхронной проверки, что значит — можно обращаться в actions стора и делать запросы на сервер.
Теперь поставим проверку на авторизацию на все роуты, а затем сделаем исключения для некоторых роутов:
/src/router/router.js
import Vue from "vue";
import VueRouter from "vue-router";
import routes from "./routes";
import MiddlewarePlugin from "vue-router-middleware-plugin";
import authMiddleware from "./middleware/authMiddleware";
Vue.use(VueRouter);
const router = new VueRouter({
routes,
mode: "history",
base: process.env.BASE_URL,
});
Vue.use(MiddlewarePlugin, {
router,
middleware: [authMiddleware],
});
export default router;
Теперь разберемся с конкретными маршрутами.
Поработаем над архитектурой нашего приложения, чтобы сделать его более предсказуемым. Сделаем отдельный шаблон Auth.vue и положим его в pages, а компоненты, которые там используются, т.е. в разделе /auth, положим в соответствующий раздел components.
Т.о. получается удобная структура:
pages
--Auth.vue
components
-- auth
---- Login.vue
---- Register.vue
---- Forgot.vue
Создадим вспомогательную функцию для генерации подобных роутов genAuthRoutes
.
/src/router/routes.js
import guestMiddleware from "./middleware/guestMiddleware";
import authMiddleware from "./middleware/authMiddleware";
import subscribersMiddleware from "./middleware/subscribersMiddleware";
const MainBoard = () =>
import(/* webpackChunkName: "MainBoard" */ "@/pages/MainBoard");
const BoardComponent = () =>
import(
/* webpackChunkName: "BoardComponent" */ "@/components/board/BoardComponent"
);
const clearAndUpper = (text) => text.replace(/-/, "").toUpperCase();
const toPascalCase = (text) => text.replace(/(^\w|-\w)/g, clearAndUpper);
const genAuthRoutes = ({ parent, tabs = [] }) => ({
path: `/${parent}`,
name: parent,
component: () => import(/* webpackChunkName: "auth" */ "@/pages/Auth"),
redirect: { name: tabs[0] },
children: tabs.map((tab) => {
const tabPascalCase = toPascalCase(tab);
return {
path: tab,
name: tab,
component: () =>
import(
/* webpackChunkName: "[request]" */ `@/components/${parent}/${tabPascalCase}`
),
meta: {
middleware: {
ignore: [authMiddleware],
attach: [guestMiddleware],
},
},
};
}),
});
const routes = [
genAuthRoutes({ parent: "auth", tabs: ["login", "register", "forgot"] }),
{
path: "/",
name: "main",
component: MainBoard,
children: [
{
path: "/board",
name: "board",
component: {
render: (h) => h("div", ["Board Page", h("router-view")]),
},
children: [
{
path: "/board/:id",
name: "board-child",
component: BoardComponent,
props: true,
children: [
{
path: "child",
components: {
default: { render: (h) => h("div", ["I'm Default"]) },
user: { render: (h) => h("div", ["I'm User"]) },
guest: { render: (h) => h("div", ["I'm Guest"]) },
},
meta: {
middleware: {
attach: [subscribersMiddleware],
},
},
},
],
},
],
},
{
path: "/license",
name: "license",
component: {
render: (h) => h("div", ["License Page"]),
},
},
],
},
{
path: "/auth-required",
name: "auth-required",
component: { render: (h) => h("div", ["Auth required!"]) },
meta: {
middleware: {
ignore: [authMiddleware],
},
},
},
{
path: "/*",
component: { render: (h) => h("div", ["404! Page Not Found!"]) },
meta: {
middleware: {
ignore: [authMiddleware],
},
},
},
];
export default routes;
Удаляем глобальную проверку на авторизацию в свойстве ignore
и добавляем другую проверку в свойстве attach
объекта meta.middleware
:
middleware: {
ignore: [authMiddleware],
attach: [guestMiddleware],
}
Создадим компоненты
src/components/auth/Login.vue; src/components/auth/Register.vue; src/components/auth/Forgot.vue, с типовым шаблоном:
<template>
<div>
Forgot Page
</div>
</template>
Также отрефакторим страницу Board
, назовем его MainBoard
/src/pages/MainBoard.vue
<template>
<div>
<h1>Main Board Page</h1>
<router-view />
</div>
</template>
Соответственно, добавляем компоненты в соответствующую категорию в components:
/src/components/board/BoardComponent.vue
<template>
<div>
Board with prop id: {{ id }}
<div>
<label for="is-user">
Is User?
<input v-model="isUser" id="is-user" type="checkbox" />
</label>
<router-view :prop-to-child="parseInt(id)" />
<router-view v-if="isUser" name="user" />
<router-view v-else name="guest" />
</div>
</div>
</template>
<script>
export default {
props: {
id: {
type: [String, Number],
default: null,
},
},
data() {
return {
isUser: false,
};
},
};
</script>
Осталось отрефакторить главный компонент — App.vue:
/src/App.vue
<template>
<div id="app">
<div class="links">
<router-link :to="{ name: 'register' }">Register</router-link>
<router-link :to="{ name: 'login' }">Login</router-link>
<router-link :to="{ name: 'forgot' }">Forgot</router-link>
<template v-if="loggedIn">
<router-link :to="{ name: 'license' }">License</router-link>
<router-link :to="{ name: 'board' }">Board</router-link>
<router-link :to="{ name: 'board-child', params: { id: 33 } }"
>Board:33</router-link
>
<router-link :to="{ path: '/board/33/child' }"
>Board:33/child</router-link
>
<router-link :to="{ path: '/404' }">404</router-link>
</template>
<label for="logged-in"
>Logged In
<input type="checkbox" id="logged-in" v-model="loggedIn" />
</label>
<label for="has-license"
>Has License
<input type="checkbox" id="has-license" v-model="hasLicense" />
</label>
</div>
<router-view />
</div>
</template>
<script>
export default {
data() {
return {
loggedIn: !!window.localStorage.getItem("logged-in"),
hasLicense: !!window.localStorage.getItem("has-license"),
};
},
watch: {
loggedIn(e) {
window.localStorage.setItem("logged-in", e ? true : "");
},
hasLicense(e) {
window.localStorage.setItem("has-license", e ? true : "");
},
},
};
</script>
<style scoped>
.links > * {
margin: 1em;
}
</style>
Теперь, снимем отметку с «Logged In» и попробуем перейти по маршруту http://localhost:8080/board. Нас незамедлительно переадресует на страницу «auth-required».
Поставим отметку на «Logged In», снимем с «Has License» и перейдем по маршруту http://localhost:8080/board/33/child. Нас перенесет на страницу license, однако, если снять отметку с «Logged In» и обновить страницу, то мы снова перейдем на страницу «auth-required».
Теперь проверим, можно ли зайти на страницу авторизации, когда пользователь уже прошел авторизацию. Поставим отметку «Logged In» и перейдем по адресу http://localhost:8080/auth/register. Нас перебросит на главную страницу.
Анимация переходов между страницами Это просто. Оборачиваем главный RouterView
компонентом анимации transition
и добавляем стили:
src/App.vue
<template>
....
<transition name="fade">
<router-view />
</transition>
...
</template>
<style scoped>
...
.fade-enter-active,
.fade-leave-active {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter,
.fade-leave-active {
opacity: 0;
}
<style>
Индикация загрузки при переходе между роутами Это тоже просто. Ставим библиотеку nprogress:
yarn add nprogress
Добавляем в router.js:
/src/router/router.js
import NProgress from 'nprogress'
import 'nprogress/nprogress.css';
router.beforeResolve((to, from, next) => {
if (to.name) {
// Запустить отображение загрузки
NProgress.start()
}
next()
})
router.afterEach(() => {
// Завершить отображение загрузки
NProgress.done()
})
Изменение тайтлов при переходе между роутами И это тоже просто.
Заполняем meta.title
маршрутам и ставим document.title
каждой странице в хуке beforeEach
:
/src/router/router.js
...
router.beforeEach(async (to, from, next) => {
const { title } = to.meta;
const brand = "NGRSoftlab";
document.title = `${title ? title + " | " : ""}${brand}`;
next();
});
...
Плавный скролл по странице при переходе вперед/назад Когда жмешь по браузерным «системным» кнопкам назад или вперед, браузер запоминает положение прокрутки и возвращает. Такое поведение мы можем повторить.
/src/router/router.js
import VueScrollTo from "vue-scrollto";
const router = new VueRouter({
routes,
mode: "history",
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
VueScrollTo.scrollTo("#app", 500, { offset: savedPosition.y });
return savedPosition;
} else {
VueScrollTo.scrollTo("#app");
}
},
});