Photo by Markus Spiske on Unsplash

Yes, there is Vueify, and we’ve used it some. However, it doesn’t seem to be maintained any longer and you can’t use it with the latest Babel. It’s pretty clear that the bulk of the community is using Webpack, so why not join them?

Since Webpack will generate bundles with hashed names that serve to bust cache and Django renders templates server side, we have to figure out a way to marry the two. Django can’t predict the file names in order to render a {% static %} template tag. Webpack can’t modify a plain HTML file like many tutorials show it doing.

django-webpack-loader

There is a cool app by Owais Lone called django-webpack-loader that reads in the manifest file that Webpack produces and renders the appropriate tags in your template. This is the not-so-secret sauce for getting Django and Webpack working well together.

There is plenty online about using this app so I won’t dive into detail other than to share the setup that is working well for us for building great Vue applications.

Quickly, here is what we do to setup of django-webpack-loader:

First we install django-webpack-loader using pipenv:

pipenv install django-webpack-loader

Next, we use use {% render_bundle %} in our main template:

{% load render_bundle from webpack_loader %}

<html>
    <head>
        {% render_bundle "vendor" "css" %}
        {% render_bundle "main" "css" %}
    </head>

    <body>
        <div id="app"></div>
        {% render_bundle "vendor" "js" %}
        {% render_bundle "main" "js" %}
    </body>
</html>

Lastly, we add some things to our settings.py:

PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))

STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"

MIDDLEWARE.insert(0, "whitenoise.middleware.WhiteNoiseMiddleware")

INSTALL_APPS.append("webpack_loader")

WEBPACK_LOADER = {
    "DEFAULT": {
        "CACHE": not DEBUG,
        "BUNDLE_DIR_NAME": "/",
        "STATS_FILE": os.path.join(PROJECT_ROOT, "webpack-stats.json"),
        "POLL_INTERVAL": 0.1,
        "TIMEOUT": None,
        "IGNORE": [".*\.hot-update.js", ".+\.map"]
    }
}

That takes care of the Django side of the setup. Now for installing and configuring Webpack and our npm scripts.

Install Frontend Tools

Let’s install some things assuming you already have a package.json (if not, go ahead an init one):

# Vue
npm i vue
npm i @vue/test-utils --save-dev

# Babel
npm i @babel/core @babel/plugin-proposal-object-rest-spread @babel/preset-env --save-dev

# ESLint
npm i eslint eslint-plugin-babel --save-dev

# SASS and CSS Tools
npm i node-sass autoprefixer browserslist --save-dev

# Webpack
npm i webpack webpack-bundle-analyzer webpack-bundle-tracker webpack-cli webpack-dev-server --save-dev

# Loaders and Plugins for Webpack
npm i babel-loader clean-webpack-plugin copy-webpack-plugin css-hot-loader css-loader file-loader imports-loader mini-css-extract-plugin optimize-css-assets-webpack-plugin postcss-loader sass-loader style-loader uglifyjs-webpack-plugin vue-loader vue-style-loader vue-template-compiler --save-dev

Phew, that’s a lot of stuff to install. But, everything has its purpose.

Webpack Configuration

Now, let’s build a webpack.config.js to use all these loaders and plugins:

const path = require('path');
const webpack = require('webpack');
const autoprefixer = require('autoprefixer');

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const BundleTracker = require('webpack-bundle-tracker');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const SentryCliPlugin = require('@sentry/webpack-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const { VueLoaderPlugin } = require('vue-loader');

const devMode = process.env.NODE_ENV !== 'production';
const hotReload = process.env.HOT_RELOAD === '1';

const vueRule = {
  test: /\.vue$/,
  use: 'vue-loader',
  exclude: /node_modules/
};

const styleRule = {
  test: /\.(sa|sc|c)ss$/,
  use: [
    MiniCssExtractPlugin.loader,
    { loader: 'css-loader', options: { sourceMap: true } },
    { loader: 'postcss-loader', options: { plugins: () => [autoprefixer({ browsers: ['last 2 versions'] })] } },
    'sass-loader'
  ]
};

const jsRule = {
  test: /\.js$/,
  loader: 'babel-loader',
  include: path.resolve('./static/src/js'),
  exclude: /node_modules/
};

const assetRule = {
  test: /.(jpg|png|woff(2)?|eot|ttf|svg)$/,
  loader: 'file-loader'
};

const plugins = [
  new webpack.ProvidePlugin({
    'window.Sentry': 'Sentry',
    'Sentry': 'Sentry',
    'window.jQuery': 'jquery',
    'jQuery': 'jquery',
    '$': 'jquery'
  }),
  new BundleTracker({ filename: './webpack-stats.json' }),
  new VueLoaderPlugin(),
  new MiniCssExtractPlugin({
    filename: devMode ? '[name].css' : '[name].[hash].css',
    chunkFilename: devMode ? '[id].css' : '[id].[hash].css'
  }),
  new BundleAnalyzerPlugin({ analyzerMode: 'static', openAnalyzer: false }),
  new webpack.HotModuleReplacementPlugin(),
  new CleanWebpackPlugin(['./static/dist']),
  new CopyWebpackPlugin([
    { from: './static/src/images/**/*', to: path.resolve('./static/dist/images/[name].[ext]'), toType: 'template' }
  ])
];

if (devMode) {
  styleRule.use = ['css-hot-loader', ...styleRule.use];
} else {
  plugins.push(
    new webpack.EnvironmentPlugin(['NODE_ENV', 'RAVEN_JS_DSN', 'SENTRY_ENVIRONMENT', 'SOURCE_VERSION'])
  );
  if (process.env.SENTRY_DSN) {
    plugins.push(
      new SentryCliPlugin({
        include: '.',
        release: process.env.SOURCE_VERSION,
        ignore: ['node_modules', 'webpack.config.js'],
      })
    );
  }
}

module.exports = {
  context: __dirname,
  entry: './static/src/js/index.js',
  output: {
    path: path.resolve('./static/dist/'),
    filename: '[name]-[hash].js',
    publicPath: hotReload ? 'http://localhost:8080/' : ''
  },
  devtool: devMode ? 'cheap-eval-source-map' : 'source-map',
  devServer: {
    hot: true,
    quiet: false,
    headers: { 'Access-Control-Allow-Origin': '*' }
  },
  module: { rules: [vueRule, jsRule, styleRule, assetRule] },
  externals: { jquery: 'jQuery', Sentry: 'Sentry' },
  plugins,
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: true,
        parallel: true,
        sourceMap: true // set to true if you want JS source maps
      }),
      new OptimizeCSSAssetsPlugin({})
    ],
    splitChunks: {
      cacheGroups: {
        commons: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          chunks: 'initial',
        },
      }
    }
  },
};

I’ll briefly discuss some of the more interesting parts of this config because it’s good to understand what’s what.

The first thing you’ll notice is this is just JavaScript. Meaning we have the freedom to use language features in building up the configuration hash that is exported from this module.

I take the approach here to break apart some of the rules and plugin definitions so that I can conditionally include them depending on the environment I’m building in (e.g. local development versus production).

The various rules that are defined follow the typical pattern of testing for a filename pattern and if matched, use a loader or some chain of loaders to process the file. I won’t go into much detail here as the webpack documentation does a great job of explaining how these work.

You will notice in the styleRule I’m using the MiniCssExtractPlugin.loader. I use that in combination with the MiniCssExtractPlugin added as a plugin in plugins a few lines below to pull apart the css into its own file.

We use the postcss-loader as part of the style processor to apply autoprefixer processing to our css. This adds vendor prefixes automatically for us so we don’t have to think about that when writing our styles.

For the jsRule it is pretty simple. Let’s just run things through Babel so we can write using latest JavaScript coolness.

For the plugins, we use the built in webpack.ProvidePlugin to define externally loaded globals. In our case, you can see we are loading the Sentry JavaScript library as well as jQuery. If you don’t do this then builds will fail when you code includes references to these globals.

The BundleTracker generates the manifest that django-webpack-loader consumes. Pretty important piece to have.

I like having the CleanWebpackPlugin because it wipes out the dist/ folder. Otherwise, you end up with thousands of old bundles that are worthless.

The CopyWebpackPlugin is used for what you might thing. Copying over static assets to your dist/ location that you might not reference in your CSS/JS so Webpack can’t know about it. You may or may not need this depending on what you are doing.

In development mode, I include the css-hot-loader. In production mode, I pass in certain environment variables that exist at build time to be constants available to my bundled code. This is useful for things like keys and such that you don’t want to hard code.

You can see a bit further down, I also change what source map I use locally versus in production so as to make local development builds faster.

Lastly, I split out all the vendor code into its own bundle using the splitChunks optimization key. This keeps my authored app code separate from vendor code which should generally provide for a better caching scenario. I don’t expect the vendor code and therefore its bundle to change nearly as frequently as the app code.

Scripts

Now it’s time that we add a few scripts to our package.json to make it easier to run things from the command line.

// package.json
{
    "scripts": {
        "start": "HOT_RELOAD=1 webpack-dev-server --mode development",
        "build": "NODE_ENV=production webpack --mode production",
    }
}

The start script is a special one and you don’t have to use the run subcommand to execute it:

npm start

That will fire up the webpack development server and serve the JS and CSS. It updates the webpack-stats.json manifest file so that the django-webpack-loader knows what to serve. It also provides hot reload functionality so that as you edit your frontend code, it smartly rebuilds just what it needs and injects it into the page without reloads. This makes for a wonderful development experience.

The build script will build your assets using production mode and optimize the bundle for serving. We like to do this at deploy time automatically.

Bootstrapping Vue

Just to give you a since for having an entry point for your Vue application, here is a minimal example:

// index.js
import '../scss/site.scss';

import loadApp from './app';

loadApp();
// app.js
import Vue from 'vue';

Vue.config.productionTip = false;

import App from './App.vue';

export default () => {
    /* eslint-disable no-new */
    new Vue({
      el: "#app",
      render: h => h(App)
    });
};

Where App.vue is your top level single file Vue component.

Babel Configuration

Just for completeness, since we are using Babel, I thought it useful, but maybe not entirely relevant, to share our typical babel configuration:

// .babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "browsers": [
            "last 2 versions",
            "safari >= 7",
            "ie >= 8"
          ]
        }
      }
    ]
  ],
  "ignore": [
    "node_modules/",
    "dist/"
  ],
  "plugins": [
    "@babel/plugin-proposal-object-rest-spread"
  ]
}

Using in Local Dev

Now to tie all this together and using this for local development, you’ll want two terminal windows open to run two processes:

Terminal 1:

./manage.py runserver

Terminal 2:

npm start

Then you can fire up your browser to http://localhost:8000 like normal and it will connect to your webpack-dev-server for hot loaded static assets.

Happy Coding!

Presentation on the Scaife Viewer for the Perseus Digital Library Using vue-devtools with Safari and Django