Page-Specific JavaScript with LiveView and Webpack

Tags

Webpack code on a screen

I was recently working on a Phoenix web application involving LiveView. These days, the default front-end tooling experience involves using webpack to compile your JavaScript modules. In this post, I’ll explore how to use webpack when working with LiveView to conditionally load JavaScript assets for specific pages and, in doing so, help keep the size of app.js down and performance up.

A Bit About Webpack

At a high level, webpack is used to give a developer control of how to treat various assets. While primarily used to bundle JavaScript, it can also be used to help manage assets such as images for fonts. Webpack builds a dependency graph consisting of various asset modules needed by your web application based on the configuration you specify.

Sometimes You Need JavaScript

When your project is a good use case for LiveView, and you’re more backend-focused, it can be quite nice to keep writing Elixir. This article, Phoenix LiveView: Interactive, Real-Time Apps. No Need to Write JavaScript, is a good description of LiveView and how it works.

But sometimes, you do end up needing to integrate a JavaScript library or two (or more) in a real-world project.

A Real World Workflow Involving Design

To provide an example, I recently worked with a designer (who was new to Phoenix) to integrate into LiveView’s JavaScript interoperability.

Initially, we added the charting library via the package.json file and webpack. Unfortunately, we found that blew up the app.js file size from ~120 KB to ~1.6 MB–a 1233.3% increase in size! The application didn’t necessarily need to be mobile-friendly (yet), but we decided to be judicious in our approach and try and conditionally load the JavaScript as needed to keep the asset sizes down. We did this just in case a user was on a poorly performing network to ensure they would only have to pay the price for the JavaScript dependency on the specific page in which it was needed.

To allow each other to work independently, we decided it was easier to let the designer integrate the JavaScript assets statically, since all that was required at that point was to mock up some dummy data.

So, he manually pulled down the charting libary they needed and inserted it via a <script> tag in the html code generated by Phoenix.

Achieving Maintainability and Page-Specific JavaScript with Dynamic Importing

For our application, we continually added nicely designed layouts and dashboards. So, while statically linking to various libraries per page might be fine when the application is small (and without much functionality), it can be quite a beast to maintain over time. Additionally, web browsers have a finite amount of connections they can keep open in parallel to download assets, which also has the potential to cause performance issues.

Fortunately, I learned about a webpack feature called “dynamic imports” while browsing the Elixir forum.

Example: Validating JSON

Let’s use LiveView to build a simple JSON validator with syntax highlighting. It will display a “success message” if the JSON is valid and an error message if it is not. Along the way, we’ll see how to go about managing the JavaScript dependencies we’ll need.

To make our lives easier on the front-end, we’ll use a dependency called CodeMirror to allow us to easily transform an html textarea input field into a simple editor with syntax highlighting and line numbers. There’s no need to try and code something from scratch. We are web developers after all.

Step 1: Install Code Mirror in Your Phoenix Application

cd assets && npm i codemirror

Step 2: Create the LiveView validation view and functionality

Here is the basic validation. We try and decode the JSON, and if we get an :ok tuple, we display the success message.

defmodule DataWebpackWeb.Validate.JsonLive do
  use DataWebpackWeb, :live_view
  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, json_code: "", info: nil, error: nil)}
  end

  @impl true
  def handle_event("validate_json", %{"json_code" => code}, socket) do
    case Jason.decode(code) do
      {:ok, _result} ->
        {:noreply, assign(socket, json_code: code, info: "Valid JSON!", error: nil)}

      {:error, _error} ->
        {:noreply, assign(socket, json_code: code, info: nil, error: "Invalid JSON!")}
    end
  end
end

Step 3: Add Dynamic Importing with Hooks and Webpack

When I first experimented with dynamic importing via webpack, I used a “single line import style” as you can see in the code example below.

const codeInput = document.getElementById('json-code');
import( /* webpackChunkName: "cm_css" */ 'codemirror/lib/codemirror.css').then(cmCss => {});
import( /* webpackChunkName: "cm_javascript" */ 'codemirror/mode/javascript/javascript.js').then(cmJs => {});
import( /* webpackChunkName: "cm" */ 'codemirror/lib/codemirror.js').then(cm => {
  const editArea = cm.fromTextArea(codeInput, {
    lineNumbers: true,
    mode: {
      name: 'javascript',
      json: true
    },
  });
  editArea.setSize('100%', '500px');
});

This could work with CodeMirror as well, but I found a new interesting way of loading multiple modules simultaneously using an ECMAScript feature Promise.all. See the code below from the default app.js file generated by Phoenix:

// default LiveView code in app.js omitted for brevity...


const codeInput = document.getElementById('json-code');
const startCodeMirror = (textarea) => {
  Promise.all([
    import( /* webpackChunkName: "cm_css" */ 'codemirror/lib/codemirror.css'),
    import( /* webpackChunkName: "cm_javascript" */ 'codemirror/mode/javascript/javascript.js'),
    import( /* webpackChunkName: "cm" */ 'codemirror/lib/codemirror.js'),
  ]).then(([cmcss, cmjs, cm]) => {
    const editArea = cm.fromTextArea(textarea, {
      lineNumbers: true,
      mode: {
        name: 'javascript',
        json: true
      },
    });
    editArea.setSize('100%', '500px');
  });
};

Hooks.loadEditor = {
  mounted() {
    startCodeMirror(codeInput);
  }
}

I used a Phoenix hook to ensure the CodeMirror dependency would only be loaded for that particular page.

Alas, Promise.all is not supported in Internet Explorer 11, but for my use case, we only had to support the latest versions of Safari, Firefox, and Chrome. I suspect you may have to use some kind of third-party support library as documented in this StackOverflow post, but I haven’t had a chance to try it yet.

Step 4: Check It Works

Here is a screenshot in Chrome showing the Network tab for the page loading the JavaScript dependency.

Chrome Network tab showing JS dependency sizes

If you clone the demonstration repository, and run the application yourself, you’ll see on the default homepage that the JavaScript dependency does not get loaded.

Summary

Through this project, I learned how to conditionally load a JavaScript dependency for a particular LiveView enabled page via Webpack. If you want to keep your app.js size down when possible and manage all your dependencies with traditional front-end tooling, consider using this technique.

DockYard is a digital product agency offering custom software, mobile, and web application development consulting. We provide exceptional professional services in strategy, user experience, design, and full stack engineering using Ember.js, React.js, Ruby, and Elixir. With a nationwide staff, we’ve got consultants in key markets across the United States, including San Francisco, Los Angeles, Denver, Chicago, Austin, New York, and Boston.