Using Webpack 3.x with Phoenix 1.3

6 minute read

My design skills are wanting. I’m much more focused on backend development. I can cobble together pieces, but I’m far from expert.

I’m really enjoying using more and more Phoenix. For those that don’t know, Phoenix uses Brunch as it’s default way to manage javascript, image, and css assets.

I’m not opposed to Brunch, but I’d need to learn it. I also am not opposed to Webpack, and I need to learn more about that as well.

Given that both are new to me, and neither are completely similar to the Rails asset pipeline, I felt that it was a good time to step back and decide what I wanted to learn.

Right around this time, Rails also embraced Webpack. On top of that, many of my coworkers, who are way more excited about JavaScript than I am, seem to really prefer Webpack.

Webpack and Phoenix

There are a number of tutorials out on the web to get this set up. I thank many of them for giving me the context to figure this out, but like all things, technology moves fast and our blog posts get out of date. Nothing I found worked out of the box with Phoenix 1.3 and Webpack 3.5.5.

The good news, in Phoenix 1.3 the assets folder is at the top level, and is really distinct from how the rest of our application functions.

Getting Started

You can start a new Phoenix app with the --no-brunch flag, or you can remove brunch. I’ll point on the one major difference, but in general, if you already have a brunch assets folder, you can remove it.

The folder structure you’ll want to have at this point in the process should look like:

assets
  \_ js
  \_ css
  \_ static
    \_ images
    \_ robots.txt

Now you can create your JavaScript package file.

{
  "dependencies": {
    "phoenix": "file:../deps/phoenix",
    "phoenix_html": "file:../deps/phoenix_html"
  },
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-plugin-transform-es2015-modules-strip": "^0.1.1",
    "babel-plugin-transform-object-rest-spread": "^6.3.13",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "bootstrap": "^4.0.0-beta",
    "copy-webpack-plugin": "^4.0.1",
    "css-loader": "^0.28.0",
    "extract-text-webpack-plugin": "^3.0.0",
    "import-glob-loader": "^1.1.0",
    "jquery": "^3.2.1",
    "node-sass": "^4.5.2",
    "popper.js": "1",
    "react": "^15.6.1",
    "react-dom": "^15.6.1",
    "sass-loader": "^6.0.3",
    "standard": "^10.0.2",
    "style-loader": "^0.16.1",
    "webpack": "^3.5.5"
  },
  "scripts": {
    "watch": "webpack --watch --color",
    "deploy": "webpack -p"
  }
}

There is a lot in there, but its the tooling I wanted to use on this project.

I recommend you use Yarn to install and manage this stuff.

Now the Webpack config file:

var path = require('path')
var ExtractTextPlugin = require('extract-text-webpack-plugin')
var CopyWebpackPlugin = require('copy-webpack-plugin')
var webpack = require('webpack')
var env = process.env.MIX_ENV || 'dev'
var isProduction = (env === 'prod')

module.exports = {
  entry: {
    'app': ['./js/app.js', './css/app.scss']
  },
  output: {
    path: path.resolve(__dirname, '../priv/static/'),
    filename: 'js/[name].js'
  },
  devtool: 'source-map',
  resolve: {
    extensions: ['.js', '.jsx']
  },
  module: {
    rules: [{
      test: /\.(sass|scss)$/,
      include: /css/,
      use: ExtractTextPlugin.extract({
        fallback: 'style-loader',
        use: [
          {loader: 'css-loader'},
          {
            loader: 'sass-loader',
            options: {
              includePaths: [
                path.resolve('node_modules/bootstrap/scss')
              ],
              sourceComments: !isProduction
            }
          }
        ]
      })
    }, {
      test: /\.(js|jsx)$/,
      include: /js/,
      use: [
        { loader: 'babel-loader' }
      ]
    }]
  },
  plugins: [
    new CopyWebpackPlugin([{ from: './static' }]),
    new ExtractTextPlugin('css/app.css'),
    new webpack.ProvidePlugin({
      $: "jquery",
      jQuery: "jquery",
      "window.jQuery": "jquery",
      Popper: ['popper.js', 'default']
    })
  ]
}

Finally we need to tell Phoenix how to invoke webpack to watch our assets while we develop. Since Phoenix will expect our asset tooling (normally Brunch) to build into the priv/static/ folder, then everything Phoenix does to serve up those files, and hot reload when they change, will still work.

In your config/dev.exs file:

config :appname, AppName.Endpoint,
  http: [port: 4000],
  debug_errors: true,
  code_reloader: true,
  check_origin: false,
  watchers: [yarn: ["run", "watch",
    cd: Path.expand("../assets", __DIR__)]]

If you didn’t include brunch, then the watchers: key will be an empty list, and if you did, then you can just change it to what I have above. If you aren’t using yarn (you should be), then you’ll need to tweak this a bit.

Breakdown of the Webpack file

Since this is the first time I’ve ever really dug deep into Webpack, let’s walk through the config file.

var path = require('path')
var ExtractTextPlugin = require('extract-text-webpack-plugin')
var CopyWebpackPlugin = require('copy-webpack-plugin')
var webpack = require('webpack')
var env = process.env.MIX_ENV || 'dev'
var isProduction = (env === 'prod')

Here we are just importing some things we will need, and setting up the environment, looking for our Elixir mix env, but defaulting to dev. We can use this to selectively do production optimizations like compressing and uglifying.

module.exports = {
  entry: {
    'app': ['./js/app.js', './css/app.scss']
  },
  output: {
    path: path.resolve(__dirname, '../priv/static/'),
    filename: 'js/[name].js'
  },
  devtool: 'source-map',
  resolve: {
    extensions: ['.js', '.jsx']
  },

Here we set up the main entrypoint, the app, and it’s two major files. We will have a js/app.js that will import all other files we need for the app. We will also have a css/app.scss that will import the css and scss for our app.

Then we define where the outputs go, specifying that they should go in the Phoenix priv/static/ folder, and that the application javascript bundle should go into the js folder there, with the entry point name and js file extension.

Then we enable source maps.

Finally we specify the resolve extensions when doing JavaScript imports, so that we can include .jsx files.

  module: {
    rules: [{
      test: /\.(sass|scss)$/,
      include: /css/,
      use: ExtractTextPlugin.extract({
        fallback: 'style-loader',
        use: [
          {loader: 'css-loader'},
          {
            loader: 'sass-loader',
            options: {
              includePaths: [
                path.resolve('node_modules/bootstrap/scss')
              ],
              sourceComments: !isProduction
            }
          }
        ]
      })
    }, {
      test: /\.(js|jsx)$/,
      include: /js/,
      use: [
        { loader: 'babel-loader' }
      ]
    }]
  },

Here is the real meat. The first rule is what to do with our scss and sass files in the css folder. We are going to run them through the Extract Text Plugin, so that they will be in their own resultant file, with a fallback to the standard style-loader. Then we are going to use specific loaders to read in css files, and sass files. For the sass-loader, we are going to include sourceComments if we aren’t building a production bundle, and we are going to load up the bootstrap 4 scss path so that we can import them into our app.scss file.

In the second part, we are going to pass any JavaScript and jsx files through the babel-loader.

For the record, here is the .babelrc file I’ve got so far:

{
  "presets":[
    "es2015", "react"
  ]
}

Finally at the end of the Webpack config:

  plugins: [
    new CopyWebpackPlugin([{ from: './static' }]),
    new ExtractTextPlugin('css/app.css'),
    new webpack.ProvidePlugin({
      $: "jquery",
      jQuery: "jquery",
      "window.jQuery": "jquery",
      Popper: ['popper.js', 'default']
    })
  ]
}

We set up the Copy Webpack Plugin to copy our static files, like the robots.txt or images into the priv folder. We also run the extract text plugin over a non scss app file (which I probably don’t actually need here). Then finally we set up a few global namespace items so that they can be bundled correctly by Webpack even if they aren’t imported specifically in the file that references them.

Usage

import 'phoenix_html';
import 'bootstrap';
@import "bootstrap";

body {
  padding-top: 1.5rem;
  padding-bottom: 1.5rem;
}

.container {
  padding-top: 1.5rem;
  padding-bottom: 1.5rem;
}

Pretty simple, you can import the JavaScript and scss for Bootstrap, and add your own times easily. You’ll still have access to the Phoenix JavaScript from your mix deps folder.

Running

When you run your mix phx.server you should see the Webpack watcher boot and emit your bundle files.

Generated appname app
[info] Running AppName.Endpoint with Cowboy using http://0.0.0.0:4000
yarn run v0.23.4
$ webpack --watch --color

Webpack is watching the files…

Hash: 19c0661b1f8ddf6c7912
Version: webpack 3.5.5
Time: 5233ms
          Asset       Size  Chunks                    Chunk Names
      js/app.js     478 kB       0  [emitted]  [big]  app
    css/app.css     234 kB       0  [emitted]         app
  js/app.js.map     900 kB       0  [emitted]         app
css/app.css.map   88 bytes       0  [emitted]         app
     robots.txt  205 bytes          [emitted]

If you change an asset file, you will see Webpack emit the update bundle.

Hash: 9797a337be458d27d712
Version: webpack 3.5.5
Time: 882ms
          Asset      Size  Chunks                    Chunk Names
      js/app.js    478 kB       0  [emitted]  [big]  app
    css/app.css    234 kB       0  [emitted]         app
  js/app.js.map    900 kB       0  [emitted]         app
css/app.css.map  88 bytes       0  [emitted]         app
   [1] ./js/app.js 62 bytes {0} [built]

Deployment

I haven’t really messed with this yet, so far just focusing on locally development. That said, it should be the same general approach as with Brunch, build the assets to the priv/static folder then run phx.digest. If I get any more info on this, it will be great content for another post.