Criando seu próprio Client Router em Javascript - Parte 1
Lí todo código do router pagejs, e o que vamos fazer é praticamente reescrevê-lo passo a passo, e acredito que você vai aprender alguns truques assim como eu aprendi.
Dividi o post em 2 partes, esse primeiro criando um router básico, e na sequencia um mais completo.
Acredito que para quem é iniciante em Javascript o código seja um pouco hard, mas vou tentar explicar com o máximo de detalhes possíveis, e se der tudo certo, você terá escrito seu próprio router para usar em seus projetos.
Funcionamento do router
O router deve criar rotas, ou seja, informar um path e executar um callback quando esse path for chamado.
Nosso router será uma função chamada micror (micro router...entendeu?) e seus métodos serão:
micror( path, callback )Cria uma rotamicror.go( path )Chama uma rotamicror.run( options )Inicia o router
Criando uma rota
Aqui passamos o path e o callback.
micror('/', function(context) {
console.log('Home');
});
Chamando uma rota
Aqui estamos chamando o path / que executará a rota que criamos acima.
micror.go('/');
Iniciando o router
Iniciar é opcional, mas iremos chamar essa função para pegar a url atuale internamente executar o micror.go('url_atual').
Nessa função podemos passar um objeto com 2 opções, que também são opcionais:
- Opção para base url:
{ base: '/adm' }. - Opção para hash /!#:
{ hash: true }.
micror.run(); // micror.run( { base: '/adm', hash: 'true'} );
Criando uma rota com parâmetros
Nós precisamos passar parâmetros em algumas rotas, por exemplo um id de um post '/post/:id', então note esses dois pontos :, isso significa que na rota post, vai ter um parâmetro chamado id e o mesmo é obrigatório.
Mas existem situações que você precisa de um parâmetro, mas o mesmo é opcional, como por exemplo em posts '/posts/:page/:order?', note que nessa rota temos o :page que é um parâmetro obrigatório, mas temos agora esse :order? onde temos um ? no final, isso significa que o parâmetro é opcional.
Tudo isso vai no objeto context que explico a seguir.
micror('/post/:id', function(context) {
console.log(context.params.id);
});
micror('/posts/:page/:order?', function(ctx) {
console.log(context.params.page);
console.log(context.params.order);
});
Criando uma rota universal
Podemos também criar um rota universal usando * que será chamada com qualquer path que colocarmos. Mas tem que se ligar na ordem onde a declaramos, pois se colocarmos na frente de todas as outras rotas, somente ela executará e não cairá nas demais. Por isso colocamos ela sempre no final para usar como um not found, ou seja, se não der match em nenhuma rota, cai nela ;)
micror('/', function(ctx) {
console.log('Home');
});
micror('/about', function(ctx) {
console.log('Home');
});
micror('*', function(ctx) {
console.log('Rota Universal - Not Found');
});
Explicando o objeto Context
Todo callback recebe uma instância do objeto Context, nele colocamos informações da url como querystring e hash (quando houver). Mas nela também pegamos os parâmetros (obrigatório e/ou opcional) informados na rota, e então colocamos no atributo params.
Se chamamos algo assim micror.go('/posts/10?year=2016#results'), note que temos várias informações aqui, temos um parâmetro que vamos chamar de page 10, temos o querystring year=2016e o hash #results. Confira como fica o context aqui.
micror('/posts/:page', function(context) {
console.log('page', context.params.page);
console.log('queryString', context.querystring);
console.log('hash', context.hash);
});
Alterando a Url
Cada vez que chamamos uma rota devemos alterar a url, e faremos isso com history.replaceState.
Bom acho que é isso, vamos criar nosso router. De repente algumas coisas não ficaram claras ainda, mas conforme criamos o código, as coisas vão clareando.
1) micror.js
Crie um arquivo chamado micror.js, todo código vai nele.
var _base = ''; // base url
var _hash = false; // controle por hash
// nosso router :)
function micror(path, callback){
// criamos um objeto "route"
var route = {
path: path,
keys: []
};
// regexp
route.regexp = regexp(path,route.keys);
// armazenando as rotas
micror.callbacks.push(middleware(route, callback));
}
// objeto que guarda as rotas
micror.callbacks = [];
Cada vez que criamos uma rota, criamos um objeto chamado route que possui como atributos (path, keys, regexp).
O atributo route.regexp chama a função regexp passando o path e suas keys que é um array vazio. A função retorna uma expressão regular que será usada para comparar a rota, e junto já extrai os parâmetros para colocar dentro de keys. Por exemplo, se damos o path /posts/:page/:order?, é retornado /^\/posts\/([^\/]+)(?:\/([^\/]+))?(?:\/(?=$))?$/i, e as keys ficarão assim [ { name: 0 }, { name: 'page' }, { name: 'order' } ].
A expressão regular acima diz que para fazer o match, precisa começar com /posts seguido de qualquer coisa que não seja uma barra e contenha mais de um caracter, seguido de opcionalmente qualquer coisa que não seja uma barra e contenha mais de um caracter e opcionalemente termine com uma barra. Ufa...
Note que as keys já estão na ordem certinha dos parâmetros que informados na rota. Os parâmetros obrigatório e/ou opcionais que usamos : e ?, são colocados os seus nomes ({ name: 'page' }), nos parâmetros normais é colocado zero ({ name: 0 }).
Mas para que isso tudo? Bom quando chamamos uma rota por exemplo micror.go('/posts/10/asc'), já que temos nossas keys na ordem certa, consiguimos juntar essas informações para colocar no context, saca só:
// Esse é o path da rota que criamos
/posts/:page/:order?
// as keys criadas pela função regexp
[ { name: 0 }, { name: 'page' }, { name: 'order' } ]
// o path chamado foi esse
/posts/123/asc
// e finalmente temos isso no context
context.params.page = 123
context.params.order = 'asc'
Esse é o truque.
2) regexp()
Essa é a função que faz tudo que falamos acima, dá uma conferida.
function regexp(path, keys) {
var regex = path.replace(/\/(:?)([^\/?]+)(\??)(?=\/|$)/g,
function(match, isVariable, segment, isOptional) {
if(isVariable) keys.push({ name: segment });
return isVariable ? isOptional ? '(?:\\/([^\\/]+))?' : '\\/([^\\/]+)' : '\\/' + segment;
});
regex = regex === '*' ? '(.*)' : (regex === '/' ? '' : regex);
if (keys.length === 0) keys.push({name: 0});
return new RegExp( '^' + regex + '(?:\\/(?=$))?$','i');
}
3) middleware()
Lá em cima fizemos isso micror.callbacks.push(middleware(route, callback));, aqui chamamos a função middleware passando o objeto route e o callback. A função middleware retorna uma outra função que espera um objeto context e uma função next.
Quando essa função for chamada, ela vai pegar ocontext passado e comparar com o objeto route que o originou, comparando o route.regexp com o context.path. Se der match nós preenchemos o atributo params do context com as keys do route conforme falamos acima, e finalmente chamamos o callback passando o context.
Caso não dê match, é chamado a função next que irá verificar outra rota até dar match ou acabar as rotas registradas.
function middleware(route, callback) {
return function( context, next ) {
var match = route.regexp.exec(decodeURIComponent(context.path));
if( match ) {
fillParams(match, route.keys, context.params );
return callback(context);
}
next();
}
}
4) fillParams
Essa função faz aquilo que falamos lá em cima, de juntar as keys e os parâmetros que foram informados na url.
function fillParams(match, keys, params) {
var len = match.length;
var idx = 0;
var key, val;
while (++idx < len) {
key = keys[idx - 1];
val = match[idx];
if (val !== undefined) {
params[key.name] = val;
}
}
}
5) micror.go()
Aqui é que verificamos cada um dos callbacks das rotas que foram registrados.
Quando chamamos uma rota, primeiramente é criado uma instância do objeto Context, esse objeto pega o path passado e extrai várias informações importantes como:
fullPath: O path original com querystring e hash.path: Aqui é retirado a querystring e hash para poder fazer o match com o route.regexpquerystringhashtitle: Título da página
O Context possui também um método para fazer o replaceState, e já fazemos isso após criá-lo.
Agora chamo a função callNextCallback que começa com o índice zero, e verifica se possui uma função registrada em micror.callbacks, se sim, ele executa a função que é aquela função middleware passando o context e ela própria callNextCallback. Lembra que lá em cima a função next em middleware? Se não der match ele chama o next que é na real nossa callNextCallback somando +1 no nosso índice, e segue adiante até acabar as funções das rotas registrados...não é genial ?
micror.go = function(path) {
var context = new Context(path);
context.saveState();
var i = 0;
function callNextCallback() {
var callback = micror.callbacks[i++];
if(!callback) {
return console.log('route [', context.path, '] not found');
}
callback( context, callNextCallback );
}
callNextCallback();
};
6) micror.run()
Aqui é simples, inicializamos nossas options, depois pegamos a url atual e já chamamos uma rota.
micror.run = function(opts) {
_base = opts && opts.base ? opts.base : '';
_hash = opts && opts.hash ? '#!' : false;
var url = location.pathname + location.search + location.hash;
url = _base ? url.replace(_base, '') : '';
if( _hash && ~location.hash.indexOf('#!') ) {
url = location.hash.substr(2) + location.search;
}
micror.go(url);
};
7) Context()
function Context(path) {
path = _base + (_hash ? '/#!' : '') + path.replace(_base,'');
path = path.length > 1 ? path.replace(/\/$/,'') : path;
this.fullPath = path;
path = _hash ? path.split('#!')[1] : (_base ? path.replace(_base,'') : path);
this.title = document.title;
this.params = {};
var h = path.split('#');
path = h[0];
this.hash = h[1] || '';
var q = path.split('?');
path = q[0];
this.querystring = q[1] || '';
this.path = path || '/';
}
Context.prototype.saveState = function() {
history.replaceState(this.state, this.title, this.fullPath );
};
Código completo
Segue o código completo agora.
var _base = '';
var _hash = false;
function micror(path, callback){
var route = {
path: path,
keys: []
};
route.regexp = regexp(path,route.keys);
micror.callbacks.push(middleware(route, callback));
}
micror.callbacks = [];
micror.go = function(path) {
var context = new Context(path);
context.saveState();
var i = 0;
function callNextCallback() {
var callback = micror.callbacks[i++];
if(!callback) {
return console.log('route [', context.path, '] not found');
}
callback( context, callNextCallback );
}
callNextCallback();
};
micror.run = function(opts) {
_base = opts && opts.base ? opts.base : '';
_hash = opts && opts.hash ? '#!' : false;
var url = location.pathname + location.search + location.hash;
url = _base ? url.replace(_base, '') : '';
if( _hash && ~location.hash.indexOf('#!') ) {
url = location.hash.substr(2) + location.search;
}
micror.go(url);
};
function Context(path) {
path = _base + (_hash ? '/#!' : '') + path.replace(_base,'');
path = path.length > 1 ? path.replace(/\/$/,'') : path;
this.fullPath = path;
path = _hash ? path.split('#!')[1] : (_base ? path.replace(_base,'') : path);
this.title = document.title;
this.params = {};
var h = path.split('#');
path = h[0];
this.hash = h[1] || '';
var q = path.split('?');
path = q[0];
this.querystring = q[1] || '';
this.path = path || '/';
}
Context.prototype.saveState = function() {
history.replaceState(this.state, this.title, this.fullPath );
};
function middleware(route, callback) {
return function( context, next ) {
var match = route.regexp.exec(decodeURIComponent(context.path));
if( match ) {
fillParams(match, route.keys, context.params );
return callback(context);
}
next();
}
}
function fillParams(match, keys, params) {
var len = match.length;
var idx = 0;
var key, val;
while (++idx < len) {
key = keys[idx - 1];
val = match[idx];
if (val !== undefined) {
params[key.name] = val;
}
}
}
function regexp(path, keys) {
var regex = path.replace(/\/(:?)([^\/?]+)(\??)(?=\/|$)/g,
function(match, isVariable, segment, isOptional) {
if(isVariable) keys.push({ name: segment });
return isVariable ? isOptional ? '(?:\\/([^\\/]+))?' : '\\/([^\\/]+)' : '\\/' + segment;
});
regex = regex === '*' ? '(.*)' : (regex === '/' ? '' : regex);
if (keys.length === 0) keys.push({name: 0});
return new RegExp( '^' + regex + '(?:\\/(?=$))?$','i');
}
Testando
Crie um arquivo chamado index.html, e coloque o código abaixo. Agora você precisa levantar um servidor apache, node, python ... eu sempre recomendo o httpster.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>micro router</title>
<base href="/">
<style>
body {
background: #fff;
color: #666;
font-size: 1.1em;
}
a {
color: #444;
text-decoration: none;
text-shadow: -1px -1px 1px rgba(0,0,0,0.1);
}
a:hover {
color: #44666D;
}
h1 a {
display: inline-block;
color: #FC0E49;
}
.limiter {
margin: 0 auto;
max-width: 600px;
text-align: center;
}
ul li {
display: inline-block;
margin: 0 10px;
}
.display {
margin: 20px auto;
padding: 20px;
border-radius: 10px;
border: solid 1px #eee;
}
</style>
</head>
<body>
<div class="limiter">
<h1><a href="">micro router</a></h1>
<ul>
<li><a href="./">Home</a></li>
<li><a href="./about">About</a></li>
<li><a href="./post/42">Post</a></li>
<li><a href="./posts/1/asc">Posts</a></li>
<li><a href="./not-found">Not Found</a></li>
</ul>
<div class="display"></div>
</div>
<script src="/micror.js"></script>
<script>
var display = document.querySelector('.display');
micror('/', function(ctx) {
display.textContent = 'Rota Home';
});
micror('/about', function(ctx) {
display.textContent = 'Rota About';
});
micror('/post/:id', function(ctx) {
display.textContent = 'Rota Post com id = ' + ctx.params.id;
});
micror('/posts/:page/:order?', function(ctx) {
var html = 'Rota Posts com page = ' + ctx.params.page;
html += ' e order = ' + ctx.params.order || '';
display.textContent = html;
});
micror('*', function(ctx) {
display.textContent = 'PAGE NOT FOUND';
});
micror.run();
// Event Listener para os Links
var links = document.querySelectorAll('a');
var len = links.length;
while( len-- ) {
links[len].addEventListener('click', function(event) {
var element = event.target;
var path = element.pathname + element.search + (element.hash || '');
micror.go(path);
event.preventDefault();
});
}
</script>
</body>
</html>
Bom, depois teste passando a base.
micror.run({ base: '/adm'}); //(não esqueça de mudar a meta tag **base** no html né)
E o hash.
micror.run({ hash: true});
Teste, leia o código e se divirta. Caso tenha alguma dúvida me pergunte.
Post bem comprido :P, no próximo vamos colocar umas features bem legais.
Se conseguiu criar o router, deixe seu comentário aqui em baixo :).
Espero que tenham gostado. That's it !