Turbolinks y Django

Ultima revision Oct. 19, 2020
Version en ingles click aquí

¿Que es Turbolinks?

Turbolinks es una gema incluida en Ruby on Rails que evita tener que recargar los archivos CSS y JavaScript cada vez que oprimes un link en tu aplicación, haciendo que la navegación se sienta más rápida y agíl en el navegador. Este funciona de la siguiente forma: en vez de recargar la página cuando se oprime un link, Turbolinks intercepta el evento click, hace un llamado AJAX al servidor y reemplaza el cuerpo (<body>) de la página con la respuesta.

Con Turbolinks tus aplicaciones se van a sentir casi como si estuviesen hechas con un framework JavaScript como Angular o React, pero sin la complejidad que eso implica. Además de mantener un caché de las últimas páginas visitadas, que permite que estas páginas se puedan mostrar de casi de forma inmediata, sin hacer un llamado al servidor.

¿Cómo lo usamos en nuestras aplicaciones Django?

En comparación con la gema de RoR en Django (hasta el momento) no he encontrado una libreria que funcione o se integre en el assets pipeline como lo hace la version de Rails. Aunque, desde la version 5 de Turbolinks donde fue reescrito por completo usando TypeScript para simplificar la API; lo que significa que puede ser usado sin necesidad de que tu aplicación este escrita en RoR, a esta version se le conoce como standalone y se puede descargar usando CDNJS e incluirlo en los assets de tu aplicación.

Lo primero que vamos a hacer es agregar Turbolinks a nuestra plantilla principal (base.html).

{% load static %}
<head>
  <title>Django + Turbolinks</title>
  ...
  <script src="{% static 'js/turbolinks.js' %}></script>
  ...
</head>

Si notas, lo hemos cargado en la cabecera y no al final de <body>, esto debido a que si lo dejamos en el final, sera recargado en cada cambio de la aplicación.

NOTA: En este caso, estamos cargando el script Turbolinks, utilizando la administración normal de estaticos de Django, esto funcionará, pero más adelante explicare por qué esta no es la forma recomendada de cargarlo.

Si usas los bloques de las plantillas en Django para renderizar ciertos scripts en ciertas paginas es mejor que dejes de usar ese método y muevas todo a la cabecera, para asegurar el optimo funcionamiento de Turbolinks, mira el siguiente ejemplo_

*base.html*
<html>
    <head>
        <title>Turbolinks + Django</title>
    </head>
    <body>
        <main>
            {% block main %}
            {% endblock main %}
        </main>
        {% block javascripts %}
        {% endblock javascripts %}
    </body>
</html>
*main.html*
{% extendeds 'base.html' %}
{% load static %}

{% block main %}
    <h1>Hi!</h1>
{% endblock main %}

{% block javascripts %}
<script src="{% static 'js/cool_script.js' %}"></script>
{% endblock javascripts %}

La razón por la que deberíamos evitar esto es que Turbolinks, como mencioné anteriormente, reemplaza el <body> al cambiar de página, y al hacerlo volverá a ejecutar todos sus scripts (imagínese que jQuery se está reevaluando), lo cual puede que no sea lo que quieras.


Puesta en marcha

Ahora veremos cómo combinar todo para poner en marcha nuestra aplicación, siguiendo unos cuantos pasos y recomendaciones.

Fingerprint

Turbolinks hace track de las URLs de los archivos estáticos que tienes en la cabecera de tu documento, es por eso que es recomendable agregar un identificador único a tus urls, que por lo general es generado con cada nuevo deploy.

<head><link rel=“stylesheet” href=“/application-258e88d.css” />
  <script src=“/application-cbd3cd4.js”></script>
</head>

Puedes auxiliarte de Django Pipelines para este proceso y de paso organizar mejor los assets de tu aplicación.

Idempotencia

Si has leído la documentación, habrás notado los eventos que agrega Turbolinks a tu aplicación, uno de estos es el turbolinks:load, pues bien si moviste todos los inicializadores dentro de esto, notaras que se te duplican los eventos, puedes hacer uso de este plugin rstacruz/onmount para evitar este tipo de errores.

$.onmount(
  .push-button,
  function() {
    $(this).on('click', doSomething)
  },
  function() {
    $(this).off('click', doSomething)
  }
);

De esta forma, cada vez que el elemento existe en el DOM renderizado, le agregamos el evento click y cuando desaparece, le desactivamos, un buen tip es invocar onmount cada vez que Turbolinks carga una pagina.

$(document).on('ready turbolinks:load', function() {
  $.onmount();
});

Async - Defer

Dado que todos los assets deben de estar en la cabecera de la aplicación, no querrás agregarle más tiempo al usuario y dañar la experiencia de este, es por ello que debes de considerar que es importante cargar primero y que puede esperar, para ello utiliza los atributos async o defer según sea conveniente.

Defer-Async

No-Cache

Si tienes alguna pagina que requiera tener siempre la version mas reciente de su contenido, utiliza la siguiente etiqueta para evitar que sea agregada a la cache de Turbolinks:

<meta name=“turbolinks-cache-control” content=“no-cache” />

Final

Así seria el ejemplo final de la aplicación.

settings.py

PIPELINE = {
  'JAVASCRIPT': {
    'plugins': {
       'source_filenames': (
         'js/plugins/plugin-a.js',
         'js/plugins/plugin-b.js',
       ),
      'extra_context': {'defer': True},
      'output_filename': 'js/plugins.js',
    },
    'editor': {
      'source_filenames': (
        'js/editor/editor-deps.js',
        'js/editor/editor.js',
      ),
      'extra_context': {'async': True},
      'output_filename': 'js/editor.js'
    },
    'app': {
      'source_filenames': (
        'js/application.js',
      ),
      'output_filename': 'js/app.js'
    },
    ...

A como puedes notar plugins es cargado de forma defer por que no necesito que estén disponibles antes de que todo el documento termine de ser renderizado, en comparación con editor el cual es async puesto que necesito que este disponible antes de que el documento termine y que sera inicializado dentro de app.

Tag <head>

<head>
  <meta charset="utf-8" />
  <title>{% block title %}{% endblock %}</title>
  ...
  {% stylesheet "theme" %}
  {% javascript "editor" %}
  {% javascript "turbolinks" %}
  {% javascript "app" %}
  {% javascript "plugins" %}
</head>

Todos los estáticos de la aplicación son cargados en la cabecera y en vez de usar el template tag {% static '<path>' %} utilizamos los tags de Django-Pipelines para que tengan el fingerprint y sean minificados en el deploy.

Middleware

# Taken from: https://github.com/dgladkov/django-turbolinks/blob/master/turbolinks/middleware.py#L11  # NOQA
def same_origin(current_uri, redirect_uri):
    a = urlparse(current_uri)
    if not a.scheme:
        return True
    b = urlparse(redirect_uri)
    return (a.scheme, a.hostname, a.port) == (b.scheme, b.hostname, b.port)


class TurbolinksMiddleware:
    ''' Send the `Turbolinks-Location` header in response to a visit
    that was redirected, and Turbolinks will replace the browser’s topmost
    history entry.
    Taken from: https://github.com/viewflow/django-material/blob/v2/material/middleware.py#L38  # NOQA
    Note: This is needed to handle redirects with TurboLinks.
    '''

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        turbolinks_referrer = request.META.get('HTTP_TURBOLINKS_REFERRER')
        is_response_redirect = response.has_header('Location')
        if turbolinks_referrer:
            if is_response_redirect:
                location = response['Location']
                prev_location = request.session.pop(
                    '_turbolinks_redirect_to', None
                )
                if prev_location is not None:
                    # relative subsequent redirect
                    if location.startswith('.'):
                        location = prev_location.split('?')[0] + location
                request.session['_turbolinks_redirect_to'] = location
                # cross domain blocker
                if not same_origin(location, turbolinks_referrer):
                    return HttpResponseForbidden()
            else:
                if request.session.get('_turbolinks_redirect_to'):
                    location = request.session.pop('_turbolinks_redirect_to')
                    response['Turbolinks-Location'] = location
        return response

App.js

$(document).on('turbolinks:load', function() {
  ...
  $.App.init();
  ...
  $.onmount();
});
...
/* Enable plugin a */
$.onmount('.my-plugin', function () {
  $(this).plugin_a();
});

Y finalizamos inicializando onmount cada vez que Turbolinks cargue una pagina, para que se activen los plugins y/o eventos según los elementos que se han renderizado.

Concluciones

Espero que este post te haya servido de utilidad y te ahorre unas cuantas horas de búsqueda en el internet, esta forma nos ha funcionado para mejorar la experiencia de nuestra aplicación, sin necesidad de tener que separar el Back-End de el Front-End.

Notas

  • Revisa siempre la forma en como Turbolinks renderiza una vista que ya ha sido guardada en la cache, puede ser que algunos plugins (tooltips por ejemplo) se hayan quedado inicializados.
  • Si tienes la oportunidad y el tiempo, es mejor que dividas tu javascript en chunks y lo escribas de una forma mas modular o moderna.
  • Siendo B tu punto o vista final, siempre pruebalo según estas cuatro formas:

    • De A hacia B.
    • De A hacia B, luego de B hacia A y otra vez de A hacia B.
    • Solo B.
    • Solo B y recargar B.
    • Deja las pruebas de rendimiento para el final.
    • Puedes experimentar con Stimulus.