SPA Routing
Deep dive with
Vue Router
Saint Petersburg 24 May 2019
Eduardo
San Martin Morote
🌍 Vue core team
👨💻 Freelance
📍Paris
🐣 Malaga 🇪🇸
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
Спасибо! 🖖
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.
- 3,618