tl;dr - This is a step-by-step (increasing complexity) guide to building a simple frontend project with Mithril, then layering on dynamic loading (via SystemJS) and ending up with mature bundle/build as managed by Parcel. You can skip straigh to the mrman/mithmail
repository which contains the finished code if you’d like!
Mithril is one of the most intersting frontend component libraries to me (I’ve written about it before) – it’s very small yet very performant, simple yet contains just about everything I need to make the apps I use these days. Other good component libraries have similar qualities but often require or heavily encourage the adoption of a suite of tools to go with, for example Vue (which is either #1 or #2 on my favorites) is often paired with vue-router and VueX. This often makes the claims people make about being “vue shop"s or “(pre/re)act shops” somewhat disingenuous – those libraries don’t do enough to be a full solution, they have. I’m not suggesting everyone go over to the other end of the spectrum with tools like Ember or Angular live, but I think the distinction is worth making/being mindful of. Mithril is particularly interesting to me because it bucks this trend – it is small yet complete (for my own subjective definition of “complete”).
With this post I’m going to try and put out some information that I think is very underrepresented in the frontend blogosphere:
git init
) with progressive complexity (i.e., don’t start with npm install webpack
, maybe avoid using webpack at all)Rather than the usual TODO application, let’s build the interface for an email client (I think I first saw this approach in the Ember docs from yesteryear). Email client UIs look somewhat like this these days:
This image was from a post on iopan.co.uk
As you can see it’s very amenable to showing strengths of a component library, there is a very clear separation of components and it’s somewhat obvious how they interact. I doubt I’ll be reaching this level of polish since I don’t want writing this to to take forever but we’ll see how stuck in we get – the basic structure should be easy to achieve.
Without further ado, let’s get into it.
As usual, we’re going to want to start by reading or at least skimming through documentation/tutorials for the technologies we’re about to use:
I’m going to assume if you’re reading this you’ve got a basic sense of how the internet and frontend development works. If not, check out the web developer reference or the developer guides on MDN.
It’s important to note that while I do think of this as a mostly introductory guide, I will not be covering the basics of Mithril or module loading or other such technologies.
OK, let’s get this thing on the road, we’ll make a fresh project folder:
$ mkdir mithmail
(a bunch of other good names to be had; “chainmail”, “mithrail”, etc)
We don’t even have git
initialized, so let’s do that – I’ll be working all on master
and branching off. so those who are interested can follow the development stream if they want.
$ cd mailthril
$ git init .
$ git remote add origin ssh@gitlab.com/mrman/mailthril
Let’s add a README.md
and index.html
and a few other folders
$ touch README.md
$ touch index.html
$ mkdir -p static/vendor # vendor-provided JS or CSS is gonna be in here
$ mkdir -p static/css # our CSS will be in here
$ mkdir -p static/images/icons # images/icons we use will be in here
$ mkdir dist # "compiled" code will be in here, you should be able to just serve this folder with no worries
Here’s the file structure:
$ tree .
.
├── dist
├── index.html
├── README.md
└── static
├── css
├── images
│ └── icons
└── vendor
6 directories, 4 files
Not shown are the *~
temp files that emacs makes, along with the .gitignore
that excludes them which I’ve also added. Let’s also npm init
so we can get the usual javascript package magic going:
$ npm init .
While I normally manage all my projects with GNU make
, I’ll make an exception this time since this project should be pretty short – we’ll use NPM scripts to manage everything.
Now, let’s get to the actual Mithril-related bit – here’s the HTML we’ll need in index.html
:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MithMail</title>
</head>
<body>
</body>
</html>
Let’s zoom in on <body>
a little bit – it’s where we’re going to be instantiating the application after all. Before we start getting into a very compressed recap of how to use Mithril, let’s download mithril’s code, you can find it at:
$ mkdir -p static/vendor/mithril/1.1.6
$ wget -O ./static/vendor/mithril/1.1.6/mithril.min.js https://unpkg.com/mithril@1.1.6/mithril.min.js
OK, now that we have that, let’s add the minimum amount of operations-related commands that “builds” our site – basically just copying index.html
and static/
into dist/
:
{
"name": "mithmail",
"version": "1.0.0",
"description": "Demo project to showcase Mithril+SystemJS+ParcelJS",
"main": "index.html",
"repository": "git@gitlab.com:mrman/mithmail.git",
"author": "vados <vados@vadosware.io>",
"license": "MIT",
"private": true,
+ "scripts": {
+ "build": "npm run build-static && build-html && npm run build-js",
+ "build-html": "cp index.html dist/",
+ "build-js": "cp app.js dist/",
+ "build-static": "mkdir -p dist && cp -r static dist/",
+ }
}
We can even leverage entr to make a “watch” script that does this when files change:
... other JSON ...
"build-js": "cp app.js dist/",
+ "build-watch": "ls index.html app.js | entr -rc npm run build"
}
With this command, I can open up another pane (I use tmux), and run npm run build-watch
for some quick and easy updating of the code in dist/
as I code if I so please. While we’re here, let’s also add a command that uses http-server
to serve our website locally out of dist/
:
$ npm install -D http-server
$ emacs -nw package.json # add the "serve" script
... other JSON ...
"build-watch": "ls index.html app.js | entr -rc npm run build"
+ "serve": "node_modules/.bin/http-server dist/"
}
}
NOTE http-server
has a convenient proxying feature so it’s great for locally spinning up a frontend that speaks to a different backend via a stop level route (like if your client-facing apps runs on example.com/app
and the API runs @ example.com/app
.
OK, with this boilerplate out of the way, we can actually get down to writing some mithril code.
OK, now that we have our scaffolded index.html
and the mithril code is vendored into our code, let’s start building this application. We’re going to do it the simplest possible way – two javascript <script>
tag imports in our HTML at the bottom of <body>
. One of the script tags will be mithril and the other one will be a file we’re going to put a lot of javascript into.
<script src="static/vendor/mithril/1.1.6/mithril.min.js"></script>
<script src="app.js"></script>
Now let’s add app.js
. If you’ve gone through at least a little of the mithril tutorial, the scaffolding of a few components should be self explanatory:
var App = {
view: function() {
return m("div", [
m("h1", "MithMail"),
m("span", "Hello world"),
]);
}
};
m.mount(document.body, App);
Even if you haven’t read the tutorial, hopefully Mithril’s hyperscript should be pretty straight-forward to read. One of the interesting things about mithril is that it sidesteps the once-infamous JSX (now everyone’s fine with it for the most part) and template compilation for straight hyperscript function m
. If you don’t absolutely hate this format (or are willing to give it time to grow on you), then it’s very convenient, because it makes a whole bunch of precompilation complexity unnecessary – we’re going to be writing some plain old ES5 here, we’ll worry about precompilation at the very end when we layer on ParcelJS.
First let’s build out the LHSNav
component, let’s get a quick refresher of what the end goal we want looks like:
Looks like we’ve got:
I’ll try and break down the code required bit by bit (at least as far as the Mithril), and link to the whole code listing.
For the header (AKA LHSNavHeader
):
var HeaderButton = {
view: function(vnode) {
const text = vnode.attrs.title || "Compose Mail";
const iconName = vnode.attrs.iconName || "pencil-white.svg";
const iconPrefix = "static/images/icons/";
return m("button", {class: "component header-button"}, [
m("span", text),
m("img", {class: "sm-margin-left icon", src: iconPrefix + iconName }),
]);
}
};
var LHSNavHeader = {
view: function() {
return m("header", [
m(HeaderButton),
]);
}
};
var LHSNav = {
view: function() {
return m("div", {class: "component lhs-nav"}, [
m(LHSNavHeader),
m("hr"),
]);
}
};
var MailContent = {
view: function() {
return m("div", {class: "component mail-content"}, [
m("h1", "RHS Stuff here"),
]);
}
};
var App = {
view: function() {
return m("div", {class: "component app"}, [
m(LHSNav),
m(MailContent),
]);
}
};
NOTE I’ll leave off the definition for App
and MailContent
in future sections – the most important bits above are LHSNav
and LHSNavHeader
.
For the list of pages to navigate to (AKA LHSNavPageList
):
var Badge = {
view: function(vnode) {
var styles = [];
var content = vnode.attrs.content || "";
var bgColor = vnode.attrs.cssBgColor || "gray";
var color = vnode.attrs.cssBgColor || "white";
styles.push("background-color:" + vnode.attrs.cssBgColor);
styles.push("color:" + vnode.attrs.cssColor);
var combinedStyles = styles.join(";");
return m("span", {class: "component badge", style: combinedStyles}, content);
}
};
var NavItem = {
view: function(vnode) {
var data = vnode.attrs.data || {};
var title = data.title || "Placeholder";
var iconImgSrc = data.iconImgSrc;
var badgeNumber = data.badgeNumber || 0;
var badgeCSSBgColor = data.badgeCSSBgColor;
var children = [title];
var classes = ["component", "nav-item", "md-padding-horiz", data.selected ? "selected" : ""];
// If there's an icon specified display it
if (iconImgSrc) {
children.unshift(m("img", {class: "sm-margin-left sm-margin-right icon", src: iconImgSrc}));
}
// If badgeColor and badgeNumber are specified then show a badge
if (badgeNumber && badgeCSSBgColor) {
children.push(m("div", {class: "badge-container float-right"}, [
m(Badge, {class: "float-right", cssBgColor: badgeCSSBgColor, content: badgeNumber})
]));
}
return m("li", {class: classes.join(" ")}, children);
}
};
var NavItemListing = {
view: function(vnode) {
var navItems = vnode.attrs.items || [];
var children = navItems.map(function(i) {
return m(NavItem, {data: i});
});
return m("ul", {class: "component nav-item-listing"}, children);
}
};
var LHSNavPageList = {
view: function() {
const navItems = LHS_NAV_ITEMS;
return m(NavItemListing, {items: navItems});
}
};
For the list of labels (AKA LHSNavLabelList
):
var LHSNavLabelList = {
view: function() {
const labelItems = LHS_LABEL_ITEMS;
return m(NavItemListing, {items: labelItems});
}
};
The LHSNavLabelList
component was mostly identical but required some changes to the other bits of the code, for example hacking in a property called badgeEmptySquare
on the Badge
component to show empty squares. The reuse of LHSNavItem
for both the LHSNavigationList
and the LHSLabelList
is awesome though.
Here’s what the LHS looks like all together, with the mock data inc:
It’s not pixel-for-pixel but most things aren’t (in production anyway) – this is definitely a pretty good start! I’ve glossed over it here, but in a “real” project you’d want to use a proper css micro/full sized framework (like bootstrap, PureCSS, skeleton, mui or even nes.css), but for this project I’ve worked out the CSS that should generally match the design we’re aiming for.
The RHS is a bit more complicated, but is more of the same – we’ve got the general holder of the different views, and we know we need to incorporate an inbox view, a starred view, a draft view, and an important view. Rather than simply making one view and trying to swap out text and configuration whenever someone clicks the navigation on the left hand side, let’s build completely separate RHS “pages” (i.e. InboxPage
, StarredPage
, DraftPage
, ImportantPage
), and try to reuse shared components but not necessarily pass in tons of “customization”.
We have a pretty good approximation of what the inbox page should look like so let’s start there:
var RHSHeader = {
view: function(vnode) {
var title = vnode.attrs.title || "Title";
var bgColor = vnode.attrs.bgColor || "#2285c6";
var newCount = vnode.attrs.newCount;
return m("div.component.rhs-header.flex", {class: vnode.attrs.class}, [
m(".title-container", [
m("span.title", title),
newCount ? m("span.new-count", "(" + newCount + ")"): undefined,
]),
m(".controls", [
m("input.search-box[type='text'][placeholder='Search']"),
m("img.sm-margin-left.icon", {src: "static/images/icons/settings-white.svg"}),
m("img.sm-margin-left.avatar", {src: "static/images/example-avatar.jpg"}),
])
]);
}
};
var MultiSelectCheckbox = {
view: function() {
return m("span.component.multi-select-checkbox.gray-boxed.clickable", [
m("input[type=checkbox]"),
m("img.xs-margin-left.icon", {src: "static/images/icons/chevron-down.svg"}),
]);
}
};
var ButtonGroup = {
view: function(vnode) {
return m("span.component.button-group", {class: vnode.attrs.class}, vnode.children);
}
};
var DropDownButton = {
view: function(vnode) {
var title = vnode.attrs.title || "Button";
return m("button.component.drop-down-button.gray-boxed.clickable", [
m("span", title),
m("img.xs-margin-left.icon", {src: "static/images/icons/chevron-down.svg"}),
]);
}
};
var PaginationControls = {
view: function(vnode) {
var title = vnode.attrs.title || "Button";
var lowerBound = 0;
var upperBound = 50;
var boundStatement = lowerBound + " - " + upperBound;
var total = 100;
return m(".component.pagination-controls", [
m("strong", boundStatement),
m("span", " of "),
m("strong", total),
m("img.xs-margin-left.icon-lg.gray-boxed.valign-mid", {src: "static/images/icons/chevron-left.svg"}),
m("img.xs-margin-left.icon-lg.gray-boxed.valign-mid", {src: "static/images/icons/chevron-right.svg"}),
]);
}
};
var EmailListItem = {
view: function(vnode) {
var email = vnode.attrs.email;
var textContent = email.text || "";
var formattedSendDate = email.sendDate.toLocaleString();
return m(".component.email-list-item", [
m(".header", [
m(".lhs", [
m("input[type=checkbox]", {
onclick: function() {
if (vnode.attrs.onSelected) {
vnode.attrs.onSelected(email);
}
}
}),
m("span.sm-margin-left.sender", email.from.name),
m("span.sm-margin-left.tag", email.label),
]),
m(".rhs", [
m(".date", formattedSendDate)
])
]),
m(".blurb.md-margin-top", [
m("p.subject", email.subject),
// This is not a great way to do ellipsis but... it'll work for the demo.
m("p.body-preview", textContent.substring(0, ELLIPSIS_CHAR_LIMIT) + "..."),
]),
]);
}
};
var EmailList = {
oninit: function() {
this.selectedEmails = {};
},
view: function(vnode) {
var emails = vnode.attrs.emails || [];
return m(".component.email-list", emails.map(function(email) {
return m(EmailListItem, {
email: email,
onEmailSelect: function(email) { this.selectedEmails[email.id] = true; },
});
}));
}
};
var MailPageSubNav = {
view: function(vnode) {
// Container with buttons
return m(".component.mail-page-subnav", [
m(".lhs", [
m(MultiSelectCheckbox),
m(ButtonGroup, {class: "sm-margin-left"}, [
m("button.clickable", "Archive"),
m("button.clickable", "Spam"),
m("button.clickable", "Delete"),
]),
m(ButtonGroup, {class: "sm-margin-left"}, [
m(DropDownButton, {title: "Move to"}),
m(DropDownButton, {title: "Label"}),
]),
]),
m(".rhs", [
m(PaginationControls)
]),
]);
},
};
var InboxPage = {
view: function(vnode) {
var newCount = vnode.attrs.newCount;
return m(".component.inbox-page", [
// Colored header
m(RHSHeader, {class: "inbox-blue-bg white-fg", title: "Inbox", newCount: newCount}),
// Container with buttons
m(MailPageSubNav),
// EmailList
m(EmailList, {emails: EMAILS}),
]);
}
};
var StarredEmailsPage = {
view: function(vnode) {
var newCount = vnode.attrs.newCount;
return m(".component.starred-emails-page", [
// Colored header
m(RHSHeader, {class: "starred-orange-bg white-fg", title: "Inbox", newCount: newCount}),
// Container with buttons
m(MailPageSubNav),
// EmailList
m(EmailList, {emails: EMAILS}),
]);
}
};
var DraftEmailsPage = {
view: function(vnode) {
var newCount = vnode.attrs.newCount;
return m(".component.draft-emails-page", [
// Colored header
m(RHSHeader, {class: "draft-gray-bg white-fg", title: "Inbox", newCount: newCount}),
// Container with buttons
m(MailPageSubNav),
// EmailList
m(EmailList, {emails: EMAILS}),
]);
}
};
var ImportantEmailsPage = {
view: function(vnode) {
var newCount = vnode.attrs.newCount;
return m(".component.important-emails-page", [
// Colored header
m(RHSHeader, {class: "important-gray-bg white-fg", title: "Inbox", newCount: newCount}),
// Container with buttons
m(MailPageSubNav),
// EmailList
m(EmailList, {emails: EMAILS}),
]);
}
};
var MailContent = {
oninit: function() {
this.subpage = "inbox";
},
view: function() {
// TODO: decide *which* view to show
switch (this.subpage) {
case "starred": return m(StarredEmailsPage);
case "draft": return m(DraftEmailsPage);
case "draft": return m(ImportantEmailsPage);
case "inbox":
default:
return m(InboxPage, {newCount: 2});
}
}
};
Whew that’s a lot of code! How about a picture:
As you might have noticed I’m gettin further and further away from the mock in terms of design polish but it’s close, at least. While the UI is present, it’s not functional at all – none of the buttons do anything (for example, the detail view isn’t anywhere, and doesn’t exist yet, since we can’t select an email yet). We’ll work on that in the next section, but at the very least we’ve got a UI that’s relatively close to the mock.
You can look at the full code listing for the rest of the non-inbox pages, they’re almost straight copy-pastes of the Inbox
page.
Let’s hook up a tiny bit of functionality for this mock – we’re going to implement most things as simple alert
s that show some information on what would have happened if the mock… wasn’t a mock. For other things that ar relatively easy to implement we’ll hook up semi-realistic functionality:
Let’s start with the LHS navigation, but before we start coding up event handlers, let’s think a little bit about the events that we expect to be triggered. One of the only ways to cause refreshes/change UI in Mithril is by triggering events, and the navigation buttons we added earlier will need onclick
event handlers and passed to them that do whatever is necessary to tell the application when it needs to change the current RHS view. As far as changing the layout of the page we’ll tear out the stubbing code for currentPage
above and use the included routing system of mithril. We’ll use paths like #!/inbox
and #!/starred
for different pages. Here’s the code (modifying the LHSNav
component):
var LHS_NAV_ITEMS = [
{
title: "Inbox",
iconImgSrc: "static/images/icons/inbox-white.svg",
badgeCSSBgColor: "#2285c6",
badgeContent: 2,
selected: true,
route: "/inbox", // <------ this is new
},
// ... other nav items ...
];
// ... lots of other code ...
var NavItem = {
view: function(vnode) {
// ... other code ...
return m("li.clickable", {
class: classes.join(" "),
onclick: function(e) {
// Navigate to the appropriate link for this
m.route.set(data.route);
},
// ... other code ...
}
};
Here’s the code in action:
You might have noticed that unsightly flicker of the LHS side – it looks like mithril isn’t smart enough to tell that the components are similar (which I wouldn’t expect, to be honest, that’s a fairly advanced feature). The mithril router also restricts you to only using one per page (so we can’t do anything like nesting routers which you might do with Ember’s routing system), so it looks like we’ll have to use the currentPage
style logic after all, with a single app container:
var MailContent = {
oninit: function() {
this.subpage = "inbox";
},
view: function() {
var path = m.route.get();
// Decide *which* view to show
switch (path) {
case "/starred": return m(StarredPage);
case "/draft": return m(DraftPage);
case "/important": return m(ImportantPage);
case "/inbox":
default:
return m(InboxPage, {newCount: 2});
}
}
};
m.route(document.body, "/inbox", {
"/inbox": MailContent,
"/starred": MailContent,
"/draft": MailContent,
"/important": MailContent,
});
Now let’s see what that looks like:
Now that we’ve gotten rid of that tiny bit of jank (we actually didn’t, it just is too fast to see in that video – see the end of section 2.4 for the actual fix), let’s deal with selecting one or more emails in the list. The mock shows a selected state without the checkbox selected, meaning that the multi-email action functionality is somewhat separate from viewing emails (we’ll call these states selected
and checked
respectively). Feel free to refer to the mock to see what it looked like when someone selected a single email.
Here’s the code to make it happen:
/**
* Email service that will manage checking
*
* Note: Mithril can't redraw subtrees yet (https://github.com/MithrilJS/mithril.js/issues/1907), so m.redraw() is everywhere
*/
function EmailService() {
this.selectedEmailId = null;
this.checkedEmailIds = {};
this.allEmailsChecked = false;
}
EmailService.prototype.selectEmailById = function(id, opts) {
if (!id) { throw new Error("Invalid email object, ID is missing"); }
console.log("[email-svc] selected email with id:", id);
this.selectedEmailId = id;
if (opts && opts.redraw) { m.redraw(); }
};
EmailService.prototype.getEmailSelectedStatus = function(id) {
if (!id) { throw new Error("Invalid email object, ID is missing"); }
return this.selectedEmailId === id;
};
EmailService.prototype.checkEmailById = function(id, opts) {
if (!id) { throw new Error("Invalid email object, ID is missing"); }
console.log("[email-svc] checked email with id:", id);
this.checkedEmailIds[id] = true;
if (opts && opts.redraw) { m.redraw(); }
};
EmailService.prototype.getEmailCheckedStatusById = function(id) {
if (!id) { throw new Error("Invalid email object, ID is missing"); }
return this.allEmailsChecked ? true : id in this.checkedEmailIds;
};
EmailService.prototype.clearCheckedEmails = function(opts) {
this.allEmailsChecked = false;
this.checkedEmailIds = {};
console.log("[email-svc] cleared checked emails");
if (opts && opts.redraw) { m.redraw(); }
};
EmailService.prototype.checkAllEmails = function(opts) {
this.allEmailsChecked = true;
console.log("[email-svc] checked all emails");
if (opts && opts.redraw) { m.redraw(); }
};
var EMAIL_SVC = new EmailService();
// ... further down in the code ...
var EmailListItem = {
view: function(vnode) {
var email = vnode.attrs.email;
var textContent = email.text || "";
var formattedSendDate = email.sendDate.toLocaleString();
var isSelected = EMAIL_SVC.getEmailSelectedStatus(email.id);
var additionalClasses = [];
if (isSelected) { additionalClasses.push("selected"); }
return m(
".component.email-list-item.clickable",
{
class: additionalClasses.join(" "),
onclick: function() { EMAIL_SVC.selectEmailById(email.id); }
},
[
m(".header", [
m(".lhs", [
m("input[type=checkbox]", {
onclick: function() {
if (vnode.attrs.onSelected) {
vnode.attrs.onSelected(email);
}
}
}),
m("span.sm-margin-left.sender", email.from.name),
m("span.sm-margin-left.tag", email.label),
]),
m(".rhs", [
m(".date", formattedSendDate)
])
]),
m(".blurb.md-margin-top", [
m("p.subject", email.subject),
// This is not a great way to do ellipsis but... it'll work for the demo.
m("p.body-preview", textContent.substring(0, ELLIPSIS_CHAR_LIMIT) + "..."),
]),
]);
}
};
You’ll notice that I’ve gone with a big bad global EMAIL_SVC
(which is an instance of the EmailService
) for state management here – this isn’t particularly ideal but it’s much easier to follow than threading all state and callbacks in and out of components. Personally I find that a simple & light Service
abstraction takes me 99% of the way towards most data-related tasks that frontends need. I usually counteract this somewhat nefarious use of globals by having components explicitly take the Service
as a dependency in their attributes (and use the global one as a default just in case), which allows for mocking in a testing situation. There are lots of ways to manage data out there, but that’s not the focus of this post – we haven’t even gotten to the focus of this post, which is getting the feel for layering on the complexity piece-by-piece for app code packaging… A real app you might centralize the data and dispatching of updates/events in some more centralized areas, but maybe not. For now I’m going to keep it simple and just use with Mithril gives us out of the box, along with some lt;spooky ghost voice> global variables </spooky ghost voice> at the top.
Now let’s hook up the functionality behind the checked
idea (selecting mail for a multi-email action), it’s pretty similar (I already added the logic for checking) so I’ll just show the updated component code (remember, the MailPageSubNav
component also had that select-all box):
// NOTE: some utility methods were added to the EmailService (they'll be used further down in the components)
// they should be pretty obvious by name, but normally of course use proper JSDOC (http://usejsdoc.org)
EmailService.prototype.resetSelectedEmailId = function(opts) {
this.selectedEmailId = null;
if (opts && opts.redraw) { m.redraw(); }
};
EmailService.prototype.toggleSelectionStatusById = function(id, opts) {
if (!id) { throw new Error("Invalid email object, ID is missing"); }
// Reset if currently selected and exit early
if (this.getEmailSelectedStatusById(id)) {
this.resetSelectedEmailId();
return;
}
this.selectEmailById(id);
if (opts && opts.redraw) { m.redraw(); }
};
EmailService.prototype.resetEmailCheckedStatusById = function(id, opts) {
if (!id) { throw new Error("Invalid email object, ID is missing"); }
delete this.checkedEmailIds[id]; // This is kinda bad to do a lot
if (opts && opts.redraw) { m.redraw(); }
};
EmailService.prototype.toggleCheckedStatusById = function(id, opts) {
if (!id) { throw new Error("Invalid email object, ID is missing"); }
// Reset if currently selected and exit early
if (this.getEmailCheckedStatusById(id)) {
this.resetEmailCheckedStatusById(id);
return;
}
this.checkEmailById(id);
if (opts && opts.redraw) { m.redraw(); }
};
EmailService.prototype.allEmailsChecked = function(opts) {
return this._allEmailsChecked;
};
EmailService.prototype.oneOrMoreEmailsChecked = function(opts) {
// Object.keys isn't supported on some IE browsers, but Mithril doesn't support older than IE11 anyway IIRC
return this._allEmailsChecked || Object.keys(this.checkedEmailIds).length > 0;
};
// .... other code ...
// NOTE: this component needed to change to show different stuff if multiple things were selected or not, prompting the need for the utility methods above
var MultiSelectCheckbox = {
view: function(vnode) {
var allEmailsChecked = vnode.attrs.allEmailsChecked;
var oneOrMoreEmailsChecked = vnode.attrs.oneOrMoreEmailsChecked;
var dropdownContentClasses = [];
var dropdownContentHidden = "dropdownContentHidden" in vnode.attrs ? vnode.attrs.dropdownContentHidden : true;
if (dropdownContentHidden) { dropdownContentClasses.push("hidden"); }
var selectionIconSrc = "static/images/icons/square.svg";
if (oneOrMoreEmailsChecked) { selectionIconSrc = "static/images/icons/minus-square.svg"; }
if (allEmailsChecked) { selectionIconSrc = "static/images/icons/check-square.svg"; }
var selectionText = "Select All";
if (oneOrMoreEmailsChecked) { selectionText = "Clear Selection"; }
if (allEmailsChecked) { selectionText = "Unselect All"; }
var clickHandler = function() { EMAIL_SVC.checkAllEmails(); };
if (allEmailsChecked || oneOrMoreEmailsChecked) { clickHandler = function() { EMAIL_SVC.clearCheckedEmails(); }; }
return m(
"span.component.multi-select-checkbox.gray-boxed.clickable",
{onclick: clickHandler},
[
m("span", [
m("img.icon", {src: selectionIconSrc}),
m("span.xs-margin-left", selectionText),
]),
]);
}
};
var EmailListItem = {
view: function(vnode) {
var email = vnode.attrs.email;
var textContent = email.text || "";
var formattedSendDate = email.sendDate.toLocaleString();
var isSelected = EMAIL_SVC.getEmailSelectedStatusById(email.id);
var isChecked = EMAIL_SVC.getEmailCheckedStatusById(email.id);
var additionalClasses = [];
if (isSelected) { additionalClasses.push("selected"); }
return m(
".component.email-list-item.clickable",
{
class: additionalClasses.join(" "),
onclick: function() { EMAIL_SVC.toggleSelectionStatusById(email.id); }
},
[
m(".header", [
m(".lhs", [
m("input[type=checkbox]", {
checked: isChecked,
onclick: function() { EMAIL_SVC.toggleCheckedStatusById(email.id); }
}),
m("span.sm-margin-left.sender", email.from.name),
m("span.sm-margin-left.tag", email.label),
]),
m(".rhs", [
m(".date", formattedSendDate)
])
]),
m(".blurb.md-margin-top", [
m("p.subject", email.subject),
// This is not a great way to do ellipsis but... it'll work for the demo.
m("p.body-preview", textContent.substring(0, ELLIPSIS_CHAR_LIMIT) + "..."),
]),
]);
}
};
Here’s what both those selection options end up looking like:
Whew, now we’ve got some reasonable selection functionality working! This is the extent to which I’m going to hook up functionality (you can imagine all the other stuff that would happen for tagging, marking emails etc).
One thing we’re still missing to make this demo a little more realistic is a simulated async call for retrieving the data that we’re going to display. Up until now we’ve just used <spooky ghost voice> global variables </spooky ghost voice> and not much else. Let’s use an mock EmailService
and implement it so that it does little more than pass our data, and pretend it did some work to fetch it.
EmailService.prototype.getEmails = function() {
var randomWaitMs = Math.floor(Math.random() * 10) * 1000;
return new Promise(function(resolve, reject) {
// After some random wait, return the global EMAILS we've always used
setTimeout(function() {
resolve(EMAILS);
}, randomWaitMs);
});
};
// ... lots of other code ...
var LoadingIndicator = {
view: function(vnode) {
var title = vnode.attrs.title || "Loading...";
return m(".component.loading-indicator", [
m("img.icon.loading-icon", {src: "static/images/icons/loader.svg"}),
m(".title", title),
]);
},
};
var InboxRHS = {
oninit: function() {
this.emailsLoaded = false;
EMAIL_SVC
.getEmails()
.then(emails => {
this.emails = emails;
this.emailsLoaded = true;
m.redraw(); // would love to only subtree render here...
});
},
view: function(vnode) {
var newCount = vnode.attrs.newCount;
var listing = m(LoadingIndicator, {title: "Loading your emails..."});
if (this.emailsLoaded) {
listing = m(EmailList, {emails: this.emails});
}
return m(".component.inbox-rhs", [
// Colored header
m(RHSHeader, {class: "inbox-blue-bg white-fg", title: "Inbox", newCount: newCount}),
// Container with buttons
m(MailPageSubNav),
// EmailList
listing,
]);
}
};
In a real application, we would go through and replace all those methods with actual remote calls or even have multiple implementations – but for this simple PoC they’re still using just local data. After hooking this up to the to the UI, we’ve got a semi-realistic UI on top of a separated service layer! I’ve added a randomized wait to most of these operations for a slightly more realistic UI feel. Here’s everything in action:
Finally, we’re done with the “build some basic functionality” part of this adventure and on to the load performance bit, which is where a bunch of often attacks frontend developers. Right now, we’ve got all our code in one javascript file (app.js
), referenced from a single index.html
(with all CSS in app.css
). Let’s start getting more sophisticated – at the very least we’ll break up and better organize the components so it’s not such a big ball of mud.
NOTE - One thing I noticed is that I forgot to implement changing of the selected left hand nav item, so I snuck that in:
var LHSNavPageList = {
view: function() {
// Mark the nav items with the correct selected property
var currentPath = m.route.get();
var navItems = LHS_NAV_ITEMS.map(function(item) {
item.selected = currentPath === item.route;
return item;
});
return m(NavItemListing, {items: navItems});
}
};
Regarding the jank fix I also went about the solution for the jank fix wrong – I missed a crucial section of the Mithril documentation, which made it very clear that I needed to use route resolvers to solve the problem of unwanted top level auto-redrawing:
However, note that because the top level component is an anonymous component, jumping from the / route to the /form route (or vice-versa) will tear down the anonymous component and recreate the DOM from scratch. If the Layout component had lifecycle methods defined, the oninit and oncreate hooks would fire on every route change. Depending on the application, this may or may not be desirable.
If you would prefer to have the Layout component be diffed and maintained intact rather than recreated from scratch, you should instead use a RouteResolver as the root object:
I made that change, along with a few improvements right before the part-2.4-add-pretend-async-loading
tag. I’m fairly certain I’m not in line with the best practices of the Mithril community, but this if anything should be instructory on how poeple might come to the langauge and use the tools provided. Check out the full unimpressive code listing at the link below:
Since at this point we all know that (load) time is money for websites, let’s look at the local performance:
With numbers:
Unthrottled
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 65.76 KB / 70.48 KB transferred | 146ms | 297ms |
Now let’s see what happens if we throttle the connection a bit with the Firefox Developer Tools, let’s use 3G:
Simulated 3G (“Regular 3G”)
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 23.50KB / 161.42KB | 1.35s | 1.43s |
So everything’s basically 2-5x worse! Load
is pretty abysmal on simulated 3G, despite how simple the site is.
We’re basically in near-ideal conditions running this locally, but we’ll use these numbers as a reference going forward and let the percentages speak to us as we make things better. These numbers are definitel not super impressive, considering the load is 100% local – server-side-rendered websites are going to be way faster than SPA for quite a while longer! Imagine if we had an even heavier framework or a more complex application!
We’re going to finally start getting to the topic of this article – we’ve just written a lot of javascript, and it’s all in the same file (yuck!). We can break apart these files and introduce some order with a app/components
directory:
$ mkdir components
# ... lots of cutting and pasting ...
$ tree components
components/
├── badge.js
├── button-group.js
├── draft-mail.js
├── drop-down-button.js
├── email-list-item.js
├── email-list.js
├── header-button.js
├── header-button.js~
├── important-mail.js
├── inbox-mail.js
├── lhs-nav-header.js
├── lhs-nav.js
├── lhs-nav-label-list.js
├── lhs-nav-page-list.js
├── loading-indicator.js
├── loading-indicator.js~
├── mail-page-subnav.js
├── main-layout.js
├── multi-select-checkbox.js
├── nav-item.js
├── nav-item-listing.js
├── pagination-controls.js
├── rhs-header.js
├── starred-mail.js
└── with-route-triggered-loader.js
0 directories, 25 files
And in index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MithMail</title>
<link rel="stylesheet" href="static/css/app.css"/>
</head>
<body>
<script src="static/vendor/mithril/1.1.6/mithril.min.js"></script>
<script src="components/badge.js"></script>
<script src="components/button-group.js"></script>
<script src="components/draft-mail.js"></script>
<script src="components/drop-down-button.js"></script>
<script src="components/email-list-item.js"></script>
<script src="components/email-list.js"></script>
<script src="components/header-button.js"></script>
<script src="components/important-mail.js"></script>
<script src="components/inbox-mail.js"></script>
<script src="components/lhs-nav-header.js"></script>
<script src="components/lhs-nav-label-list.js"></script>
<script src="components/lhs-nav-page-list.js"></script>
<script src="components/lhs-nav.js"></script>
<script src="components/loading-indicator.js"></script>
<script src="components/mail-page-subnav.js"></script>
<script src="components/main-layout.js"></script>
<script src="components/multi-select-checkbox.js"></script>
<script src="components/nav-item-listing.js"></script>
<script src="components/nav-item.js"></script>
<script src="components/pagination-controls.js"></script>
<script src="components/rhs-header.js"></script>
<script src="components/starred-mail.js"></script>
<script src="components/with-route-triggered-loader.js"></script>
<script src="app.js"></script>
</body>
</html>
This is a pretty pathological thing to do – simply splitting all your JS into numerous files and loading them at the bottom of index.html
is surely not going to increase performance, this is obvious with any idea of the limitations of HTTP/1.1. Most of our components don’t do anything until view
is called so we don’t have to worry about the order of the imports (as long as they all happen before app.js
), but as you might expect, the perf is bad:
And with numbers:
Unthrottled
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 65.76 KB / 70.48 KB transferred | 146ms | 297ms |
naive per-component script tags | 40 | 66.91 KB / 78.39 KB transferred | 330ms | 554ms |
All in all about 2x worse. How wabout 3G?
Simulated 3G (“Regular 3G”)
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 23.50KB / 161.42KB transferred | 1.35s | 1.43s |
naive per-component script tags | 40 | 66.91 KB / 66.91 KB transferred | 525ms | 919ms |
I have to admint that I was surprised by these results – things obviously do much worse in the Unthrottled case, but I expected such a naive splitting to do badly in simulated 3G as well – turns out if you’ve got lots of bandwidth but just slow transfer speeds it does ake sense to open as many connections as possible (which makes sense by itself), so they can be slow together. We can see this in the devtools snapshot:
While in this case there was a benefit on 3G, I don’t think I know anyone that would recommend just leaving it at this state – there’s still a bit more work we can do to hit the sweet spot – making use of multiple connections and reducing the number of requests so we get done quicker. There’s also another optimizationc issue – we don’t need a bunch of the files we’re loading for some of the views that aren’t shown right away. We need to do two things primarily:
This leads us to the obvious conclusion that we need to bundle tightly related code (components) so less web requests are made, and then have a way to asynchronously load bundles as needed, after user interaction. You might notice this result as the state of web dev roughly 5-10 years ago – it’s what everyone else decided as well (we won’t talk about how HTTP/2 & HTTP/3 will completely upend all this).
As you might have guessed, we’re going to tackle the first one first – we’re going to do the bundling by hand. I joined some of the files and here’s a bundling I chose:
$ tree components
components/
├── draft-mail.js
├── email-list.js
├── important-mail.js
├── inbox-mail.js
├── lhs-nav.js
├── mail-page-subnav.js
├── main-layout.js
├── rhs-header.js
├── starred-mail.js
└── with-route-triggered-loader.js
0 directories, 10 files
I didn’t spend a bunch of time thinking about it, but it should be near minimal – here’s the index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MithMail</title>
<link rel="stylesheet" href="static/css/app.css"/>
</head>
<body>
<script src="static/vendor/mithril/1.1.6/mithril.min.js"></script>
<!-- LHS nav -->
<script src="components/lhs-nav.js"></script>
<!-- Shared by components on the right hand side of the page (i.e. email listing pages) -->
<script src="components/mail-page-subnav.js"></script>
<script src="components/loading-indicator.js"></script>
<script src="components/email-list.js"></script>
<script src="components/rhs-header.js"></script>
<script src="components/with-route-triggered-loader.js"></script>
<!-- email listing pages, they're used directly from app.js -->
<script src="components/inbox-mail.js"></script>
<script src="components/draft-mail.js"></script>
<script src="components/important-mail.js"></script>
<script src="components/starred-mail.js"></script>
<!-- layout used directly from app.js, I'd *like* for it to be separate -->
<script src="components/main-layout.js"></script>
<script src="app.js"></script>
</body>
</html>
These changes, though little, represent an effort at bundling the application – here are the performance results:
Unthrottled
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 65.76 KB / 70.48 KB transferred | 146ms | 297ms |
naive per-component script tags | 40 | 66.91 KB / 78.39 KB transferred | 330ms | 554ms |
naively bundled by hand | 28 | 66.78 KB / 74.73 KB transferred | 296ms | 556ms |
OK, obviously a drop in total requests (good), but not much change across the board, and definitely still worse than the single huge app.js
case. What about 3G?
Simulated 3G (“Regular 3G”)
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 23.50KB / 161.42KB transferred | 1.35s | 1.43s |
naive per-component script tags | 40 | 66.91 KB / 66.91 KB transferred | 525ms | 919ms |
naively bundled by hand | 28 | 66.78 KB / 66.78 KB transferred | 398ms | 605ms |
Awesome, much clearer gains here in the 3G case! Reducing the number of requests helped considerably here, even with the relatively small amount of effort.
The next bit – async loading – is exactly where SystemJS comes in. SystemJS has been around a while but it still feels like the new hotness, if we consider requirejs to be the the old hotness (which has served the community faithfully for decades at this point). Like requirejs
, SystemJS is an asynchronous module loader, and is exactly what we need to… load modules asynchronously. We’re going to actually use s.js
(1.5KB!) first then graduate to the system.js
loader (3kb!).
We’re going to have to make quite a bit of code changes to utilize mithril’s asynchronous route resolution for code splitting features and SystemJS together:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MithMail</title>
<link rel="stylesheet" href="static/css/app.css"/>
</head>
<body>
<script src="static/vendor/mithril/1.1.6/mithril.min.js"></script>
<script src="static/vendor/s.min.js"></script>
<script type="text/javascript">
System.import("./app.js");
</script>
</body>
</html>
Well that’s not terrible, less <script>
tags and more a single System.import
…
On the JS side a bunch of boiler plate now has to be written… It took me a while to figure out exactly how to package SystemJS modules using System.register
– the documentation was helpful but didn’t have enough examples, or I wasn’t smart enough to figure it out quickly. I ended up looking at a similar but different documentation for the ES module loader and it finally started clicking. For example check out main-layout.js
:
main-layout.js
;
/* global System */
System.register([
// Declare dependencies
"./lhs-nav.js"
], function(exportModule, moduleCtx) {
return {
execute: function() {
// Import the module from this module's context
moduleCtx
.import("./lhs-nav.js")
.then(function(LHSNavModule) {
var LHSNav = LHSNavModule.component;
// Use all the imports to actualy do work
var MainLayout = {
view: function(vnode) {
return m(".component.app-page.important-page", [
m(LHSNav),
vnode.children,
]);
},
};
// SystemJS export
exportModule({component: MainLayout});
return Promise.resolve();
});
},
};
});
DON’T PANIC YET - writing this much boilerplate is not necessary for most applications, but I decided to include it so that people can see how things get wired together after transpilation. In the next section we’ll use a ParcelJS, which will handle all of this for us, including transpilation from import
-capable syntax which will look much better.
Outside of the fact that this was an absolute PITA to write/port, we do now have everything loading asynchronously through JS, which was the second step we wanted. Let’s see what our performance looks like:
Unthrottled
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 65.76 KB / 70.48 KB transferred | 146ms | 297ms |
naive per-component script tags | 40 | 66.91 KB / 78.39 KB transferred | 330ms | 554ms |
naively bundled by hand | 28 | 66.78 KB / 74.73 KB transferred | 296ms | 556ms |
naively bundled and async loaded | 29 | 79.36 KB / 87.62 KB transferred | 113ms | 509ms |
What a nice performance boost! As we expected we have one extra request (SystemJS’s minimal loader s.min.js
), and all the other metrics have taken a huge tumble! This is somewhat misleading though, due to the fact that the loads have just been moved into JS land, rather than from the HTML document. As you’d also expect, the DomContentLoaded got way faster since there’s much less work to do there. What about 3G?
Simulated 3G (“Regular 3G”)
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 23.50KB / 161.42KB transferred | 1.35s | 1.43s |
naive per-component script tags | 40 | 66.91 KB / 66.91 KB transferred | 525ms | 919ms |
naively bundled by hand | 28 | 66.78 KB / 66.78 KB transferred | 398ms | 605ms |
naively bundled and async loaded | 29 | 79.36 KB / 79.36 KB transferred | 252ms | 925ms |
Uh-oh, we’ve got a bit of a regression here – looks like we’re now on the other side of the spectrum of bandwidth/latency. While DomContentLoaded is the quickest we’ve seen (as it should be), load was roughly the same as when we put in tons of <script>
tags for every component individually. The best approach so far for load time was bundling by hand and starting the loading in the document…
BUT WAIT! There’s one more thing we haven’t done – we can can actually make use of the code splitting features enabled by Mithril + SystemJS – we don’t need to load the data for the Starred, and Important pages! In the code for app.js
, let’s remove the System.import
s and stop specifying those pages as dependencies (check out the commit if you’re curious how) and see what the the perf looks like then:
Unthrottled
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 65.76 KB / 70.48 KB transferred | 146ms | 297ms |
naive per-component script tags | 40 | 66.91 KB / 78.39 KB transferred | 330ms | 554ms |
naively bundled by hand | 28 | 66.78 KB / 74.73 KB transferred | 296ms | 556ms |
naively bundled and async loaded | 29 | 79.36 KB / 87.62 KB transferred | 113ms | 509ms |
naively bundled, async loaded, top level code split | 26 | 76.40 KB / 83.78 KB transferred | 112ms | 447ms |
There we go, just a bit more performance teased out there! But what about 3G?
Simulated 3G (“Regular 3G”)
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 23.50KB / 161.42KB transferred | 1.35s | 1.43s |
naive per-component script tags | 40 | 66.91 KB / 66.91 KB transferred | 525ms | 919ms |
naively bundled by hand | 28 | 66.78 KB / 66.78 KB transferred | 398ms | 605ms |
naively bundled and async loaded | 29 | 79.36 KB / 79.36 KB transferred | 252ms | 925ms |
naively bundled, async loaded, top level code split | 26 | 76.40 KB / 76.40 KB transferred | 152ms | 600ms |
Awesome! the addition of the top level code split definitely improved things, and now we’re down to loading @ 600ms on 3G (considering we started at 1.43s) for the single huge file (which might seem more efficient), this is pretty impressive! The tradeoff between bandwidth and latency can be very tricky but it looks like we’ve got a decent tradeoff going with the sum total of the tech we’ve applied so far.
Hopefully this has served as a great introduction to Mithril and SystemJS (with a smooth complexity curve ont eh way) – now we’re going to rewrite this app like sane people, and leave this kind of hard work to a real bundler – ParcelJS!
I highly doubt you’ll ever run into a setup like this at work but hopefully going through it was at least educational. Most places manage this complexity with another tool (rightfully so), and while that tool is usually Webpack, I abhor Webpack’s complexity – so we’re going to use ParcelJS for our bundling and splitting!.
NOTE halfway through writing this post, I switched to ParcelJS because Rollup just doesn’t work out of the box very well for client-side code (for example see this github issue). I don’t think it’s a mithril-only problem but Rollup requires a bunch of non-obvious plugins to build client-side code and trying to set it up from the ground up manually was a real PITA. ParcelJS, on the other hand, is very much focused on client-side code and has polished their developer ergonomics for the usecase. Rollup is almost certainly the more powerful/general tool but after using Parcel on a few other projects and seeing how decent it can be I have no desire to muck with Rollup (I spent ~6 hours spread over 2 days trying to set it all up only to realize that no one is going to do this for the non-standard stack that is Mithril+SystemJS+Rollup).
The top two trusted options (as far as I know) in the frontend world for transpiling, bundling, and building javascript code are Webpack and Parcel. Webpack has come a long way, but is still known these days for it’s complexity, though it’s basically become the default. Alternatives like ParcelJS have risen to stardom almost purely no the promise of zero config setups, which I think is due to the distaste that webpack can sometimes leave. Parcel represents a somewhat simpler approach (you should have seen what the docs of early versions of Webpack looked like), and I’m a huge fan of the fact that it has an easy path to integrating SystemJS, so it’s what we’re going to use here. It’s got most of the impressive features of Webpack as well, like hot module reloading (granted you use SystemJS and systemjs-hot-reloader
, see the github issue for details) and code splitting as well as tree shaking.
I also am somewhat biased towards SystemJS and solutions that use it from my time with JSPM the package manager. The video of an early demo of JSPM blew my mind and prompted almost immediate use of it, and I’ve been very happy with it in the past. It’s built on SystemJS and while using JSPM I got a chance to get used to SystemJS (the next async loader I had the most experience with was requirejs), and found it so easy to use I even hacked on it.
Anyway, enough about me, let’s start transitioning this codebase to take advantage of what ParcelJS has to offer. First we’re going to need to npm install -D parcel-bundler
(or yarn
if you like). Then we can just simply run Parcel on index.html
:
$ ./node_modules/.bin/parcel index.html
Server running at http://localhost:1234
✨ Built in 1.97s.
After that, we should be able to replace our jury-rigged build setup (running build-watch
and server
) with parcel
:
"scripts": {
... other stuff ...
"parcel": "parcel index.html"
If you’re actually following along you might have noticed that while this will serve index.html
it won’t actually work – you’ll get an error about m
(mithril) not being defined. Well while Parcel does a great job with from-scratch projects, we need to a a bit of configuration and porting to make it work with the monstrosity we’ve created. Since parcel does transpilation automatically for us, we can stop using the God-awful ES5 syntax we’ve been using and let some import
s and arrow functions into our lives! Sweet release at last. Here’s an example of what main-layout.js
would look like after being transformed from that manual System.register
mess:
import m from "../static/vendor/mithril/1.1.6/mithril.min.js";
import LHSNav from "./lhs-nav.js";
// Use all the imports to actualy do work
var MainLayout = {
view: function(vnode) {
return m(".component.app-page.important-page", [
m(LHSNav),
vnode.children,
]);
},
};
export default MainLayout;
So much better! Well I’m not going to go into too much detail, but let’s say I spent some time going through the code and making everything look much better.
But what does our performance look like now?
Unthrottled
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 65.76 KB / 70.48 KB transferred | 146ms | 297ms |
naive per-component script tags | 40 | 66.91 KB / 78.39 KB transferred | 330ms | 554ms |
naively bundled by hand | 28 | 66.78 KB / 74.73 KB transferred | 296ms | 556ms |
naively bundled and async loaded | 29 | 79.36 KB / 87.62 KB transferred | 113ms | 509ms |
naively bundled, async loaded, top level code split | 26 | 76.40 KB / 83.78 KB transferred | 112ms | 447ms |
ParcelJS (code split) | 18 | 103.53 KB / 111.21 KB transferred | 112ms | 200ms |
Awesome, better to drastically better numbers across the board! We’re using proper tooling and getting gains with no effort! And what about… 3G?
Simulated 3G (“Regular 3G”)
Method | total requests | transferred (KB) | DOMContentLoaded | Load |
---|---|---|---|---|
single huge app.js |
17 | 23.50KB / 161.42KB transferred | 1.35s | 1.43s |
naive per-component script tags | 40 | 66.91 KB / 66.91 KB transferred | 525ms | 919ms |
naively bundled by hand | 28 | 66.78 KB / 66.78 KB transferred | 398ms | 605ms |
naively bundled and async loaded | 29 | 79.36 KB / 79.36 KB transferred | 252ms | 925ms |
naively bundled, async loaded, top level code split | 26 | 76.40 KB / 76.40 KB transferred | 152ms | 600ms |
ParcelJS (code split) | 18 | 103.53 KB / 103.53 KB transferred | 273ms | 495ms |
Awesome, better results here too! much better load time, though DOMContent loaded jumped up a bit (index.html
is loading app.js
now, which is bigger than just s.min.js
)! We did see some nice benefits in total # of requests as well as load. Another great thing that Parcel does is that it also allows you to load assets as part of a JS file, which resolves to a URL. This is super useful, and also tips parcel off into making better bundles for you.
This means that instead of loading all the little SVGs I use for icons separately (contributing to that total request count of 18), I can tie them to the pages they’re used on and sacrifice a little initial load time for a more complete load and less flickering.
Now we’ve got something somewhat resembling a production application, and we took all the steps to get here ourselves. Mission accomplished!
BTW, dont’ forget that all of this is still slower than just sever-rendering, but it’s nice that we’re getting so close, while affording teams that work on backend and frontend to work and iterate separately.
If you don’t know about Typescript, you should take a look. It’s a light-handed approach to making your Javascript code a little bit safer. The best thing about Typescript is the bugs you never write. Yes you won’t move as fast as if you didn’t write any type annotations, but you will write drastically less bugs, which is almost more important, because a hard to track down bug can cause lots of frustration and wasted time.
ParcelJS supports typescript and so does Mithril. This post has gotten absurdly long so I’ll leave it as an excersize for the reader, but here are two hints:
.ts
yarn add @types/mithril
For more on how to integrate Typescript with mithril, check out the mithril.d.ts repo. Here’s what one of the components looks like when updated:
Overall, the experience of building the application was really cathartic, and I found that most of the time when I built up the pieces of the UI with Mithril, the code I wrote just worked. There wasn’t much to keep in my head, and the interfaces between components were pretty well defined and easy to reason about – and I wasn’t even using Typescript or any typechecker to avoid errors.n
Hopefully you enjoyed this fun romp through Mithril and some of the other fun related technologies!