Last Updated: February 25, 2016
·
4.858K
· mimoo

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 if scrolly 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 jQuery click() because I need to make sure future links that will be loaded are clickable as well. It's the new live() 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 :)

2 Responses
Add your response

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!

over 1 year ago ·

For the front-end, there's a little library called PJAX that does exactly what you're describing:
http://pjax.heroku.com/

over 1 year ago ·