En este artÃculo explicaremos como integrar Django con WebPack y como administrar toda la paqueterÃa JavaScript mediante Yarn, este modo de operar nos brindará una serie de ventajas respecto al desarrollo tradicional ya que WebPack nos permitirá reutilizar código entre módulos JavaScript, un sistema de cacheo inteligente y carga de contenido bajo demanda de partes del código JavaScript.
Antes de comenzar es recomendable leer los artÃculos anteriores sobre Django ya que son los pasos previos a este artÃculo:
- Django: Venv bajo FreeBSD
- Django: MVT, Apps y URLs
- Django: Modelos de base de datos
- Django: Interfaz de administración
- Django: DTL(Django Template Language)
- Django: Debug Toolbar
- Django: Registro y autenticación de usuarios
La integración de Django con WebPack implica varias partes:
- WebPack: Empaquetador de contenido.
- Yarn: Administrador de paquetes JavaScript, se jacta de ser más rápido, seguro y confiable que NPM.
- Webpack-bundle-tracker: Plugin de WebPack encargado de generar el fichero webpack-stats.json donde se indica toda la información necesaria para que el paquete django-webpack-loader pueda hacer las sustituciones necesarias en los templates de Django por los nombres de los ficheros de los bundles.
- Django-webpack-loader: Paquete que leerá el fichero webpack-stats.json y realizará las sustituciones necesarias en los templates por los nombres de los ficheros de los bundles.
WebPack es un empaquetador de contenido web, empaqueta tanto assets como código JavaScript, la salida final es un único fichero JS, de este modo importando este fichero podremos importar hojas de estilo y código JS.
También permite “code splitting” y “lazy loading”, asà se cargarán las partes del contenido que se pida en ese momento en respuesta a ciertas acciones del usuario.
Por otra parte Webpack resulta muy útil para evitar problemas de cacheo de contenido, los ficheros son generados con un único hash como nombre del fichero, de este modo cuando se realizan cambios en la web se regeneran dichos hashes, estos son distintos a los hashes previos, por lo tanto un cliente que recargue la web estará pidiendo otro fichero distinto y la caché del navegador será descartada mostrando el contenido actualizado.
Para utilizar assets en los templates de Django hay que hacerlo por su nombre, el problema es que WebPack genera los ficheros hasheados como se ha descrito anteriormente, para poder utilizar los ficheros generados por WebPack de forma transparente utilizaremos el plugin webpack-bundle-tracker y el paquete django-webpack-loader que hará las sustituciones oportunas en nuestros templates con los nombres de los ficheros del último bundle generado. webpack-bundle-tracker genera toda la información que necesita django-webpack-loader en un fichero llamado webpack-stats.json.
Antes de empezar debemos tener en cuenta ciertos aspectos sobre WebPack:
- Para generar el árbol de dependencias de la aplicación Webpack necesita un punto de partida, un fichero JS que leerá para construir el árbol de dependencias, a este JS se le llama entry point.
- Mediante la definición de varios entry points lograremos partir nuestro código en partes mas pequeñas, según la parte de la web que estemos visitando cargaremos unos bundles u otros haciendo asà la navegación mas eficiente.
- Los loaders son extensiones de terceros que ayudan a Webpack a tratar con varios tipos de ficheros como CSS, imágenes y ficheros de texto entre otros, el objetivo de los loaders es transformar los ficheros en módulos, una vez transformados Webpack ya puede emplearlos como dependencias del proyecto.
- Webpack también puede utilizar lo que llama Plugins, estos son extensiones que modifican el comportamiento de Webpack por ejemplo para que haga unas acciones u otras según el entorno prod/dev.
- Existen dos modos de operación, desarrollo y producción, la diferencia principal entre ambos modos es que en producción se aplica la minificación y otras optimizaciones del código JS.
Yarn es un nuevo administrador de paquetes JavaScript, es compatible con NPM pero con la ventaja de que opera más rápido, de forma más segura y más confiable.
Instalamos NPM(root):
Instalamos yarn de forma global(root):
Los administradores de paquetes JS precisan de un fichero de configuración package.json para funcionar este indica los comandos disponibles, versiones y demás información asociada a los paquetes, lo generamos mediante el siguiente comando:
yarn init -y
yarn init v1.22.10
warning The yes flag has been set. This will automatically answer yes to all questions, which may have security implications.
success Saved package.json
Done in 0.02s.
El parámetro -y indica que deseamos generar el fichero package.json con los parámetros por defecto.
Nos habrá creado el fichero con el nombre de proyecto rxWodProject, si utilizamos VSCode como editor de código, esto emitirá un warning, para evitarlo lo cambiamos a minúsculas:
 "name": "rxwodproject",
Como ya hemos comentado webpack-bundle-tracker es el plugin de WebPack encargado de generar el fichero webpack-stats.json donde se indica toda la información necesaria para que el paquete django-webpack-loader pueda hacer las sustituciones necesarias en los templates de Django por los nombres de los ficheros de los bundles.
Instalamos WebPack, webpack-cli, webpack-bundle-tracker y progress-bar-webpack-plugin:
yarn add webpack webpack-cli webpack-bundle-tracker progress-bar-webpack-plugin
La última versión de webpack-bundle-tracker genera el fichero webpack-stats.json con una sintaxis que django-webpack-loader es incapaz de entender, al intentar cargar los datos muestra el siguiente error:
TypeError: string indices must be integers
Afortunadamente django-webpack-loader es
extremadamente flexible
y permite la carga de datos de diferentes maneras y fuentes tan solo debemos extender la clase webpack_loader.loader.WebpackLoader, esto nos permite cargar las stats desde un fichero de texto, una base de datos o cualquier otra fuente de datos, en mi caso tan solo modificamos ligeramente la clase para que cargue los datos con la nueva sintaxis del fichero webpack-stats.json.
vi fixes/webpack.py
from webpack_loader.loader import WebpackLoader
class CustomWebpackLoader(WebpackLoader):
def filter_chunks(self, chunks):
chunks = [chunk if isinstance(chunk, dict) else {'name': chunk} for chunk in chunks]
return super().filter_chunks(chunks)
NOTA: Hay foros en los que recomiendan downgradear la versión de webpack-bundle-tracker pero entonces obtendremos mensajes sobre deprecations:
(node:44658) [DEP_WEBPACK_DEPRECATION_ARRAY_TO_SET] DeprecationWarning: Compilation.chunks was changed from Array to Set (using Array method ‘map’ is deprecated)
Pienso que la mejor opción es este parche hasta que los desarrolladores fixeen el bug.
Creamos el directorio assets donde residirá nuestro código fuente JS y los ficheros generados por WebPack:
vi assets/js/index.js
alert("Testing WebPack and Django from AlfaExploit!");
Generamos una configuración de WebPack básica donde empleamos el plugin webpack-bundle-tracker y le indicamos un único fichero entry:
var path = require("path")
var webpack = require('webpack')
var BundleTracker = require('webpack-bundle-tracker')
var ProgressBarPlugin = require('progress-bar-webpack-plugin');
module.exports = {
mode: "production",
context: __dirname,
entry: {
index: './assets/js/index.js',
},
output: {
path: path.resolve('./assets/bundles/'),
publicPath: '/static/',
filename: "[name]-[fullhash].js",
},
plugins: [
new BundleTracker({filename: '../../webpack-stats.json'}),
new ProgressBarPlugin(),
],
}
NOTA: La misma configuración sirve para dev pero debemos definir mode: “development”.
Nuestro proyecto tendrá la siguiente estructura de ficheros/directorios:
|-- assets
|-- db.sqlite3
|-- fixes
|-- manage.py
|-- node_modules
|-- package.json
|-- requirements.txt
|-- rxWod
|-- rxWodProject
|-- usersAuth
|-- webpack.dev.config.js
|-- webpack.prod.config.js
`-- yarn.lock
Django gestiona los assets mediante su configuración en el fichero settings.py, primero importamos la librerÃa os ya que nos hará falta y añadimos webpack_loader como app de Django.
import os
...
INSTALLED_APPS = [
...
'webpack_loader',
]
Luego configuramos los parámetros relacionados con WebPack:
- STATIC_URL: Url desde la que colgará todo nuestro contenido estático, por ejemplo si queremos acceder a una imagen accederemos a http://DOMAIN_NAME/STATIC_URL/file.png
- STATICFILES_DIRS: Directorio donde se encuentra el contenido estático generado por WebPack, cuando se ejecute la orden collectstatic se leerán los ficheros de este directorio y se copiarán al path configurado en STATIC_ROOT.
- STATIC_ROOT: Directorio donde se copiará todo el contenido estático del proyecto cuando se ejecute la orden collectstatic, este contenido estará disponible bajo la Url: http://DOMAIN_NAME/STATIC_URL/, este paso solo es necesario cuando pasemos nuestro proyecto a producción y utilicemos algún servidor web como Apache/Nginx, ahora por ahora tan solo lo dejamos configurado.
- WEBPACK_LOADER ->
BUNDLE_DIR_NAME
: Directorio donde encontrar los bundles de WebPack, como STATICFILES_DIRS ya apunta directamente a ese directorio debemos dejarlo vacÃo.
NOTA: If your webpack config outputs the bundles at the root of your staticfiles dir, then BUNDLE_DIR_NAME should be an empty string ‘’, not ‘/’. - WEBPACK_LOADER -> LOADER_CLASS: Cargamos nuestra clase parcheada, fixes.webpack.CustomWebpackLoader.
- WEBPACK_LOADER -> STATS_FILE: Indicamos el nombre del fichero que nos generará el módulo webpack-bundle-tracker.
NOTA: Con el servidor integrado con Django no es necesario ejecutar el comando collectstatic ya que servirá los bundles directamente desde el directorio STATICFILES_DIRS.
STATIC_URL = '/static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'assets/bundles/'),
)
STATIC_ROOT = '/usr/local/www/rxWod/static/'
WEBPACK_LOADER = {
'DEFAULT': {
'BUNDLE_DIR_NAME': '',
'LOADER_CLASS': 'fixes.webpack.CustomWebpackLoader',
'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json'),
}
}
NOTA: A fecha de 6/08/2021 ya se ha parcheado el bug de webpack-bundle-tracker por lo tanto los ficheros de configuración quedarÃan del siguiente modo:
var path = require("path")
var webpack = require('webpack')
var BundleTracker = require('webpack-bundle-tracker')
var ProgressBarPlugin = require('progress-bar-webpack-plugin');
module.exports = {
mode: "production",
context: __dirname,
entry: {
index: './assets/js/index.js',
},
output: {
path: path.resolve('./assets/bundles/'),
publicPath: '/static/',
filename: "[name]-[fullhash].js",
},
plugins: [
new BundleTracker({filename: 'webpack-stats.json'}),
new ProgressBarPlugin(),
],
}
Ha cambiado el path del BundleTracker -> filename.
STATIC_URL = '/static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'assets/bundles/'),
)
STATIC_ROOT = '/usr/local/www/rxWod/static/'
WEBPACK_LOADER = {
'DEFAULT': {
'BUNDLE_DIR_NAME': '',
'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json'),
}
}
En la configuración de WEBPACK_LOADER ya no es necesario indicar un LOADER_CLASS.
Además cabe remarcar que ya no es necesario el parche fixes/webpack.py, por lo tanto eliminamos el directorio:
Definimos los comandos para compilar los bundles de WebPack, primero eliminamos ficheros generados por compilaciones anteriores y luego lanzamos una compilación nueva, en el caso de dev además lanzamos el servidor integrado de Django:
{
"name": "rxwodproject",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"webpack": "^5.24.4",
"webpack-bundle-tracker": "^1.0.0-alpha.1",
"webpack-cli": "^4.5.0"
},
"scripts": {
"prod-build": "rm -rf assets/bundles/*; yarn webpack --config ./webpack.prod.config.js",
"dev-build": "rm -rf assets/bundles/*; yarn webpack --config ./webpack.dev.config.js; python manage.py runserver",
"watch": "rm -rf assets/bundles/*; yarn webpack --watch --config ./webpack.dev.config.js"
}
}
Instalamos el plugin django-webpack-loader y congelamos las dependencias:
pip freeze > requirements.txt
Compilamos los bundles con WebPack:
Podemos dejar WebPack en modo watch para que esté recompilando continuamente los bundles en cuanto se detecten cambios en los ficheros entry o sus dependencias, pero si cambiamos la configuración de WebPack debemos relanzar el comando con el watch.
Nos habrá generado un fichero:
assets/bundles/index-[hash].js
total 6
drwxr-xr-x  2 kr0m  kr0m   3 Mar 10 22:27 .
drwxr-xr-x  4 kr0m  kr0m   4 Mar 10 22:08 ..
-rw-r--r--  1 kr0m  kr0m  1305 Mar 10 22:27 index-b68ca7fe24923105061f.js
WebPack ya está funcionando ahora queda utilizar los bundles generados en los templates de Django, para ello vamos a emplear django-webpack-loader , este paquete leerá la información del fichero webpack-stats.json para cargar los ficheros de los bundles desde el template de Django.
Si visualizamos el contenido del fichero webpack-stats.json podremos ver la correspondencia entre el nombre del entry point y el bundle generado.
entry: {
index: './assets/js/index.js',
},
{"status":"done","chunks":{"index":["index-b68ca7fe24923105061f.js"]},"publicPath":"/static/","assets":{"index-b68ca7fe24923105061f.js":{"name":"index-b68ca7fe24923105061f.js","publicPath":"/static/index-b68ca7fe24923105061f.js"}}}
Cuando hagamos referencia a index en nuestro template estaremos cargando el fichero index-b68ca7fe24923105061f.js
Modificamos nuestro template para que cargue nuestro código JS:
{% extends 'rxWod/base.html' %}
{% load render_bundle from webpack_loader %}
{% block base_body %}
{% if routines %}
<ul>
{% for routine in routines %}
<li><a>{{ routine.date }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>No routines available.</p>
{% endif %}
{% render_bundle 'index' %}
{% endblock %}
Arrancamos el servidor:
Podemos ver el template y el mensaje emitido vÃa JS:
Django automáticamente utilizará el último bundle generado, para comprobarlo podemos hacer un cambio en el código JS:
alert("Second test: Testing WebPack and Django from AlfaExploit!");
Recompilamos con los bundles:
Comprobamos que se ha generado la versión nueva del fichero:
total 6
drwxr-xr-x  2 kr0m  kr0m   3 Mar 10 22:27 .
drwxr-xr-x  4 kr0m  kr0m   4 Mar 10 22:08 ..
-rw-r--r--  1 kr0m  kr0m  1316 Mar 10 22:55 index-b4463486d2c11cf7787f.js
Arrancamos el servidor:
Accedemos a la web y comprobamos que ha actualizado correctamente el mensaje sin tener que tocar el template:
Para poder utilizar CSS en los templates de Django debemos importarlos dentro de nuestro código JS pero para poder hacer esto tendremos que utilizar los loaders de WebPack: css-loader y style-loader .
El primero permite al JS cargar el contenido del CSS y el segundo permite mostrarlo en el DOM del navegador.
Editamos el fichero webpack.ENV.config.js y añadimos la sección module, donde indicamos que cuando encuentre un fichero CSS debe utilizar los dos loaders:
var path = require("path")
var webpack = require('webpack')
var BundleTracker = require('webpack-bundle-tracker')
var ProgressBarPlugin = require('progress-bar-webpack-plugin');
module.exports = {
mode: "development",
context: __dirname,
entry: {
index: './assets/js/index.js',
},
output: {
path: path.resolve('./assets/bundles/'),
publicPath: '/static/',
filename: "[name]-[fullhash].js",
},
plugins: [
new BundleTracker({filename: '../../webpack-stats.json'}),
new ProgressBarPlugin(),
],
module:{
rules:[
{
test: /\.css$/i,
use:['style-loader','css-loader']
}
]
},
}
Generamos el fichero CSS con el siguiente contenido:
vi assets/css/index.css
body {
background-color: lightblue;
}
Importamos el CSS desde el código JS:
import '../css/index.css';
alert("Third test: Testing WebPack and Django from AlfaExploit!");
Recordemos que nuestro template tiene el siguiente aspecto:
{% extends 'rxWod/base.html' %}
{% load render_bundle from webpack_loader %}
{% block base_body %}
{% if routines %}
<ul>
{% for routine in routines %}
<li><a>{{ routine.date }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>No routines available.</p>
{% endif %}
{% render_bundle 'index' %}
{% endblock %}
Recompilamos mediante WebPack:
Arrancamos el servidor:
Accedemos a la web y comprobamos que hemos cargado el código JS y nuestro CSS:
Además si miramos los ficheros descargados desde la web podemos ver un único JS, el resto de ficheros forman parte de la
debug toolbar de Django
asà que podemos ignorarlos:
Podemos cargar solo la parte JS/CSS indicándolo como segundo parámetro en el comando render_bundle del template, pero para ello tendremos que generar el fichero CSS mediante el plugin
mini-css-extract-plugin
.
vi webpack.ENV.config.js
var path = require("path")
var webpack = require('webpack')
var BundleTracker = require('webpack-bundle-tracker')
var ProgressBarPlugin = require('progress-bar-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
mode: "development",
context: __dirname,
entry: {
index: './assets/js/index.js',
},
output: {
path: path.resolve('./assets/bundles/'),
publicPath: '/static/',
filename: "[name]-[fullhash].js",
},
plugins: [
new BundleTracker({filename: '../../webpack-stats.json'}),
new ProgressBarPlugin(),
new MiniCssExtractPlugin({
filename: '[name]-[hash].css',
}),
],
module:{
rules:[
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
}
]
},
}
NOTA: Al utilizar MiniCssExtractPlugin ya no será necesario el loader style-loader
Recompilamos mediante WebPack:
Ha generado el fichero CSS:
total 6
drwxr-xr-x  2 kr0m  kr0m   4 Mar 10 22:27 .
drwxr-xr-x  5 kr0m  kr0m   5 Mar 10 22:02 ..
-rw-r--r--  1 kr0m  kr0m   43 Mar 10 23:27 index-94788942e470c8727cee.css
-rw-r--r--  1 kr0m  kr0m  3409 Mar 10 23:27 index-94788942e470c8727cee.js
Hagamos la prueba:
{% extends 'rxWod/base.html' %}
{% load render_bundle from webpack_loader %}
{% block base_body %}
{% if routines %}
<ul>
{% for routine in routines %}
<li><a>{{ routine.date }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>No routines available.</p>
{% endif %}
{% render_bundle 'index' 'css' %}
{% endblock %}
Como vemos solo ha cargado el CSS, no ha aparecido la ventana de alert del código JS y tan solo se ha bajado el fichero CSS:
Hagamos que solo cargue el código JS:
{% extends 'rxWod/base.html' %}
{% load render_bundle from webpack_loader %}
{% block base_body %}
{% if routines %}
<ul>
{% for routine in routines %}
<li><a>{{ routine.date }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>No routines available.</p>
{% endif %}
{% render_bundle 'index' 'js' %}
{% endblock %}
No hace falta recompilar ya que no hemos cambiado nada del JS/CSS.
Accedemos a la web y como vemos solo ha cargado el JS, sin aplicar el CSS:
Podemos ver que se ha bajado el fichero JS:
Si dejamos el extractor de CSS habilitado y no indicamos nada en el comando render_bundle, se cargará tanto el código JS como el contenido CSS pero se bajará dos ficheros y no uno solo como ocurrÃa antes de habilitar el extractor:
{% extends 'rxWod/base.html' %}
{% load render_bundle from webpack_loader %}
{% block base_body %}
{% if routines %}
<ul>
{% for routine in routines %}
<li><a>{{ routine.date }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>No routines available.</p>
{% endif %}
{% render_bundle 'index' %}
{% endblock %}
Normalmente el CSS se suele cargar al principio de la sección head:
<head>
{% render_bundle 'main' 'css' %}
Y el código JS al final del body:
{% render_bundle 'main' 'js' %}
</body>
Si necesitamos utilizar algún asset como una imagen podemos utilizar el loader file-loader y el tag webpack_static en los templates.
Instalamos el loader:
Pero hay un problema y es que webpack_static debe estar bugeado y no carga los ficheros de imágenes hasheados, siempre hace referencia al nombre original sin la parte del hash, la única solución que yo he encontrado es no hashearlos indicándole al file-loader que guarde los ficheros con el nombre original name: ‘[name].[ext]’:
var path = require("path")
var webpack = require('webpack')
var BundleTracker = require('webpack-bundle-tracker')
var ProgressBarPlugin = require('progress-bar-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
mode: "development",
context: __dirname,
entry: {
index: './assets/js/index.js',
},
output: {
path: path.resolve('./assets/bundles/'),
publicPath: '/static/',
filename: "[name]-[fullhash].js",
},
plugins: [
new BundleTracker({filename: '../../webpack-stats.json'}),
new ProgressBarPlugin(),
new MiniCssExtractPlugin({
filename: '[name]-[hash].css',
}),
],
module:{
rules:[
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
{
test: /\.(png|jpe?g|gif)$/i,
loader: 'file-loader',
options: {
name: '[name].[ext]',
},
},
]
},
}
Creamos el directorio donde almacenaremos las imágenes y nos bajamos una imagen de ejemplo:
fetch https://static.djangoproject.com/img/logos/django-logo-positive.png -o assets/images/django-logo-positive.png
Importamos la imagen desde nuestro JS:
import '../css/index.css';
import img from '../images/django-logo-positive.png';
alert("Third test: Testing WebPack and Django from AlfaExploit!");
Compilamos mediante WebPack:
Nuestro template quedará del siguiente modo:
{% extends 'rxWod/base.html' %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
{% block base_body %}
{% if routines %}
<ul>
{% for routine in routines %}
<li><a>{{ routine.date }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>No routines available.</p>
{% endif %}
{% render_bundle 'index' %}
<img src="{% webpack_static 'django-logo-positive.png' %}"/>
{% endblock %}
Podemos ver como nos muestra la imagen:
Podemos ver los elementos cargados:
El único inconveniente es que al no generar el fichero hasheado, perdemos la posibilidad de descartar el cacheo de imágenes del navegador pero para los JS/CSS seguirá funcionando.