Building a client-side router from scratch

Rajasegar Chandran

Agenda

  • Introduction
  • Hash-based routing
  • URL-based routing
  • Use a library for routing

What is not here?

  • Templating solutions
  • Query Params
  • Server side redirection

Disclaimer

Don't use this in production!

Standard Web Application

AJAX Web Application

Single-page Web Application

Architecture

  • Registry
  • Routes
  • Listeners

Registry

Collection of our application’s routes.


            const routes = [];
             

Route

Object that maps a URL to a DOM component

Listeners

listener for the current url

Hash-based routing

https://freshdesk.com#about

https://freshdesk.com#contact

Fragment Identifiers

Part of the URL beginning with and following the # symbol

URL = http://google.com#cool-feature

Fragment Id = #cool-feature

Window: hashchange event

The `hashchange` event is fired when the fragment identifier of the URL has changed.

index.html

            
            
            
          

routes.js

          
export default container => {
  const home = () => {
    container
      .textContent = 'This is Home page'
  }
  const list = () => {
    container
      .textContent = 'This is List Page'
  }
  return {
    home,
    list
  }
}

          
          

404


const notFound = () => {
  container
    .textContent = 'Page Not Found!'
}
          

Basic Functions of a Router

  • Define and add routes to the registry
  • Map the current URL to the corresponding route and it's component
  • Handle unknown routes with 404
  • Initialize and Listen for URL changes

router.js

 export default () => {
  // Registry
  const routes = [];
  let notFound = () => {};
  const router = {};
  const checkRoutes = () => {
    const currentRoute = routes.find(route => {
      return route.fragment === window.location.hash;
    })
    if (!currentRoute) {
      notFound();
      return;
    }
    currentRoute.component();
  }

  router.addRoute = (fragment, component) => {
    routes.push({
      fragment,
      component
    });
    return router;
  }

  router.setNotFound = cb => {
    notFound = cb;
    return router;
  }

  // Listener
  router.start = () => {
    window.addEventListener('hashchange', checkRoutes)
    if (!window.location.hash) {
      window.location.hash = '#/'
    }
    checkRoutes()
  }

  return router
}
          

index.js

import createRouter from './router.js';
import createPages from './routes.js';
const container = document.querySelector('main');
const pages = createPages(container);
const router = createRouter();
router
  .addRoute('#/', pages.home)
  .addRoute('#/list', pages.list)
  .setNotFound(pages.notFound)
  .start();
          

index.html


          

             
           

Programmatic Navigation

  • Redirection (Login/Signup)
  • Access Restriction

index.html


          
           

Listening anchor events

const NAV_A_SELECTOR = 'a[data-navigation]';
router.start = () => {
  checkRoutes();
  window.setInterval(checkRoutes, TICKTIME);
  document
    .body
    .addEventListener('click', e => {
      const { target } = e;
      if (target.matches(NAV_A_SELECTOR)) {
        e.preventDefault();
        router.navigate(target.href);
      }
    })
  return router;
}
 

router.navigate


  router.navigate = fragment => {
    window.location.hash = fragment
  }
          

Route Parameters

Fragments

  • Dynamic Segments (Ember.js)
  • Bound Parameters (Rails)

freshdesk.com/tickets/:id

freshdesk.com/tickets/123

routes.js

const detail = (params) => {
  const { id } = params
  container.textContent = `This is Detail Page with Id ${id}`;
}
const anotherDetail = (params) => {
  const { id, anotherId } = params
  container.textContent = `This is another Detail Page 
    with Id ${id}  and AnotherId ${anotherId}`;
}
 

Defining Routes with parameters

router
  .addRoute('#/', pages.home)
  .addRoute('#/list', pages.list)
  .addRoute('#/list/:id', pages.detail)
  .addRoute('#/list/:id/:anotherId', pages.anotherDetail)
  .setNotFound(pages.notFound)
  .start()
             

Extracting Parameter names

#/list/:id/:anotherId

/:(\w+)/g

[ 'id', 'anotherId' ]

Matching route with parameters

#/list/:id/:anotherId

^#\/list\/([^\\/]+)\/([^\\/]+)$

Extracting Parameters Name from Fragments

const ROUTE_PARAMETER_REGEXP = /:(\w+)/g
const URL_FRAGMENT_REGEXP = '([^\\/]+)'
router.addRoute = (fragment, component) => {
  const params = []
  const parsedFragment = fragment
    .replace(
      ROUTE_PARAMETER_REGEXP,
      (match, paramName) => {
        params.push(paramName)
        return URL_FRAGMENT_REGEXP
      })
    .replace(/\//g, '\\/')
  routes.push({
    testRegExp: new RegExp(`^${parsedFragment}$`),
    component,
    params
  })
  return router
}
 

Route entry in the Registry

#/list/:id/:anotherId


            {
              "testRegExp": ^#\/list\/([^\\/]+)\/([^\\/]+)$,
              "component": pages.anotherDetail,
              "params": ['id', 'anotherId']
            }
            

Populate the route params from the current fragment

            const extractUrlParams = (route, windowHash) => {
  if (route.params.length === 0) {
    return {};
  }
  const params = {};
  const matches = windowHash.match(route.testRegExp);
  matches.shift();
  matches.forEach((paramValue, index) => {
    const paramName = route.params[index]
    params[paramName] = paramValue
  });
  return params;
}
 

checkRoutes

const checkRoutes = () => {
  const { hash } = window.location;
  const currentRoute = routes.find(route => {
    const { testRegExp } = route;
    return testRegExp.test(hash);
  });
  if (!currentRoute) {
    notFound();
    return;
  }
  const urlParams = extractUrlParams(
    currentRoute,
    window.location.hash
  );
  currentRoute.component(urlParams);
}
             
            
Demo

Downsides of Hash-based routing

  • Older Browsers
  • Doesn't play well with Server side
  • Small client-side only apps with no Backend
  • Search Engine Indexing & SEO
  • Server Side Rendering

URL based routing

History API

Manipulate the user's browsing history

History API

back() Goes to the previous page in the history.
forward() Goes to the next page in the history.
go(index) Goes to a specific page in the history.
pushState(state, title, URL) Pushes the data in the history stack and navigate to the provided URL.
replaceState(state, title, URL) Replaces the most recent data in the history stack and navigates to the provided URL.

checkRoutes

const { pathname } = window.location;
    if (lastPathname === pathname) {
      return;
    }
             

router.navigate

router.navigate = path => {
    window
      .history
      .pushState(null, null, path)
  }
               
Demo

Navigo

Created by Krasimir Tsonev

Github

Adding Navigo to our app

            
            
          

Using Navigo in our router.js

export default () => {
  const navigoRouter = new window.Navigo()
  const router = {}
  router.addRoute = (path, callback) => {
    navigoRouter.on(path, callback)
    return router
  }
router.setNotFound = cb => {
    navigoRouter.notFound(cb)
    return router
  }
  router.navigate = path => {
    navigoRouter.navigate(path)
  }
  router.start = () => {
    navigoRouter.resolve()
    return router
  }
  return router
}
          

index.html


            
             
Demo

When to use What?

Hash-based URL-based
Older Browsers
Browser History
Server-side Rendering
Backend interactions

Javascript Frameworks

Ember.js


// Hash based routing
ENV.locationType = "hash";
            

// URL based routing
ENV.locationType = "history";
            

Automatic routing


// Modern Browsers => History API 
// Older Browsers => Hash based routing
ENV.locationType = "auto";
            

React

react-router

Hash based routing:


            
            

URL based routing ( History API)


            
            

Vue.js

vue-router

Hash based routing (default):


const router = new VueRouter({
  mode: 'hash',
});
            

HTML5 History Mode


const router = new VueRouter({
  mode: 'history',
});
            

References:

Slides

https://rajasegar.github.io/csr-slides/

Code Examples

Thank you!