Upgrading Ele.me to Progressive Web App
Huge JavaScript Re-Startup Cost
According to the profile, most of the time (900ms) before hitting the first paint is spent on evaluating JavaScript. Half is on dependencies including Vue Runtime, components, libraries etc., another half is on actual Vue starting-up and mounting. Because all UI rendering is depended on JavaScript/Vue, all of the critical scripts remain guiltily parser-blocking. I’m by no means blaming JavaScript or Vue’s overheads here, It’s just a tradeoff when we need this layer of abstraction in engineering.
In SPA, JavaScript Start-up Cost is amortized during the whole lifecycle. Parsing/Compiling for each script is only once, many heavy executing can be only once. The big JavaScript objects like Vue’s ViewModels and Virtual DOM can be kept in memory and reused as much as you want. This is not the case in MPA however.
Could Browser Caches Help?
Yes and no.
V8 introduced code caching, a way to store a local copy of compiled code so fetching, parsing and compilation could all be skipped next time. As Addy Osmani mentioned in JavaScript Start-up Performance, scripts stored in Cache Storage via Service Worker could trigger code caching in just the first execution.
Another browser cache you might hear of is “Back-Forward Cache”, or bfcache. The name varies, like Opera’s “Fast History Navigation” or WebKit’s “Page Cache”. The idea is that browsers can keep the previous page live in memory, i.e. DOM/JS states, instead of destroying everything. In fact, this idea works very well for MPA. You can try every traditional Multi-page websites in iOS Safari and observe an instantaneously loading when back/forward. (With browser UI/Gesture or with hyperlink can have a slight difference though.)
Unfortunately, Chrome has no this kind of in-memory bfcache currently concerning to memory consumption and its multi-process architecture. It just leverages HTTP disk cache to simplify the loading pipeline, almost everything still needs to be redone. More details and discussions can be seen here.
Striving for Perceived Performance
Although the reality is dark, we don’t want to give up so easily. One optimization we try to do is to render DOM nodes/create Virtual DOM nodes as less as possible to improve the Time-To-Interactive. While another opportunity we see is to play tricks on perceived performance.
Owen Campbell-Moore have written a great post “Reactive Web Design: The secret to building web apps that feel amazing” covering both “Instant loads with skeleton screens” and “Stable loads via predefined sizes on elements” to improve perceived performance and user experience. Yes, we actually used both.
What about we showing the end result after these optimizations first before entering technical nitty gritty? There you go!
So fast that you can not see the pulsing Skeleton Screen clearly? Here is a version showing how it looks like under 10 times slower CPU.
This is a much better UX, right? Even we have slow navigation in slow devices, at least the UI is stable, consistent and always responding. So how we get there?
Pre-rendering Skeleton Screen with Vue at Build-Time
As you might have guessed, the Skeleton Screen that consists of markups, styles, and images is inlined into *.html
of each routes. So they can be cached by Service Worker, be loaded instantly, and be rendered independently with any JavaScript.
We don’t want to manually craft each Skeleton Screen for each route. It’s a tedious job and we have to manually sync every change between Skeleton Screens and the actual UI components (Yes we treat every route as just a Vue component). But think about it, Skeleton Screen is just a blank version of a page into which information is gradually loaded. What if we bake the Skeleton Screen into the actual UI component as just a loading state so we can render Skeleton Screen out directly from it without the issue of syncing?
Thanks to the versatility of Vue, we can actually realize it with Vue.js Server-Side Rendering. Instead of using it on a real server, we use it at build time to pre-render Vue components to strings and injected them into HTML templates. You should write code that is “universal” to make Vue components can be executed in Node. But for routes that depend heavily on some DOM/BOM-specific 3rd-party modules, we have to make a separated *.shell.vue
to temporarily work around it.
Fast Skeleton Painting…
Having markups in *.html
doesn't mean that they will be painted fast, you have to make sure the Critical Rendering Path is optimized for that. Many developers believed that putting script tags in the end of the body is sufficient for getting content painted before executing scripts. This might be true for browsers supporting rendering an incomplete DOM tree (e.g. streaming render), But browsers might not do that in mobile concerning slower hardwares, battery, and heats. Although we are told that script tags with async
or defer
is not parser-blocking, it doesn't mean we can get content painted before executing scripts in reality.