How to stay on one page
I've always dreamed of creating that kind of website that loads all of its pages dynamically. It seemed hard so I postponed that moment until... just now. And now that I've done it, I feel like it was easier than I first expected. So why not share the fun?
There are 3 things: the html, the controllers that serve them (php, python, ruby...) and the javascript. Let's start from easier to harder, although you can tl;dr or fast-forward read the html and the controllers (views if you're using a MTV).
The HTML
The HTML pages that I have are generated by Django's templating system. If you don't use a templating system you will have to make different files: one for normal calls (For those who don't have javascript activated!) and one without the stuff we don't want to load (so no <head>, no <header>, no <footer>, normally just the content).
In Twig I would normally do that:
{% if ajax is not true %}
{% extends 'layout.html' %}
{% endif %}
{% block content %}
my content :)
{% endblock %}
It's simple. If ajax == True
then you don't extend your block.
For Django's templating system I had to use a work around because you cannot wrap the extends function in a if
.
{% extends ajax|yesno:"blogs/ajax.html,blogs/layout.html" %}
{% block content %}
my content :)
{% endblock %}
and I had to create another ajax.html containing just the block content
{% block content %}{% endblock %}
the yesno
part is just a ternary operator
The PHP/Python/Ruby...
Now, you have to call your templates. If you don't use any templates then just understand that a GET variable is sent to the controllers. GET['ajax'] = True
if we want just the content, false
if we want the entire page.
It's pretty easy. I'll give you the code just in case you didn't understand:
In Django
# ajax?
ajax = request.GET.get('ajax', False)
# output
context = { 'ajax': ajax}
return render(request, 'content.html', context);
The get
returns False if nothing is found.
In CodeIgniter
// ajax?
$ajax = ($this->input->get('ajax'));
// output
$data = array('ajax' => $ajax);
$this->twig->display('content.html', $data);
(note that I use twig in CodeIgniter which is not the default templating engine)
The Javascript
Now comes the interesting part. But if you understand what I wrote earlier you'll understand how my system works.
Load a page
First, here's my function to load a new page: (and yes I forgot, I use jQuery)
function ajaxLocation(url, scrolly){
// spectacle
$('.flash_helper').show()
$(window).scrollTop(0)
scrolly = typeof scrolly !== undefined ? scrolly : 0
// load
$.get(url, { ajax : true }, function(data){
$('main').empty().html(data)
$(document).ready(function(){
$(window).scrollTop(scrolly)
$('.flash_helper').fadeOut('xslow')
})
})
}
-
url
is the complete path you want to load, exemple: '/blog/blog_23.html
' or '/blog/23/title/
' -
scrolly
is the vertial position of the scrollbar, it's useless for the moment but when the user wants to go back to previous pages it's cumbersome not to go give him back his exact previous location.scrollTop
is the jQuery function that sets the scrollbar to a position. I also test ifscrolly
is given as a parameter to the function (with a ternary operator again) and set it to 0 if not.
The first part of the code is just for the show, I use a .flash_helper
to produce a "white flash" effect and load the new page behind the scene.
reasons:
- It's nice because it shows the user that content has changed
-
I use a global css rule that disable the outline when you click on a link. If the page doesn't change instantly the user can think his click didn't work. In the code the first thing I do is flashing the page, so the user knows something is going on.
a:active, a:focus{ outline:0; } .flash_helper{ position:static; top:0; left:0; background-color:#FFF: width:100%; height:100%; z-index:99; display:none; }
I then load the url with the jQuery
$.get
function which is a shortcut for$.ajax('get')
I empty the div where the content is
I add my new page instead
When the page has loaded all the text (use
load()
if you want images to have loaded as well) we scroll to the right position (useless at the moment) and remove the white screen.
Note: you can use the jQuery function remove()
to remove entirely the div(s), and you can use the functions append
, prepend
, before
, after
to insert content exactly where you want.
Override links' default behavior
When the page has loaded, I'm getting sure that links clicked launch my special function instead of opening a new page. Only internal links! as javascript cannot load external links.
$(document).ready(function(){ // page loaded
$(document).on('click', '.internal', function(e){
// new tab or same tab?
if(ctrlPressed) return true
e.preventDefault()
// update this state first
var stateObj = { 'url': history.state.url, 'scrolly': $(window).scrollTop()}
history.replaceState(stateObj, "page", window.location.pathname)
// next state
var url = $(this).attr('href')
var stateObj = { 'url': url };
history.pushState(stateObj, "page", url)
// load
ajaxLocation(url, 0)
})
})
- I'm using a jQuery
on()
instead of a simple jQueryclick()
because I need to make sure future links that will be loaded are clickable as well. It's the newlive()
that has been deprecated. - I'm testing for ctrl+click (or cmd+click on mac) for people who want to open the link in a new tab. The
ctrlPressed
is set to true if so (in a function I'll explain later) and stops the code right here. - The two bits of code that update the state and push the next state are for the previous and forward buttons of your browser. As explained in the fabulous developer.mozila page.
- Finally I load the URL in the function we just created!
States?
Okay. I should explain the replaceState
and the pushState
.
Those two functions works similarly, they need 3 parameters : infos
, title
, url
.
Infos
will be useful to know where the previous page is and where the scrollbar was. The title
part is useless (and not used by any browser I know). The url
part is... well it's the url.
I used history.state.url
which is the current state's url (it's impossible in javascript to check others states). And I also use window.location.pathname
. They're the same here.
/!\ Careful though, the first time you load a page (opening a new tab for example) you will have a state = null (we'll tackle that later).
I'm updating the current state with the final position of the scrollbar (we're leaving the page)
I'm creating a new state. This will change the address in the address bar of the browser (so it's cool if you need to copy/paste the url or save it) and will also create a new state in the history. Now if you click on the previous button in your browser it will stay on the same page and load the previous state's variables (that we just modified). This does not refresh the page and this is why we can do ajax dynamic loading here :)
Alright we're all good.
Now we still need a few other things. The easiest one first:
Ctrl + Click or Cmd + Click on mac
Here is the function that checks if the user wants to open the link in a new tab:
var ctrlPressed = false
$(window).on('keydown', function(e){
if(e.metakey || e.ctrlKey) ctrlPressed = true
}).on('keyup', function(e){
if(e.metakey || e.ctrlKey) ctrlPressed = false
})
pretty straight forward.
First State
As I said earlier, if you load the page for the first time it will create a null
state. This is bad as we won't be able to go back to our first page if we keep hitting the previous button.
Solution? Push a new state first thing in the code.
if(history.state === null){
var stateObj = { 'url': window.location.pathname, 'scrolly': 0}
history.pushState(stateObj, "initial", window.location.pathname)
}
If you understood the previous parts this should not be hard to get.
Previous / Forward button
Now, if someone hits the previous button (or forward button) it will not do anything. Because as I said it will get the previous/forward states variables we created and they do not change the window.location.
window.onpopstate = function(event) {
if(event.state != null){
ajaxLocation(event.state.url, event.state.scrolly)
}
};
This event listener will do something on PopState
. Which is called every time we hit the previous/forward button of our browsers. After a PopState, the current state changes and we just have to check what is its URL and the position of its scrollbar and give them to our ajaxLocation
function :) and voilà! Happy?
Oh wait. One more thing. Chrome and Safari do a PopState
when they load a page for the first time. It's useless but it's actually what you're supposed to do according to the specs.
So we just check that we're not on a null
state first :)
Final Words
I didn't want to write that much when I started this but oh well... I hope you understood everything, you can ask me questions in the comments :)
Written by David Wong
Related protips
2 Responses
Very interesting "homemade" method. If you want to take this further, checkout either Backbone (http://www.backbonejs.org) or angular (http://www.angularjs.com) -- Both will allow you to roll your backend in the language of your choice. Also, the templates are stored locally on the user browser and pre-downloaded, which makes sure you don't "wait' for pages to download.
Thanks for the article and have a nice day!
For the front-end, there's a little library called PJAX that does exactly what you're describing:
http://pjax.heroku.com/