Page Specific Javascript with Phoenix LiveView and Esbuild
LiveView has a minimal Javascript footprint, but if you're building a
client heavy app it can change. While building IndiePaper I implemented
the realtime editor using JS hooks. This caused the
app.js
file to be around 1.4 MB. Since this file is
loaded on every page, it wasted bandwidth and CPU time even when people
didn't use the editor. This post outlines the technique I used to split
the JS code and only load the required parts for each page.
Code Splitting and Dynamic Imports
Phoenix uses esbuild
to bundle assets. Bundling is the
process of taking code in separate modules and combining it into a
single file, minifying and converting it to standard Javascript in the
process. This is why app.js
increases in size when we
import more node modules. We can reduce the size by splitting the
files based on the pages that require it and dynamically load the
required parts only when needed.
Starting point
Consider that we have a LiveView app with two pages, one being a JS
heavy text editor using TipTap .
Initially we are going to have all the code reside in the
app.js
file.
import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; ... let Hooks = {}; Hooks.SimpleTipTapHtmlEditor = { mounted() { const contentHTMLElementId = this.el.dataset.contentHtmlElementId; const editorElementId = this.el.dataset.editorElementId; window.tipTapHtmlEditor = new Editor({ element: editorElement, content: contentHTMLElement.value, }; }; let liveSocket = new LiveSocket(socketHost, Socket, { params: { _csrf_token: csrfToken }, hooks: Hooks});
This increases the size of the app.js
file. Even though
the code is only required in the editor page, it is downloaded and
executed on every page.
Refactor to Modules
First step is to extract the code for the editor to a new function,
export it from a new file simple-editor.js
and include
that in app.js
.
# simple-editor.js import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; export function setupSimpleTipTapHtmlEditor( contentHTMLElementId, editorElementId ) { const contentHTMLElement = document.getElementById(contentHTMLElementId); const editorElement = document.getElementById(editorElementId); window.tipTapHtmlEditor = new Editor({ element: editorElement, content: contentHTMLElement.value, }); }
Setup Dynamic Import
Import that file in app.js
and replace with the hook
with the dynamic function call, and remove the old imports from the
top.
# app.js let Hooks = {}; Hooks.SimpleTipTapHtmlEditor = { mounted() { const contentHTMLElementId = this.el.dataset.contentHtmlElementId; const editorElementId = this.el.dataset.editorElementId; import("./simple-editor").then( ({ setupSimpleTipTapHtmlEditor }) => { setupSimpleTipTapHtmlEditor(contentHTMLElementId, editorElementId); } ); }, };
The import
statement has a different promise based form
here. When the import is encountered, the chunk of code is fetched
dynamically and then executed.
Setup Esbuild Chunking
When we enable Esbuild chunks, rather than bundling everything
together, Esbuild creates different chunk
files.
Esbuild in Phoenix is configured with editing
config/config.exs
# config.exs ... # Configure esbuild (the version is required) config :esbuild, version: "0.14.0", default: [ args: ~w(js/app.js js/simple-editor.js --chunk-names=chunks/[name]-[hash] --splitting --format=esm --bundle --target=es2017 --minify --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ]
We have added the file js/simple-editor.js
to list of
files, and added a set of options to enable splitting.
--chunk-names=chunks/[name]-[hash] --splitting --format=esm --bundle --target=es2017 --minify
Import as module
Finally you have to set the import of app.js
in your
root.html.heex
as a module.
<script type="module" defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
Extra bits
When you load JS files dynamically, there are some gotchas that you have to look out for.
Loading Delay
Dynamically loaded modules will only start fetching after
app.js
file has started executing. This leads to a
slight delay, so it's better to inline critical parts in the file
itself.
If you prefer, you can show a loading indicator while the file is
being downloaded. The example setup requires
AlpineJS. We declare
isLoading
on the element where the hook is added and
set it to true. We use that Alpine variable to show a loading
indicator. We disable the loading indicator when we recieve an event
through x-on:hook-loaded
# Element where Hook is loaded <div id="hook_element" phx-hook="SimpleHook" x-data="{isLoading: true}" x-on:hook-loaded="isLoading = false"> <p x-show="isLoading">Loading Indicator</p> ... </div>
When mounting the Hook, we have to sent a
hook-loaded
event to Alpine.
# simple-editor.js function sendEditorLoaded() { let event = new CustomEvent("hook-loaded", {}); context.el.dispatchEvent(event); } export function setupSimpleTipTapHtmlEditor( contentHTMLElementId, editorElementId ) { ... sendEditorLoaded(); }
Conclusion
With this setup, whenever you include a new Hook in a LiveView page, the corresponding module gets dynamically imported. The modules are chunked automatically and loaded on demand. This leads to only the pages that require the Javascript downloading and executing it.