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
Made with Slides.com