-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpagewise-js-liveview.html
257 lines (241 loc) · 8.77 KB
/
pagewise-js-liveview.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
<!DOCTYPE html>
<html class="no-js" lang="">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Page Specific Javascript with Phoenix LiveView and Esbuild</title>
<meta
name="description"
content="Learn how to split your LiveView Javascript pagewise with esbuild, so your main app.js file stays small and people only download and execute the javascript they require."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
href="./styles/output.css"
type="text/css"
media="screen"
/>
<script
defer
data-domain="aswinmohan.me"
data-api="/stats/api/event"
src="/stats/js/script.js"
></script>
</head>
<body>
<header class="flex items-center justify-between">
<p><a href="/">back to home</a></p>
<p>2022-Apr-25</p>
</header>
<div
id="job-banner"
class="border-primary-300 mt-12 border bg-gray-200 px-4 py-2"
>
<p>
I'm currently looking for fulltime/contracting oppurtunities in Elixir,
Phoenix and LiveView. If you're looking for a kickass Product focused
Elixir developer, I'm your guy. You can find my resume
<a href="./resume.pdf">here</a> and reach me at
<a href="mailto:hey@aswinmohan.me">hey@aswinmohan.me</a>.
</p>
</div>
<article>
<h1>Page Specific Javascript with Phoenix LiveView and Esbuild</h1>
<p>
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
<code> app.js </code> 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.
</p>
<section>
<h2>Code Splitting and Dynamic Imports</h2>
<p>
Phoenix uses <code>esbuild</code> 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 <code>app.js</code> 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.
</p>
<h3>Starting point</h3>
<p>
Consider that we have a LiveView app with two pages, one being a JS
heavy text editor using <a href="https://tiptap.dev">TipTap</a> .
Initially we are going to have all the code reside in the
<code> app.js </code> file.
</p>
<pre>
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});
</pre>
<p>
This increases the size of the <code> app.js </code> file. Even though
the code is only required in the editor page, it is downloaded and
executed on every page.
</p>
<h3>Refactor to Modules</h3>
<p>
First step is to extract the code for the editor to a new function,
export it from a new file <code> simple-editor.js </code> and include
that in <code>app.js</code>.
</p>
<pre>
# 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,
});
}
</pre>
<h3>Setup Dynamic Import</h3>
<p>
Import that file in <code> app.js </code> and replace with the hook
with the dynamic function call, and remove the old imports from the
top.
</p>
<pre>
# 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);
}
);
},
};
</pre>
<p>
The <code> import </code> statement has a different promise based form
here. When the import is encountered, the chunk of code is fetched
dynamically and then executed.
</p>
<h3>Setup Esbuild Chunking</h3>
<p>
When we enable Esbuild chunks, rather than bundling everything
together, Esbuild creates different <code> chunk </code> files.
Esbuild in Phoenix is configured with editing
<code> config/config.exs </code>
</p>
<pre>
# 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__)}
]
</pre>
<p>
We have added the file <code> js/simple-editor.js </code> to list of
files, and added a set of options to enable splitting.
</p>
<pre>
--chunk-names=chunks/[name]-[hash] --splitting --format=esm --bundle --target=es2017 --minify
</pre>
<h3>Import as module</h3>
<p>
Finally you have to set the import of <code> app.js </code> in your
<code> root.html.heex </code> as a module.
</p>
<pre>
<script type="module" defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
</pre>
</section>
<section>
<h2>Extra bits</h2>
<p>
When you load JS files dynamically, there are some gotchas that you
have to look out for.
</p>
<h3>Loading Delay</h3>
<p>
Dynamically loaded modules will only start fetching after
<code> app.js </code> file has started executing. This leads to a
slight delay, so it's better to inline critical parts in the file
itself.
</p>
<p>
If you prefer, you can show a loading indicator while the file is
being downloaded. The example setup requires
<a href="https://alpinejs.dev/">AlpineJS</a>. We declare
<code> isLoading </code> 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 <code> x-on:hook-loaded </code>
</p>
<pre>
# 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>
</pre>
<p>
When mounting the Hook, we have to sent a
<code> hook-loaded </code> event to Alpine.
</p>
<pre>
# simple-editor.js
function sendEditorLoaded() {
let event = new CustomEvent("hook-loaded", {});
context.el.dispatchEvent(event);
}
export function setupSimpleTipTapHtmlEditor(
contentHTMLElementId,
editorElementId
) {
...
sendEditorLoaded();
}
</pre>
</section>
<section>
<h2>Conclusion</h2>
<p>
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.
</p>
</section>
</article>
</body>
</html>