Integrating vue-cli with Django

For the Airavata Django Portal project I recently worked on updating the javascript build scripts from cobbled together Webpack scripts to using vue-cli. There were several advantages to switching to vue-cli:

  • Less idiosyncratic build configuration for the different Django apps. The UI for the Django Portal is broken into several Django apps, each with their own frontend code and with a package of common frontend code. A couple of these were being built in very different ways since they started from very different Webpack templates.
  • Added functionality like integrated linting on save and Hot Module Replacement (HMR). Getting a Vue.js frontend app to build with Webpack is reasonably doable. But adding additional functionality like HMR requires quite a bit of extra work and that work would have to be replicated, with some adjustments, to each Django app. Using vue-cli allows us to get all of the goodies of modern javascript tooling for free.

In this post I’ll recap the issues I ran into and how I solved them. To see the vue-cli configuration that I ended up with, check out the following in one of the Django apps (in this case, the workspace app):

Getting Started

vue-cli has an easy way to create a project from scratch, but I needed to integrate it with existing Vue.js projects. What I did was generate a dummy project in a completely separate folder and then look at what was generated and copy in the necessary bits to the existing Vue.js projects. Here are some things that were different and needed to be copied over:

  • in our old config we were using .babelrc files. vue-cli generates a babel.config.js file (and you don’t want both of them)
  • from the generated package.json file I copied the scripts, devDependencies, the eslintConfig, postcss, and browserslist

webpack-bundle-tracker and django-webpack-loader

There are two basic approaches one could take to integrate the generated Webpack bundles with the backend Django templates:

  1. Generate Webpack bundles with expected file names (so, no cache-busting hashes) the same way for dev and production modes. This way the path to the generated bundle files is known in advance and can be hardcoded in the Django templates. This is what we were doing in Airavata Django Portal before this integration.
  2. Load the Webpack bundles dynamically. That is, figure out what files were generated for a Webpack bundle and load those. Webpack is free to name the files however it needs to; it can even provide URLs to these files if they are dynamically generated as in the case of the dev server.

With the migration to vue-cli I wanted to get the benefits that come with approach #2. To get #2 to work requires generating bundle metadata and a library to load that metadata. Lucky for me those both already exist. webpack-bundle-tracker is a Webpack plugin that will generate a JSON file with the needed bundle metadata and django-webpack-loader is a Django app that provides template tags that can read the bundle metadata and load the appropriate files.

See the linked vue.config.js file above to see how to integrate webpack-bundle-tracker. And see the linked settings.py file above to see how to integrate django-webpack-loader. Once integrated, the bundles can be loaded in the template. See the base.html file above for an example.

To get the bundle loading to work, however, I do need to generate the same set of files in production and development since I need to know which bundles to load in the Django templates. In vue-cli the dev server mode just generates a single javascript file to be loaded but in production mode there are potentially three files generated, one for vendor code, one for common code (if there are multiple entry points) and one for the entry point’s code (and similarly for CSS code). To do this I ran npx vue inspect --mode production and inspected the production chunk configuration:

...
    splitChunks: {
      cacheGroups: {
        vendors: {
          name: 'chunk-vendors',
          test: /[\/]node_modules[\/]/,
          priority: -10,
          chunks: 'initial'
        },
        common: {
          name: 'chunk-common',
          minChunks: 2,
          priority: -20,
          chunks: 'initial',
          reuseExistingChunk: true
        }
      }
    }
...

and then copied this into the appropriate part of the vue.config.js file (see the linked vue.config.js file above).

Local packages

As mentioned above, there are a couple of common packages that the Vue.js frontend code make use of. One is of common UI code and the other is code for making calls to load data from the REST services. These are linked into the Vue.js projects via relative links in the dependencies section of the package.json file:

{
...
  "dependencies": {
...
    "django-airavata-api": "file:../api",
    "django-airavata-common-ui": "file:../../static/common",
...
  },
...
}

For reasons that aren’t entirely clear to me, this caused problems with vue-cli. When running ESLint, for example, vue-cli would complain that it couldn’t find the ESLint config file for these relatively linked packages. I got a similar problem with PostCSS. This comment on issue #2539 gave me the config I needed to force using the project’s ESLint and PostCSS config:

const path = require('path');
module.exports = {
  chainWebpack: config => {
    config.module
      .rule('eslint')
      .use('eslint-loader')
      .tap(options => {
        options.configFile = path.resolve(__dirname, ".eslintrc.js");
        return options;
      })
  },
  css: {
    loaderOptions: {
      postcss: {
        config:{
          path:__dirname
        }
      }
    }
  }
}

Hot Module Replacement

To get HMR working I needed to have the following configuration to allow loading the JS and CSS files from the dev server on a separate port (9000), since I also have the Django server running on localhost on another port (8000):

  devServer: {
    port: 9000,
    headers: {
      "Access-Control-Allow-Origin": "*"
    },
    hot: true,
    hotOnly: true
  }

Other changes

vue-cli doesn’t include the template compiler in the bundle so the entry point cannot include a template string. This meant I needed to change the entry point code to use a render function instead of a template string. For example, instead of

import Vue from 'vue'
import BootstrapVue from 'bootstrap-vue'
import ViewExperimentContainer from './containers/ViewExperimentContainer.vue'

// This is imported globally on the website so no need to include it again in this view
// import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

Vue.use(BootstrapVue);

new Vue({
  el: '#view-experiment',
  template: '<view-experiment-container :initial-full-experiment-data="fullExperimentData" :launching="launching"></view-experiment-container>',
  data () {
      return {
          fullExperimentData: null,
          launching: false,
      }
  },
  components: {
      ViewExperimentContainer,
  },
  beforeMount: function () {
      this.fullExperimentData = JSON.parse(this.$el.dataset.fullExperimentData);
      if ('launching' in this.$el.dataset) {
          this.launching = JSON.parse(this.$el.dataset.launching);
      }
  }
})

I needed this essentially equivalent code that uses a render function instead:

import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import ViewExperimentContainer from "./containers/ViewExperimentContainer.vue";

// This is imported globally on the website so no need to include it again in this view
// import 'bootstrap/dist/css/bootstrap.css'
import "bootstrap-vue/dist/bootstrap-vue.css";

Vue.use(BootstrapVue);

new Vue({
  render(h) {
    return h(ViewExperimentContainer, {
      props: {
        initialFullExperimentData: this.fullExperimentData,
        launching: this.launching
      }
    });
  },
  data() {
    return {
      fullExperimentData: null,
      launching: false
    };
  },
  beforeMount() {
    this.fullExperimentData = JSON.parse(this.$el.dataset.fullExperimentData);
    if ("launching" in this.$el.dataset) {
      this.launching = JSON.parse(this.$el.dataset.launching);
    }
  }
}).$mount("#view-experiment");

One thing I learned in this process is that the vue-template-compiler version needs to be the same as the version of Vue.js, otherwise you get an error like this:

Module build failed (from ./node_modules/vue-loader/lib/index.js):
Error: [vue-loader] vue-template-compiler must be installed as a peer dependency, or a compatible compiler implementation must be passed via options.
    at loadTemplateCompiler (/Users/machrist/Airavata/django/django_airavata_gateway/django_airavata/apps/dataparsers/node_modules/vue-loader/lib/index.js:21:11)
    at Object.module.exports (/Users/machrist/Airavata/django/django_airavata_gateway/django_airavata/apps/dataparsers/node_modules/vue-loader/lib/index.js:65:35)

Just make sure you reference the same version of both as dependencies in package.json.

Conclusion

The dev experience is now better than ever. Just start up the Python server

source venv/bin/activate
python manage.py runserver

Then navigate to the Django app folder and run

npm run serve

Now we have hot module replacement and linting on save.

I think some improvements can still be made. For one, there is still a good bit of boilerplate config that is needed for each Django app. It would be good if it could be shared. Also, I investigated whether there was a webpack-bundle-tracker vue-cli plugin. Turns out there are two, but they don’t quite do what I want. Maybe I’ll make a third one? 🙂

Resources that helped me

Leave a comment

Leave a Reply

%d bloggers like this: