Page specific JavaScript in Phoenix framework (pt.2)

This post belongs to the Page specific JavaScript in Phoenix framework series.

  1. Brunch and ES6 approach
  2. Webpack approach

Simple approach using webpack

In the previous part we designed a mechanism for organizing and requiring page specific JavaScript in a new Phoenix project using its defaul asset manager and build tool, Brunch. Another great thing about Phoenix is that it gives you plenty of freedom to use any other alternative like, for instance, webpack. One of the cool things about webpack is its dynamic requires and we can take it as an advantage for building a more flexible and straightforward mechanism, removing the manual import of every js view module we did last time:

// web/static/js/views/loader.js

import MainView    from './main';
import PageNewView from './page/new';
import PageEditView from './page/edit';
import UserShoView from './user/show';

// Let's get rid of this!
const views = {
  PageNewView,
  PageEditView,
  UserShoView,
};

// ...

So let's get started!

Switching to webpack

Before continuing we have to make some minor changes to the project in order to switch from brunch to webpack. This changes can be found in this commit, but we are going to go through them right now. To begin with we need to remove the brunch-config.js file in the ./node_modules folder. We also need to update the package.json file so we replace all the necessary packages needed:

{
  "repository": {},
  "dependencies": {
    "phoenix": "file:deps/phoenix",
    "phoenix_html": "file:deps/phoenix_html"
  },
  "devDependencies": {
    "babel-core": "^6.7.7",
    "babel-loader": "^6.2.4",
    "babel-preset-es2015": "^6.6.0",
    "copy-webpack-plugin": "^2.1.3",
    "css-loader": "^0.23.1",
    "extract-text-webpack-plugin": "^1.0.1",
    "style-loader": "^0.13.1",
    "webpack": "^1.13.0"
  }
}

Don't forget to run npm install after. Now we need to add a basic webpack configuration file:

// webpack.config.js

var ExtractTextPlugin = require('extract-text-webpack-plugin');
var CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
  entry: [
    './web/static/js/app.js',
    './web/static/css/app.css',
  ],
  output: {
    path: './priv/static',
    filename: 'js/app.js',
  },
  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel',
        query: {
          presets: ['es2015'],
        },
      },
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract('style', 'css'),
      },
    ],
  },
  plugins: [
    new ExtractTextPlugin('css/app.css'),
    new CopyWebpackPlugin([{ from: './web/static/assets' }]),
  ],
};

The final step is to change the development configuration file to use webpack in the watchers section, removing brunch:

# config/dev.exs

use Mix.Config

config :phoenix_template, PhoenixTemplate.Endpoint,
  http: [port: 4000],
  debug_errors: true,
  code_reloader: true,
  check_origin: false,
  watchers: [node: ["node_modules/webpack/bin/webpack.js", "--watch", "--color"]]

# ..
# ..

Now we are ready for some JavaScript fun!

Dynamic view/tempate specific JavaScript

The main goal is to dynamically load a JavaScript view module depending on the current view and template the application is displaying to the user. In other words, if it's displaying the new template for the PageView view module, it will need to load a file located in web/static/js/views/page/new.js. Therefore, let's refactor the PageView module so instead of generating a name generates a path string:

# web/views/layout_view.ex

defmodule PhoenixTemplate.LayoutView do
  use PhoenixTemplate.Web, :view

  @doc """
  Generates path for the JavaScript view we want to use
  in this combination of view/template.
  """
  def js_view_path(conn, view_template) do
    [view_name(conn), template_name(view_template)]
    |> Enum.join("/")
  end

  # Takes the resource name of the view module and removes the
  # the ending *_view* string.
  defp view_name(conn) do
    conn
    |> view_module
    |> Phoenix.Naming.resource_name
    |> String.replace("_view", "")
  end

  # Removes the extion from the template and reutrns
  # just the name.
  defp template_name(template) when is_binary(template) do
    template
    |> String.split(".")
    |> Enum.at(0)
  end
end

We also need to update the app.html.eex layout so it references the new function:

<!-- web/templates/layout/app.html.eex -->

...
...

<body data-js-view-path="<%= js_view_path(@conn, @view_template) %>">

...

After refreshing the browser and inspecting the source code it should look something like this:

<body data-js-view-path="page/index">

Nex step is to modify the loader.js module, so it dynamically loads the generated js view path:

// web/static/js/views/loader.js

import MainView from './main';

export default function loadView(viewPath) {
  let view;

  try {
    const ViewClass = require('./' + viewPath);
    view = new ViewClass();
  } catch (e) {
    view = new MainView();
  }

  return view;
}

Notice how we don't need to import every specific view module no more. Using webpack's require against the viewPath parameter will return the module if it exists, otherwhise it will throw an error which will be caught to return a new default MainView. We also need to slightly change the specific view modules we have so we use webpack's module.exports api:

import MainView from '../main';

module.exports = class View extends MainView {
  mount() {
    super.mount();
    console.log('PageNewView mounted');
  }

  unmount() {
    super.unmount();
    console.log('PageNewView unmounted');
  }
};

And last but not least, the main app.js file also needs to be updated:

// web/static/js/app.js

import loadView from './views/loader';

function handleDOMContentLoaded() {
  const viewName = document.getElementsByTagName('body')[0].dataset.jsViewPath;

  const view = loadView(viewName);
  view.mount();

  window.currentView = view;
}

function handleDocumentUnload() {
  window.currentView.unmount();
}

window.addEventListener('DOMContentLoaded', handleDOMContentLoaded, false);
window.addEventListener('unload', handleDocumentUnload, false);

And once we visit again http://localhost:4000/ in our browser we can check again in the console how everything is working just like before:

Conclusion

Thanks to Phoenix's modern and flexible way of managing and building static assets we can have a well organized front-end code. Whether we choose Brunch, Webpack or any other available option we migh like, there's no excuse for returning back to the dark days ruled by the Asset Pipeline and the spaghetti code. Long live Phoenix!

Check out the source code:

Happy coding!

comments powered by Disqus