SPA Routing
Deep dive with
Vue Router

Saint Petersburg 24 May 2019

GitHub icon
Twitter icon

Eduardo

San Martin Morote

🌍 Vue core team

👨‍💻 Freelance

📍Paris

🐣 Malaga 🇪🇸

GitHub icon

Routing in you App

the 1st week

Routing in your App

after a year

What is aN
SPA Router?

🗂 History

🚦 Router

📦 Componentes

HTML5 history

Hash based history

Abstract (SSR, no url)

Phone

Creating routes

Matching logic

Lazy loading

Guards

Public API

View
Link

Three kind of routers

  • Imperative
  • Declarative
  • Configuration-based

Creating routes in Page.js

page('/users', (context, next) => {
  // change users page
  next()
})

// applying multiple middlewares
page('/users/:id/edit', checkUser, userProfileEdit)

Navigating in Page.js

page('/') // go to home
page('/users/2/edit') // go to user edition with id 2

Simple & Flexible
but Verbose

Page.js

✍️ Imperative

✅ Programmatic navigation

❌ Declarative navigation

Navigation Guards

❌ Dynamic routing (add/remove routes)

Dynamic routing

router.addRoute('/some-route', options)
router.removeRoute('/some-route')

⚠️ not actual API

Reach Router

<Router>
  <Home path="/" />
  <UserProfileEdit path="/users/:id/edit" />
</Router>
<div>
  <Link to="/">Home</Link>
  <Link to={`/users/${this.user.id}/edit`} />
</div>

Reach router

Idiomatic for React

✍️ Declarative

⚠️ Programmatic navigation (in-jsx)

 Declarative navigation

 Navigation Guards

Dynamic routing (add/remove routes)

 

Vue Router

const router = new Router({
  mode: 'history',
  routes: [
    { path: '/', component: Home },
    { path: '/users/:id', component: UserProfile },
  ]
})
<div>
  <router-link to="/">Home</router-link>
  <router-link :to="`/users/${this.user.id}`">
    My Profile
  </router-link>
</div>

Vue router

Decoupled:
router instance / components

✍️ Configuration-based

 Programmatic navigation

 Declarative navigation

Navigation Guards

⚠️ Dynamic routing (add/remove routes)

 

🗂 HISTORY

🚦 ROUTER

📦 COMPONENTS

router.push('/search')
<router-view/>

🗂 HISTORy

  • Store visited URLs

  • JS ↔ URL

    • push() replace(), ...
    • listen()

🚦 ROUTER

  • Route matching​

    • ​match()
    • ​resolve()
  • Navigation

    • ​​currentRoute
    • push() replace(), ...
    • ​beforeEach(), ...
  • Creating routes

    • new Router({ routes })
    • addRoutes()

📦 COMPONENTES

<router-view/>
<router-link to="/">Home</router-link>

The
Good PARTS

📦 router-view
<router-view/>

Router Instance

  • Dynamically render current view
  • Pass params as props
📦 router-link

Router Instance

  • Resolve target location
  • Render an anchor tag with link
  • Handles click event
  • Applies active classes
<router-link to="/">Home</router-link>
<router-link :to="{ name: 'User', params: {id: '2'}}">...</router-link>
this.$route
{
  path: '/users/2',
  name: 'UserProfile',
  query: {},
  params: { id: '2' },
  meta: {}
}
<p>User: {{ $route.params.id }}</p>
created () {
  fetch(`/api/users/${this.$route.params.id}`)
    .then(/* ... */)
}
this.$route
created () {
  if (someCondition) {
    this.$router.push('/other-route')
  }
}
this.$router
const router = new Router({
  mode: 'history',
  routes: [
    { path: '/', component: Home },
    { path: '/users/:id', component: UserProfile },
  ]
})

The
UGLY PARTS

No clear Division of responsibilities

History

Router

Global Guards

router.beforeEach((to, from, next) => {
  // verify roles based on `to`
  // ...

  // we can only call `next` once
  if (!isLoggedIn) next('/login') // redirect to login
  else if (!isAuthorized) next(false) // abort
  else next() // allow navigation
})

router.afterEach((to, from) => {
  // Analytics, etc
  // cannot modify navigation anymore
})
router.beforeEach
function beforeEach(guard: NavigationGuard): ListenerRemover {
  this.beforeGuards.push(guard)
  return () => {
    const i = this.beforeGuards.indexOf(guard)
    if (i > -1) this.beforeGuards.splice(i, 1)
  }
}
router.push
function push (location, onComplete, onAbort) {
  this.history.push(location, onComplete, onAbort)
}
history.push
function push (location, onComplete, onAbort) {
  const route = this.router.match(location, this.currentLocation)

  try {  
    // run navigation guards queue
    // ...

    // change the url in the browser (HTML5)
    window.history.pushState({}, '', route.fullPath)
    onComplete(route)
  } catch (error) {
    // handle the error
    // ...
    onAbort(error)
  }
}

⚠️ Simplified version

🗂 History

  • Base: +300 LoC
  • HTML5: 70 LoC
  • Hash: 130 LoC

🚦 Router

  • Router Class: ~200 LoC
  • Matcher: ~180 LoC

😨 Complex codebase

🙂 Simple codebase

No clear Division of responsibilities

  • Harder to fix bugs
  • Harder to add features
  • Harder to contribute
  • Harder to extend

🗂 HISTORy

  • Store visited URLs

  • JS ↔ URL

    • push() replace(), ...
    • listen()

🚦 ROUTER

  • Route matching

    • ​match()
    • ​resolve()
  • Navigation

    • ​​currentRoute
    • push() replace(), ...
    • ​beforeEach(), ...
  • Creating routes

    • new Router({ routes })
    • addRoutes()

🗂 HISTORy

  • push() replace()
  • listen()
  • URL parsing

API

  • Modify the Location
  • Parses URL
    • path
    • query
    • hash
  • Notifies when Location changes
  • Handles Encoding problems
  • Can be overloaded

Responsibilities / Expectations

🗂 History Base =

    ~10 Loc + ~150 Loc parsing

🗂 History HTML5 =

    ~200 Loc

🗂 HTML5 Push

push(to: HistoryLocation, data?: HistoryState) {
  const normalized = this.utils.normalizeLocation(to)
  // replace current entry state to add the forward value
  this.history.replaceState({
    ...this.history.state,
    forward: normalized
  },
    ''
  )

  const state = {
    back: this.location,
    current: normalized,
    forward: null,
    replaced: false
    ...data,
  }

  this.history.pushState(state, '', normalized.fullPath)
  this.location = normalized
}

🗂 HTML5 Popstate listener

private setupPopStateListener() {
  const handler: PopStateListener = ({ state }: { state: StateEntry }) => {
    const from = this.location
    // save the location where we are going or build it
    // from window.location
    this.location = state ? state.current : buildFullPath()

    // call all listeners
    const navigationInfo = {
      direction:
        state.forward && from.fullPath === state.forward.fullPath
          ? NavigationDirection.back
          : NavigationDirection.forward,
    }
    this._listeners.forEach(listener =>
      listener(this.location, from, navigationInfo)
    )
  }

  // setup the listener and return the handler
  // so it can be removed in the future
  window.addEventListener('popstate', handler)
  return handler
}

vue-router/push-state.js

reach-router/history.js

Browser Quirks

URI ENcoding Issues

Directly navigate to

/é?é=é#é​
  • URL Bar:  /é?é=é#é​
  • location.pathname: '/%C3%A9'
    
  • location.search: '%C3%A9=%C3%A9'
    
  • location.search: '#%C3%A9'
  • URL Bar:  /é?é=é#é​
  • location.pathname: '/é'
    
  • location.search: 'é=é'
    
  • location.search: '#é'
history.pushState({}, '', '/é?é=é#é​')

🚦 ROUTER

  • Route matching

    • ​resolve()
  • Adding Route Records
    • addRouteRecord
    • removeRouteRecord
  • Navigation

    • ​​currentRoute
    • push() replace(), ...
  • Navigation Guards

    • ​beforeEach(), ...
  • Dynamic Routing
    • ​addRoute / removeRoute

🚦 ROUTER

🛣 Matcher

🛣 Matcher

  • Route matching

    • ​resolve()
  • Adding Route Records
    • addRouteRecord
    • removeRouteRecord

API

  • Resolving a Router Location to a Route Record
  • Only handles the path of URL
  • Handles priority of Route Records
  • Parses/handle params

Responsibilities / Expectations

🛣 add Route Record

function addRouteRecord(
  record: RouteRecord,
  parent?: RouteMatcher
): void {
  // create the matcher, link it to parent
  const matcher = createMatcher(record, parent)
  if (parent) parent.children.push(matcher)
  
  // handle nested routes
  if ('children' in record) {
    for (const childRecord of record.children) {
      this.addRouteRecord(childRecord, matcher)
    }
  }

  this.insertMatcher(matcher)
}
  • Each segment gets 4 points and then…

  • Static segments get 3 more points

  • Dynamic segments 2 more

  • Root segments 1

  • and finally wildcard segments get a 1 point penalty

  • Navigation

    • ​​currentRoute
    • push() replace()
  • Navigation Guards

    • ​beforeEach()
  • Dynamic Routing
  • Lazy loading Pages
  • In-component Guards
  • Error handlers

🚦 ROUTER

API

  • Async navigation
  • Trigger Navigation guards
  • Expose current Route Location
  • Handle redirections

Responsibilities / Expectations

🚦 Push / Replace

async function push(to: RouteLocation): Promise<RouteLocationNormalized> {
  // match the location
  const toLocation = this.resolveLocation(to, this.currentRoute)
  
  // navigate, handle guards
  try {
    await this.navigate(toLocation, this.currentRoute)
  } catch (error) {
    if (error instanceof NavigationGuardRedirect) {
      // trigger the whole navigation again
      return this.push(error.to)
    } else {
      throw error
    }
  }

  // change the URL
  if (to.replace === true) this.history.replace(toLocation)
  else this.history.push(toLocation)

  // save current location
  const from = this.currentRoute
  this.currentRoute = toLocation

  // navigation is confirmed, call afterGuards
  for (const guard of this.afterGuards) guard(toLocation, from)

  return this.currentRoute
}

🚦 resolve Location

function resolveLocation(
  location: MatcherLocation,
  currentLocation: MatcherLocationNormalized,
  redirectedFrom?: MatcherLocationNormalized
): MatcherLocationNormalized {
  // resolve against the matcher
  const matchedRoute = this.matcher.resolve(location, currentLocation)

  // handles in-record redirects
  if ('redirect' in matchedRoute) {
    const { redirect, normalizedLocation } = matchedRoute
    // match the redirect instead
    return this.resolveLocation(
      this.history.utils.normalizeLocation(redirect),
      currentLocation,
      // pass down the location we tried to navigate to
      normalizedLocation
    )
  } else {
    // save the redirection stack
    matchedRoute.redirectedFrom = redirectedFrom
    return matchedRoute
  }
}

🚦 Navigate

async function push(to: RouteLocation): Promise<RouteLocationNormalized> {
  // match the location
  const toLocation = this.resolveLocation(to, this.currentRoute)
  
  // navigate, handle guards
  try {
    await this.navigate(toLocation, this.currentRoute)
  } catch (error) {
    if (error instanceof NavigationGuardRedirect) {
      // trigger the whole navigation again
      return this.push(error.to)
    } else {
      throw error
    }
  }

  // change the URL
  if (to.replace === true) this.history.replace(toLocation)
  else this.history.push(toLocation)

  // save current location
  const from = this.currentRoute
  this.currentRoute = toLocation

  // navigation is confirmed, call afterGuards
  for (const guard of this.afterGuards) guard(toLocation, from)

  return this.currentRoute
}

Navigation
Guards

Global Guards

router.beforeEach((to, from, next) => {
  // verify roles based on `to`
  // ...

  // we can only call `next` once
  if (!isLoggedIn) next('/login') // redirect to login
  else if (!isAuthorized) next(false) // abort
  else next() // allow navigation
})

Per-route Guards

{
  path: '/admin',
  component: AdminPanel,
  beforeEnter (to, from, next) {
    if (isAdmin) next()
    else next(false)
  },
}

in-component guards

export default {
  name: 'AdminPanel',

  data: () => ({ adminInfo: null }),

  async beforeRouteEnter (to, from, next) {
    const adminInfo = await getAdminInfo()
    next(vm => {
      vm.adminInfo = adminInfo
    })
  },
}

beforeEach

beforeEnter

beforeRouteEnter

next()
next(false)

/posts ➡️ /admin

/posts

Admin.vue

Static To Lazy in Vue

import Calendar from '@/components/Calendar.vue'
const Calendar = () => import('@/components/Calendar.vue')

beforeEnter

beforeRouteEnter

next()

/posts ➡️ /admin

await Admin()

🚦 Navigate

async function navigate(
  to: RouteLocationNormalized,
  from: RouteLocationNormalized
): Promise<void> {
  let guards: Array<() => Promise<any>>

  // check beforeRouteLeave guards
  guards = extractComponentsGuards(
    from.matched.filter(record => to.matched.indexOf(record) < 0).reverse(),
    'beforeRouteLeave',
    to,
    from
  )

  // run the queue of per beforeRouteLeave
  for (const guard of guards) await guard()

  // check global guards beforeEach
  guards = []
  for (const guard of this.beforeGuards) {
    guards.push(guardToPromiseFn(guard, to, from))
  }

  // beforeEach queue
  for (const guard of guards) await guard()

  // check in components beforeRouteUpdate
  guards = extractComponentsGuards(
    to.matched.filter(record => from.matched.indexOf(record) > -1),
    'beforeRouteUpdate',
    to,
    from
  )

  // beforeRouteUpdate queue
  for (const guard of guards) await guard()

  // check the route beforeEnter
  guards = []
  for (const record of to.matched) {
    // do not trigger beforeEnter on reused views
    if (record.beforeEnter && from.matched.indexOf(record) < 0) {
      guards.push(guardToPromiseFn(record.beforeEnter, to, from))
    }
  }

  // beforeEnter queue
  for (const guard of guards) await guard()

  // check in-component beforeRouteEnter
  guards = extractComponentsGuards(
    to.matched.filter(record => from.matched.indexOf(record) < 0),
    'beforeRouteEnter',
    to,
    from
  )

  // beforeRouteEnter queue
  await this.runGuardQueue(guards)
}

🚦 extract components Guards

type GuardType = 'beforeRouteEnter' | 'beforeRouteUpdate' | 'beforeRouteLeave'
export async function extractComponentsGuards(
  matched: MatchedRouteRecord[],
  guardType: GuardType,
  to: RouteLocationNormalized,
  from: RouteLocationNormalized
): Array<() => Promise<void>> {
  return matched.map(record => {
    const { component } = record
    return async () => {
      // resolve async components from cache
      const resolvedComponent = await resolveComponent(component)

      // await the guard if it exists
      const guard = resolvedComponent[guardType]
      if (guard) {
        await guardToPromiseFn(guard, to, from)()
      }
    }
  })
}

🚦 ROUTER

🛣 Matcher

🗂 History

  • Try to equally distribute code complexity and size
  • Use Typescript
  • Refactor often
  • Tests in many browsers
  • Make it easier to contribute

Patreons 🙌

patreon.com/posva

Спасибо! 🖖

GitHub icon

How does a frontend router work? Deep dive with Vue Router

By Eduardo San Martin Morote

How does a frontend router work? Deep dive with Vue Router

When we develop single-page applications, we have to use a router. Every single framework has its own router, React even has multiple ones you can choose from. And even though each framework is different and every router takes a different approach, they all share the same principles. What is behind a simple and easy-to-use API? Is it really that difficult to create your own SPA router? What are the different approaches and their advantages, caveats? Let's answer all these questions by comparing different routers and taking a deeper dive into Vue Router.

  • 708

More from Eduardo San Martin Morote