Обложка статьи «Роутинг в Vue»

Роутинг в Vue

Константин Базров

Константин Базров, ведущий разработчик 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");
    }
  },
});

Хинт для программистов: если зарегистрируетесь на соревнования Huawei Honor Cup, бесплатно получите доступ к онлайн-школе для участников. Можно прокачаться по разным навыкам и выиграть призы в самом соревновании.

Перейти к регистрации