Well, since the time I started playing with node.js and express I wondered how a router works under the hood. I even remember I once had a look on Backbone's router and didn't seem too hard to understand, but was wondering who is that crazy person who came up with this.
A couple days ago I wanted to build a simple markdown-notes list for fun using react. I started with a list of notes where you click one and navigate to the single note view. Ok, first I need to install React Router... Nah. That's boring. I'll just make a dummy router for now. How hard can it be? Let's see.
So I had in mind something that works similar to React Router:
<Router>
<Route path="/">
list of items
</Route>
<Route path="/items/:slug">
individual item
</Route>
</Router>
But how does a router work inside?
I imagine that it has a render method that returns the active route if any.
export default class DummyRouter extends React.Component {
// ...
render() {
return this.state.currentRoute || null;
}
}
Then I guess we need to listen to the url change event and call a method that will update currentRoute
in state.
Lets start with hashchange
event for now.
export default class DummyRouter extends React.Component {
// ...
componentDidMount() {
// Listen on hash change:
window.addEventListener("hashchange", this.selectRoute);
// Run on first load:
this.selectRoute();
}
// ...
}
Now in selectRoute()
we need to go through all the routes which are direct children of router (routes) and check if their set path matches the url hash.
export default class DummyRouter extends React.Component {
// ...
selectRoute = () => {
const currentPath = window.location.hash.slice(1) || '/';
const { children: routes } = this.props;
React.Children.map(routes, route => {
if (route.props.path === currentPath) {
this.setState({ currentRoute: React.cloneElement(route, {}) });
}
});
}
// ...
}
So that was it. Here is the router you asked:
import React from "react";
export default class DummyRouter extends React.Component {
state = {
currentRoute: null
};
selectRoute = () => {
const currentPath = window.location.hash.slice(1) || '/';
const { children: routes } = this.props;
// I'd love to have a reduce or filter here.
React.Children.map(routes, route => {
// route matches current path
if (route.props.path === currentPath) {
this.setState({ currentRoute: React.cloneElement(route, {}) });
}
});
}
componentDidMount() {
// Listen on hash change
window.addEventListener("hashchange", this.selectRoute);
// Run on first load
this.selectRoute();
}
componentWillUnmount() {
// Stop listening
window.removeEventListener("hashchange", this.selectRoute);
}
render() {
return this.state.currentRoute || null;
}
}
What? This is not a real router!
I want to match patterns, not the exact string. I also want a way to grab the params like usernames or ids from the path.
That should be a simple Regular Expression, right?
Thinking... Thinking... Thinking...
We can have a Regular Expression to match patterns but then how do we know which param is which?
I know, Regular Expressions can have named groups. We can name each match with the word specified in the route path and then we can easily access it's value.
Oh no! JS doesn't support named groups in regular expressions.
But hey, I remember I read about some improvements the other day for regular expressions in ES6, thinking who is ever gonna need that. That's the irony of life.
All we need to do is to match a pattern like /mypath/:paraminpath/:otherparam
and then replace it with a regular expression that collects the parameters.
So /notes/:slug
should be transformed to /\/notes\/\:(?<slug>\\w+)\//
. This should be able to collect the slug
so that we can use it to render the page with the correct data.
But how can we transform the pattern to that expression?
Using another regular expression of course. :D
/**
* Generates Regular Expression to
* extract parameters from path
*/
const transformPattern = pathPattern => {
// Match text that starts with ":"
const pathParamRX = /:(\w+)/;
const matchedParam = pathPattern.match(pathParamRX);
const paramName = matchedParam && matchedParam[1];
// replace the text we matched with the right regex
const routeRX = pathPattern.replace(pathParamRX, `(?<${paramName}>\\w+)`);
// If unparsed params rerun or return generated regular expression
return pathParamRX.test(routeRX)
? transformPattern(routeRX)
: new RegExp(routeRX);
};
Awesome. Now all we need is to use that regular expression in order to find which route corresponds to the current location path.
/*
* Checks if path matches route
* and returns any parameters
*/
export const getPathPatternMatch = (pathPattern, currentPath) => {
const patternParamsRX = transformPattern(pathPattern);
// Try to get parameters from route path
if (patternParamsRX) {
const patternMatchesPath = currentPath.match(patternParamsRX);
if (patternMatchesPath) {
// Return match with any parameters found
return patternMatchesPath.groups || {};
}
}
return false;
};
And now we can call this function inside our Router's selectRoute
method instead of the route.props.path === currentPath
.
export default class DummyRouter extends React.Component {
// ...
selectRoute = () => {
const currentPath = window.location.hash.slice(1) || '/';
const { children: routes } = this.props;
React.Children.map(routes, route => {
const currentMatch = getPathPatternMatch(route.props.path, currentPath);
if (currentMatch) {
const currentRoute = React.cloneElement(route, {
...currentMatch
});
this.setState({ currentRoute });
}
});
}
// ...
}
Final result with some small improvements can be found in codesandbox and the complete Dummy Router code on github.