Skip to content

Latest commit

 

History

History
856 lines (630 loc) · 24.1 KB

course-vue-router-4-for-everyone.md

File metadata and controls

856 lines (630 loc) · 24.1 KB

Vue Router 4 for Everyone

Isntall vue-router

Use the following command:

npm install vue-router@4 --save

Adding routes

It is done in the main.js or main.ts file.

The routes can be imported from an external file outside of main.js.

In JavaScript, a route is defined like this:

  routes: [
    //routes go here
    {
        //the path is the url when you navigate in the browser
        path: '/',
        //the name of the route, which is a more handy way to use <router-link> component
        name: 'Home',
        //the component to load for the route
        component: HomeVue
    },

However, in TypeScript, you need to do it slightly differently:

  1. You create a route variable:

    const UserLoginRoute: RouteRecordRaw = {
      path: RoutePath.UserLogin,
      name: RouteName.UserLogin,
      component: () => import("@/pages/UserLogin.vue"),
    };
  2. You create the RouterOptions where you initialze the routes array and add the route variable:

    const routerOptions: RouterOptions = {
      history: createWebHistory(import.meta.env.BASE_URL),
      routes: [UserLoginRoute],
    };
  3. You create the router:

    const router: Router = createRouter(routerOptions);

Using router-view to render a page

To render the view for a route, you need to include in the App.vue the component router-view.

<template>
  <RouterView></RouterView>
</template>

or

<template>
  <router-view></router-view>
</template>

It is recommended to include the :key prop to make sure that the components are rendered on route change.

Using router-link to render routed links

If you want to build a navigation, using the router-link component enables to create adequate links:

<template>
  <router-link to="/">Home</router-link> |
  <router-link to="/about">About</router-link>
  <router-view></router-view>
</template>

Using router-link instead of a elements makes possible to load just what we need instead of everything on each pageload.

If you open the DevTools, the router-link is rendered as a element in the DOM.

But vue-router intercepts the click events so the browser doesn't reload the page.

Therefore, we'll use a elements for external links and router-link for internal links.

Extracting routes to its own file

This is commun practice. You create a router directory under src where you add index.js with the setup of the router.

import { createRouter, createWebHistory } from "vue-router";

import HomeVue from "../views/Home.vue";
import AboutVue from "../views/About.vue";

const routes = [
  //routes go here
  { path: "/", name: "Home", component: HomeVue },
  { path: "/about", name: "About", component: AboutVue },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;

History Mode: HTML5 vs Hash

What is the difference?

The # mode is there to imitate a file path without the server setup. It is used in SPAs.

Without the #, it uses the HTML5 native web history feature.

The drawback of # mode has a bad impact on SEO, so prefer not using it for public applications (but SPAs are not good for SEO either, BTW. See Nuxt framework).

So in Vue applications, use createWebHistory, which is the HTML5 mode.

Lazy Loading routes

Without lazy loading, all the JavaScript modules of all routes will be loaded. It is not efficient.

To lazy load routes, you need to use code splitting of the bundles generated by the builder ; in my case, I use Vite.

Without lazy loading, you define a route this way:

import BrazilVue from '@/views/Brazil.vue';
var routes = [
  {
    path: '/brazil',
    name: 'brazil',
    component: BrazilVue'),
  }
]

The result is in the dist folder, you have a JavaScript file (index-[hash].js).

With lazy loading, you define a route with a dynamic import:

var routes = [
  {
    path: "/brazil",
    name: "brazil",
    component: () => import("@/views/Brazil.vue"),
  },
];

The result is in the dist folder:

  • the main JavaScript file (index-[hash].js)
  • a JavaScript file for each component of a given route.

With Vuecli, it uses a similar technique but using a Webpack comment to name the chunk with magic comment:

var routes = [
  {
    path: "/brazil",
    name: "brazil",
    component: () =>
      import(/* webpackChunkName: "brazil" */ "@/views/Brazil.vue"),
  },
];

Custimizing the router link active class

It is easy to do that with the linkActiveClass property in the createRouter options.

Dynamic Routes

You can add parameters to a route using :myparam. This parameter is accessible in the route object, under params object.

The params object contains properties whose names are the name of the parameters specified in the route's path.

As for the naming of the parameters, use camelCase, PascalCase or snake_case.

kebab-case will not work.

So:

  • myparam will be available as route.params.myparam,
  • myParam will be available as route.params.myParam,
  • my_param will be available as route.params.my_param,
  • my-param will NOT be available in route.params.

The best practice: I'd use Douglas Crockford's suggestions that say (in bold what I think applies to naming route's params):

Names should be formed from the 26 upper and lower case letters (A .. Z, a .. z), the 10 digits (0 .. 9), and _ (underbar). Avoid use of international characters because they may not read well or be understood everywhere. Do not use $ (dollar sign) or \ (backslash) in names.

Do not use _ (underbar) as the first character of a name. It is sometimes used to indicate privacy, but it does not actually provide privacy. If privacy is important, use the forms that provide private members. Avoid conventions that demonstrate a lack of competence.

Most variables and functions should start with a lower case letter.

Conclusion: I'll use camelCase only.

Here for some discussion on the topic:

Named routes

It is recommended to give all routes a name to use it in the code.

That will allow to change the path easily.

If you use router-link component with a route name that doesn't exist, you will get console warning:

TheNavigation.vue:4 [Vue warn]: Property "doesntexist" was accessed during render but is not defined on instance.
  at <TheNavigation>
  at <App>

Interesting fact: if the route name equals undefined though, you will get no warning. But the a element will have href equal to the currently loaded page.

I wonder what is the best practice to use named routes within a component to avoid typos.

Personally, in TypeScript, I use a RouteName enum that I use not only in the router index but also in the various places where I need to create a router link.

That technique allow intellisence and guarantee that you never mistype a route name.

Here is an example:

export enum RouteName {
  //Public pages
  TheHome = "TheHome",
  UserShow = "UserShow",
  CategoryShow = "CategoryShow",
  ForumShow = "ForumShow",
  ThreadShow = "ThreadShow",
  ThreadCreate = "ThreadCreate",
  ThreadEdit = "ThreadEdit",
  UserRegister = "UserRegister",
  UserLogin = "UserLogin",
  UserLogout = "UserLogout",
  //Behind auth pages
  AccountEdit = "AccountEdit",
  AccountShow = "AccountShow",
  //Commun page
  NotAuthorized = "NotAuthorized",
  NotFound = "NotFound",
}

I do the same of the route paths BTW.

Reacting to Param Changes

We have two options:

  1. use watch to listen on the router.params changes and fetch the new data.

  2. ask Vue to recreate the component using the key attribut on the router-view component. The value is $route.path, but be aware of the query string... You might need something like this:

    <router-view :key="`${$route.path}${JSON.stringify($route.query)}`">
    </router-view>

The second approach takes more JavaScript computation time.

But it is the solution to use most of the time as you use the same component for different data.

Usecase of Route Props

To build loosely-built component, passing props to routes make the code easier to maintain.

For example, a route would be defined at follows:

const routes = [
  {
    path: "/destination-details/:id/:slug",
    name: routesNames.destinationShow,
    component: () => import("@/views/DestinationShow.vue"),
    props: (route) => ({ slug: route.params.slug }),
  },
];

It would be used the following way in the component:

<script setup>
  import useSourceData from "@/composables/useSourceData";

  const props = defineProps({
    slug: {
      type: String,
      required: true,
    },
  });
  const { destination } = useSourceData(props.slug);
</script>

This way, the component can receive the props from any source: route, form input, etc...

Nested routes

When you have an application with components nested multiple levels deep, it is very commun that a certain route segment corresponds to the matching component.

For example, a DestinationDetails component containing a list of ExperienceDetails component will be configured as follows:

  • the destination route could be /destination-details/:id/:slug
  • the experience route for a destination would be :experienceSlug and would be nested as a child route.
const routes = [
  { path: "/", name: "home", component: HomeVue },
  {
    //parent route
    path: "/destination-details/:id/:slug",
    name: routesNames.destinationShow,
    component: () => import("@/views/DestinationShow.vue"),
    props: (route) => ({ id: parseInt(route.params.id) }),
    children: [
      {
        //child route with only the extra segment needed.
        path: ":experienceSlug",
        name: "experience-details",
        component: () => import("@/views/ExperienceShow.vue"),
        props: (route) => ({
          id: parseInt(route.params.id),
          experienceSlug: route.params.experienceSlug,
        }),
      },
    ],
  },
];

Going back

The functionnality is available out-of-the-box with the instance of $router. It defines back() that acts exactly like the back button of a browser.

Route transitions in SPA applications

The component <transition>...</transition> is available in Vue2 by wrapping the <router-view>...</router-view> component.

<transition>
  <router-view>...</router-view>
</transition>

In Vue3, the syntax was changed: router-view wraps the transition by using the router view slot.

Then, the transition component contains an anonymous component.

<router-link v-slot="{ Component }">
  <transition name="slide">
    <component :is="Component" :key="$route.path"></component>
  </transition>
</router-link>

A key attribut equal to $route.path is mandatory on the anonymous component to insure the transition occurs properly and that the route changes continue to destroy and recreate the components to load.

IMPORTANT: for the transition component to apply the proper CSS classes on each page, all pages must have a single root element.

Otherwise, you will get a blank page and this warning in the console:

[Vue warn]: Component inside <Transition> renders non-element root node that cannot be animated.

What about routes that don't exist

You simply declare a route catch all using the Vue 3 syntax (more flexible and performant):

const routes = [
  {
    path: "/:pathMatch(.*)*",
    name: "notfound",
    component: NotFoundVue,
  },
];

In Vue 2, it was simplier to understand but gave us less flexibility:

const routes = [
  {
    path: "*",
    name: "notfound",
    component: NotFoundVue,
  },
];

Navigation Guards

They allow to hook into the navigation process and perform checks (before we navigate to the requested route or before we navigate away from the current route) and act accordingly: continue or doing something else.

For example, we define a route:

const routes = [
  //routes go here
  { path: "/", name: "home", component: HomeVue },
  {
    path: "/destination-details/:id/:slug",
    name: routesNames.destinationShow,
    component: () => import("@/views/DestinationShow.vue"),
  },
];

The URL /destination-details/1/france could serve data, but maybe /destination-details/100/france won't. If the data doesn't exist, it won't be caught by the catch all route.

That's when navigation guards are useful.

We define it on the appropriate event. In the example, before we enter the next route:

    beforeEnter(to, from, next) {
      //to is the destination route
      //from is the origin route
      //so we want to check the data contains the element matching route.params.id
      const exists = sourceData.destinations.find(
        (element) => element.id === parseInt(to.params.id),
      );

      if (!exists) return next({ name: 'notfound' });

      //otherwise we continue
      next();
    },

While the above guard is an example of a guard in the route definition, also called middleware, you use the guards in the components.

Usually, you use navigation guards defined in the route definition when it is auth-related and if you are making sure the requested data is available.

In some case, you may need to componentless route with a guard that runs some store action: a sign out route is one of those usecases.

const UserLogoutRoute: RouteRecordRaw = {
  path: "/logout",
  name: RouteName.UserLogout,
  redirect: "",
  beforeEnter: async (_to, _from, next) => {
    await logoutUser();
    updateFetching();
    next({
      name: RouteName.TheHome,
    });
  },
};

If you want to keep the URL intact even if it is wrong, you will need to add the following to return statement:

return {
  name: "notfound",
  //allows keeping the URL intact while rendering a different page
  params: { pathMatch: to.path.split("/").slice(1) },
  query: to.query,
  hash: to.hash,
};

The other two guards are beforeLeave and beforeUpdate.

The common usecase for beforeLeave is to catch the scenario when the user fills a form and click away. You can show the user a Wanna save? modal thanks to this guard.

Handle scroll

You may notice that when you change route and if you scroll down, the scroll position is saved.

To use the native behavior of non-SPA application, you will need to implement scrollBehavior guard:

const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    //this restore the top position with 300 ms dely to avoid a visual bug since we have a transition active.
    return (
      savedPosition ||
      new Promise((resolve) => {
        setTimeout(() => resolve({ top: 0, behavior: "smooth" }), 300);
      })
    );
  },
});

The above applies to all routes. But what if you want to apply it conditionally?

Using meta on a given route, you can execute the scroll to top only if the route has some meta data set.

For example:

  1. You define the route:
{
  path: "/account/edit",
  name: "UserConnectedEdit",
  component: () => import("@/pages/UserShow.vue"),
  meta: { toTop: true, smoothScroll: true },
}
  1. You define the behavior in the createRouter options:

    scrollBehavior(to) {
        //this restore the top position with 300 ms dely to avoid a visual bug since we have a transition active.
        const scroll = {};
        if (to.meta.toTop) scroll.top = 0;
        if (to.meta.smoothScroll) scroll.behavior = "smooth";
    
        return new Promise((resolve) => {
          setTimeout(() => resolve(scroll), 500);
        });
      },

Route Meta fields

They are abitrary information passed along the route.

A common use case for route meta fields is to mark some routes as protected.

Using a application-wide guard or global route guard, in the router definition, we can deal with the authentication requirement as follows, in a global route guard:

router.beforeEach((to, from) => {
  if (to.meta.requiresAuth && !user) {
    // Load a login page
    return {
      name: "login",
    };
  }

  //Else continue
});

Redirects

Using the push method on the router instance on the login page, for example, we can redirect the user to the protected area and log him out:

  • for the login:

    //script to handle the login (FAKE)
    import { ref } from "vue";
    import { useRouter } from "vue-router";
    
    const router = useRouter();
    
    const username = ref("");
    const password = ref("");
    
    const loginUser = () => {
      window.userLogged = `{user: ${username.value}, password: ${password.value}}`;
      router.push({ name: "protected" });
    };
  • for the the logout:

    //script of the protected area page
    import { useRouter } from "vue-router";
    const router = useRouter();
    
    //data of user to display in the view
    const username = window.user;
    
    const logoutUser = () => {
      window.user = undefined;
      router.push({ name: "home" });
    };

NB: meta fields are passed on to the children in the case of nested routes.

Router Query Params

To read query parameters, it is as simple as reading the query object in the route.

An example usecase would be to include the path to a destination page after authentication.

For example, I have an invoices page that requires authentication. If I navigate to it directly, I'd want to return to the invoices page after I sgin-in.

To do so, we need to modify the global route guard:

router.beforeEach((to, from) => {
  if (to.meta.requiresAuth && !window.userLogged) {
    // Load a login page
    return {
      name: "login",
      //add the query string "redirect" with the requested path
      query: { redirect: to.fullPath },
    };
  }

  //else we continue
});

Extending Router Link for External URLs

Again, to navigate to external links, we don't use router-link but a regular a element.

However, it is a good idea to use a single application-web component for both router-link and regular a element.

We could then create an AppLink.vue (to follow the style guidelines).

It would look like this:

<template>
  <a
    v-if="isExternal"
    :href="to"
    target="_blank"
    rel="noopener"
    class="external-link"
    >
    <slot></slot>
  </a>
  <router-link v-else v-bind="$props" class="internal-link"
    ><slot></slot
  ></router-link>
</template>
<script setup>
  import { computed, guardReactiveProps } from 'vue';
  import { RouterLink } from 'vue-router';

  const props = defineProps({
      ...RouterLink.props
  });

  const isExternal = computed(() => {
    return typeof props.to === "string" && props.to.startsWith('http');
  });
</script>

And its usage would be:

    <AppLink :to="{name: 'dashboard'}">Dashboard</AppLink>
    <AppLink to="https://google.com">Google</AppLink>

Router.push vs Router.replace

The method push will add a new entry in the history stack, while replace won't.

replace effectively replaces the entry or remove it from the history stack.

Named views

The usecase is customization of layouts per route.

For example, the following defines a LeftSideBar component used in the Dashboard route of the routes definition array:

  {
    path: '/dashboard',
    name: 'dashboard',
    components: {
      default: () => import('@/views/Dashboard.vue'),
      LeftSideBar: () => import('@/components/LeftSideBar.vue'),
    },
    meta: {
      requiresAuth: true,
    },
  },

You can then add the router-view as follow in App.vue to make available in the application:

<router-view class="view left-sidebar" name="LefSideBar"></router-view>

The name attribut is important: the value must match the key in the components object of the route using the component.

Redirects and aliases

Redirects allow to catch a route and redirect it another:

  { path: '/', name: 'home', component: HomeVue },
  { path: '/home', redirect: { name: 'home' } },

The URL changes in a redirect.

Aliases load the target view, but the URL doesn't change.

  { path: '/', name: 'home', component: HomeVue, alias: '/home' },

Detecting Navigation Failures

Why would it happen?

  1. Users are already on the page that they are trying to navigate to.
  2. A guard aborts the navigation by returning false
  3. A new navigation guard takes place before the previous one has finished
  4. A navigation guard redirects somewhere else by returning a new location
  5. A navigation guard throw an Error.

To trigger the cases, see the following case:

  import { NavigationFailureType, isNavigationFailure, useRouter } from "vue-router";
  const router = useRouter();
  const triggerRouterError = async () => {
    //let navigate to the home page while on the home page...
    const navigationResult = await router.push('/');
    console.log(navigationResult);
    if (isNavigationFailure(navigationResult, NavigationFailureType.duplicated)) {
      //the route is the current and cannot be navigated to
    }
    else if (isNavigationFailure(navigationResult, NavigationFailureType.aborted)) {
      //false is returned in a navigation guard
    }
    else if (isNavigationFailure(navigationResult, NavigationFailureType.cancelled)) {
      //a new navigation took place before the current could finish.
    }
    else {
      //all was fine.
    }

navigationResult contains the to and from objects and the typical route properties.

Advanced Routes’ Matching Syntax with regex

Using regular expressions on a route's path can allow fine tuning of the route.

For example:

  {
    //match a route ending with at least 1 digit and nothing else.
    // => "/example/1" will work
    // => "/example/123" will work
    // => "/example/one" won't work and will load the 404 page
    path: '/example/:id(\\d+)',
    component: () => import('@/views/Example.vue'),
  },

This can be extended to having a list of data:

  {
    //match a route ending with at least 1 digit and nothing else.
    // => "/example/1/2/3/4" will provide in params a value "id" being an array of STRING values 1, 2, 3 and 4.
    path: '/example/:id+',
    component: () => import('@/views/Example.vue'),
  },

Having the + makes requires to provide at least one value for id. Otherwise, you get a 404.

Having the * makes the id optional and it will load the route and its component. route.params.id will just be undefined.

And put it together with format restriction and repeatable params:

  {
    //match a route ending with at least 1 digit and nothing else.
    // => "/example/1/2/3/4" will provide in params a value "id" being an array of INTEGER values 1, 2, 3 and 4.
    path: '/example/:id(\\d+)+',
    component: () => import('@/views/Example.vue'),
  },

Finally, making the parameter optional without making it repeatable, use ?:

  {
    //match a route ending with 0 to N digits and nothing else.
    // => "/example" will work
    // => "/example/123" will work
    path: '/example/:id(\\d+)?',
    component: () => import('@/views/Example.vue'),
  },

Dynamic routes

Using the addRoute method of the router instance, you add a route on the fly:

const addDynamicRoute = () => {
  router.addRoute({
    name: "dynamic",
    path: "/dynamic",
    component: () => import("@/views/UserLogin.vue"),
  });
};

The caveat is: you cannot use the name of the route until you are sure it is available.

So using the path:

    <button @click="addDynamicRoute" class="btn">Add dynamic route</button>
    <!-- will show and lead to 404 if the button isn't clicked -->
    <router-link to="/dynamic">Visite Dynamic Route</router-link>
    <!-- will break the current page since the named route "dynamic" doesn't exist -->
    <router-link :to="{name: 'dynamic'}">Visite Dynamic Route</router-link

Similarly, you can remove routes:

router.removeRoute("dynamic");