How To Get Phoenix & VueJS working Together?

Seems to me lots of blogs about Phoenix get into Brunch like it’s somehow an essential part of Phoenix - it isn’t. When it comes to the development workflow there is one loose “connection” to Brunch in my_app/config/dev.exs - the watchers configuration. It contains the command to start up the process responsible for building bundles whenever the primary files change. Phoenix LiveReload is then responsible for supplying the re-built files to the browser via a hidden <iframe> which coordinates the necessary updates in the browser.

Vue with Webpack (instead of Brunch) can make use of the same mechanism. To demonstrate, start with a simple Vue.js component project:

$ tree assets
assets
├── build
│   └── webpack.dev.conf.js
├── images
├── index.html
├── js
├── package.json
└── src
    ├── App.vue
    ├── assets
    │   └── phoenix.png
    └── main.js

assets/index.html:

<!DOCTYPE html>
<html>
  <head>
    <!-- assets/index.html -->
    <meta charset="utf-8">
    <title>Vue.js Demo Page</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="js/app.js"></script>
  </body>
</html>

assets/src/App.vue:

<template>
  <div class="foo">
    <img src="./assets/phoenix.png">
    <h1>{{ msg }}</h1>
    <button id="change-message" @click="changeMessage">Change message</button>
    <p>{{ passedProp }}</p>
  </div>
</template>

<script>
  export default {
    name: 'hello',
    data() {
      return {
        msg: 'Welcome to Your Vue.js App'
      };
    },
    props: ['passedProp'],
    methods: {
      changeMessage() {
        this.msg = 'new message';
      }
    }
  };
</script>

<style>
  .foo {
    font-family: 'Avenir', Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    margin-top: 60px;
  }
</style>

assets\src\main.js:

// src/main.js
import Vue from 'vue';
import App from './App';

Vue.config.productionTip = false;

new Vue({
  el: '#app',
  template: '<App passedProp="Greetings!" />',
  components: { App }
});

assets/build/webpack.dev.conf.js:

// build/web.config.dev.js
const path = require('path');
const webpack = require('webpack');
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin');

const PATHS = {
  assetsRoot: '/',
  assetsSubDirectory: '/'
};

function assetsPath(filePath) {
  return path.posix.join(PATHS.assetsSubDirectory, filePath);
}

function resolve (dir) {
  return path.join(__dirname, '..', dir);
}

module.exports = {
  entry: {
    app: './src/main.js'
  },
  output: {
    filename: 'js/[name].js',
    path: resolve(PATHS.assetsRoot)
  },
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: {
      'vue$': 'vue/dist/vue.esm.js',
      '@': resolve('src')
    }
  },
  module: {
    rules :[
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        query: {
          presets: ['env']
        },
        include:[resolve('src')]
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 20000,
          name: assetsPath('images/[name].[hash:7].[ext]')
        }
      }
    ]
  },
  // cheap-module-eval-source-map is faster for development
  devtool: '#cheap-module-eval-source-map',
  plugins: [
    new webpack.NoEmitOnErrorsPlugin(),
    new FriendlyErrorsPlugin()
  ],
  watchOptions: {
    ignored: /node_modules/
  }
};

assets/package.json:

{
  "name": "assets",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "devbuild": "node ./node_modules/webpack/bin/webpack.js  --config ./build/webpack.dev.conf.js",
    "watch": "./node_modules/.bin/webpack --watch-stdin --config ./build/webpack.dev.conf.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-preset-env": "^1.6.0",
    "css-loader": "^0.28.7",
    "file-loader": "^1.1.5",
    "friendly-errors-webpack-plugin": "^1.6.1",
    "url-loader": "^0.6.2",
    "vue-loader": "^13.0.5",
    "vue-style-loader": "^3.0.3",
    "vue-template-compiler": "^2.4.4",
    "webpack": "^3.6.0"
  },
  "dependencies": {
    "vue": "^2.4.4"
  }
}

Check this project with:

assets $ npm i

...
added 623 packages in 10.714s

assets $ npm run watch

> assets@1.0.0 watch /Users/wheatley/sbox/vue/phx/assets
> webpack --watch-stdin --config ./build/webpack.dev.conf.js


Webpack is watching the files…
...
^C assets $

At this point it should be possible to view assets/index.html through a browser from the filesystem.

Now create a new Phoenix 1.3 project:

assets $ cd ..
       $ mix phx.new my_app --no-brunch --no-ecto

and create a new directory my_app/assets and copy the following files into it:

my_app/assets
├── build
│   └── webpack.dev.conf.js
├── package.json
└── src
    ├── App.vue
    ├── assets
    │   └── phoenix.png
    └── main.js

Modify my_app/assets/build/webpack.dev.conf.js

// build/web.config.dev.js
const path = require('path');
const webpack = require('webpack');
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin');

const PATHS = {
  assetsRoot: '../priv/static', // CHANGED
  assetsSubDirectory: '/'
};

function assetsPath(filePath) {
  return path.posix.join(PATHS.assetsSubDirectory, filePath);
}

function resolve (dir) {
  return path.join(__dirname, '..', dir);
}

module.exports = {
  entry: {
    app: './src/main.js'
  },
  output: {
    filename: 'js/[name].js',
    path: resolve(PATHS.assetsRoot)
  },
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: {
      'phoenix': resolve('../deps/phoenix/priv/static/phoenix.js'),                // ADDED
      'phoenix_html': resolve('../deps/phoenix_html/priv/static/phoenix_html.js'), // ADDED
      'vue$': 'vue/dist/vue.esm.js',
      '@': resolve('src')
    }
  },
  module: {
    rules :[
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        query: {
          presets: ['env']
        },
        include:[resolve('src')]
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 20000,
          name: assetsPath('images/[name].[hash:7].[ext]')
        }
      }
    ]
  },
  // cheap-module-eval-source-map is faster for development
  devtool: '#cheap-module-eval-source-map',
  plugins: [
    new webpack.NoEmitOnErrorsPlugin(),
    new FriendlyErrorsPlugin()
  ],
  watchOptions: {
    ignored: /node_modules/
  }
};

Note the modified PATHS.assetsRoot and the additions to resolve.alias.

Modify my_app/assets/src/main.js:

// src/main.js
import Vue from 'vue';
import App from './App';

import 'phoenix_html';            // ADDED
// import socket from "./socket";

Vue.config.productionTip = false;

new Vue({
  el: '#app',
  template: '<App passedProp="Greetings!" />',
  components: { App }
});

Install the supporting packages:

assets $ npm i
...
added 623 packages in 11.886s

Give it a quick check:

$ npm run watch

> assets@1.0.0 watch /Users/wheatley/sbox/vue/phx/my_app/assets
> webpack --watch-stdin --config ./build/webpack.dev.conf.js


Webpack is watching the files…
...
^C assets $ 

Now modify watchers in my_app/config/dev.exs:

config :my_app, MyAppWeb.Endpoint,
  http: [port: 4000],
  debug_errors: true,
  code_reloader: true,
  check_origin: false,
  watchers: [node: ["node_modules/webpack/bin/webpack.js", # CHANGED
                  "--watch-stdin",
                  "--config", "./build/webpack.dev.conf.js",
                  cd: Path.expand("../assets", __DIR__)]
            ]

to kick off Webpack watching the frontend source files.

The same thing can be accomplished by specifying a suitable npm run script, however this rather verbose incantantion is necessary for some MS Windows installations.

Replace my_app/lib/my_app_web/templates/layout/app.html.eex with

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="">

    <title>Hello MyApp!</title>
  </head>

  <body>
    <%= render @view_module, @view_template, assigns %>
    <script src="<%= static_path(@conn, "/js/app.js") %>"></script>
  </body>
</html>

and my_app/lib/my_app_web/templates/page/index.html.eex with

<div id="app"></div>

Finally start Phoenix

assets $ cd ..
my_app $ mix phx.server
Compiling 12 files (.ex)
Generated my_app app
[info] Running MyAppWeb.Endpoint with Cowboy using http://0.0.0.0:4000

Webpack is watching the files…

 DONE  Compiled successfully in 924ms20:40:49
...
  [14] ../deps/phoenix_html/priv/static/phoenix_html.js 1.23 kB {0} [built]
    + 6 hidden modules

Now open the browser at http://localhost:4000/ to see the “Hello MyApp!” page.

If you modify the message text in my_app/assets/src/App.vue Phoenix LiveReloader will update the page in the browser shortly after you save the file (and the bundle has been rebuilt).

With a basic setup like this it should be possible to go further by “scavenging” features from other non-Phoenix Vue “donor” projects like the ones generated by vue-cli, e.g. $ vue init webpack my-project.

8 Likes