Building a progressive web app with Vue.js and OnsenUI

Recently, we have been doing some proof of concept work on a PWA for our web site. The immediate goal is to make our mobile experience faster, but looking toward the future, we could potentially invest in this technology as a replacement for our current mobile apps if PWAs continue to receive more support on the major platforms.

Technology Selection

Since 2015, our web site has been using jQuery for our front-end applications, using RequireJS to manage dependencies. For various reasons, we have become increasingly dissatisfied with this approach, and we’ve been looking for opportunities to adopt one of the modern reactive Javascript frameworks.

We had an internal project that needed a nice mobile front-end, so we decided to use that as an opportunity to experiment with some newer technology. After reading up on libraries like React, Angular, and Vue.js, we opted for Vue.js, which looks to improve upon the progress that React and Angular had made, and it has some nice momentum in the industry.

We needed a UI library so we could give our application a native feel so as to be more familiar to our target users. Vuetify is material-centric, so it might feel foreign to our iOS users. Ionic 4 is promising, but it’s not out of beta yet. Onsen UI emerged as our leading candidate, providing the cross-platform native look, along with the maturity and stability we need for a production application.

Our experience using Vue.js and OnsenUI in this internal app was positive enough that we decided it was worth using the same technology to build a proof-of-concept PWA.

Design considerations

Before we got too deep into the implementation, we had a few things to consider about how our PWA was going to interact with the existing responsive web site.

One fundamental decision we made is that the PWA will exist in parallel with our existing responsive web site. Desktop users will continue to see the existing site. It’s likely that we would continue serving the responsive site to tablet users, and the existing site will be a fallback for mobile browsers that don’t support PWAs.

URL structure

Surveying some existing PWAs, we have seen a few different approaches taken with the structure of the URLs used by the PWA.

  • use the same URLs as existing site (this requires that you have employed user agent sniffing to install the service worker
  • deploy the PWA on another subdomain, e.g. “https://mobile.example.com” with the desktop site being on “https://www.example.com”
  • use a subdirectory for the PWA, and restrict the scope of the PWA to that subdirectory, e.g. “https://www.example.com/pwa/”
  • use a single point-of-entry URL, with fragments for routing, e.g. https://www.example.com/pwa/#page1

Ultimately, we opted to use the same URLs as the existing site. This makes sharing links as seamless as possible, with no need to maintain a bunch of redirects between the existing site and the PWA (e.g., PWA user shares a URL with a desktop user, we would have to redirect the desktop user to the appropriate URL on the existing site, and vice-versa).

Service worker delivery mechanism

Given that we want to use the same URLs as the existing site, we have to consider how and when we want to deliver the service worker to the client for registration.

  • deliver on any URL to all users – this would mean that desktop users would be running the PWA, and the PWA would have to provide an appropriate UI, or it would have to be able to retrieve the existing web site and display it in its App Shell.
  • deliver on any URL, but use user-agent sniffing to detect compatible mobile devices – this way all mobile users would automatically use the PWA, but we don’t have to build desktop support into the PWA.
  • deliver only at a designated “installation” URL – in this scenario, you would direct mobile users to open a specific link to install the PWA; adoption would be much lower than if we just registered the PWA on every URL, and most users would never see the benefits of the PWA.

Ultimately, we want to deliver the service worker at all URLs on the site, but just for mobile users. While we are testing the concept, however, we will use a designated installation URL. And because we’re using the same URLs for the PWA as the existing site, there are some scoping challenges inherent with this decision; we will get to those later.

Note — if you are using a separate subdomain, subdirectory, or single point-of-entry for your URL structure, this isn’t as important a design question. I would just serve the PWA at all URLs under the subdomain, subdirectory, or point-of-entry.

Challenges

vue-router and onsen-navigator integration

This was more difficult than it really needed to be. We pulled inspiration from this forum thread. This code needed a lot of modifications to make it work for our application, and would likely need a lot of changes for your application. It makes an assumption that URL structure is related to the depth of your navigation stack. That isn’t true on our site; hence we had to rework this integration code.

But the core concept is sound – put the page stack into the vuex store (preferably in its own module) and keep it in sync with the router using logic in the beforeEach() navigation guard.

using a designated “installation” URL and higher scope

We opted to use the same URLs in our PWA as are used on our existing site. And at least initially, we want to provide an installation URL for the PWA — so you only get the PWA if you explicitly visit this installation URL.

Because we don’t like throwing content like Javascript into the root directory of our web site, we put the service worker and the rest of the PWA into a subdirectory of our site, e.g. http://www.example.com/pwa/index.html. But because we want the PWA to apply to all URLs on the site, we need it to use a scope that is higher up than the path to the service worker Javascript.

Doing this proved to be a little more complicated than we initially thought. Assuming you created your PWA using @vue/cli-plugin-pwa, here are the steps we took to get this to work.

Step 1: define the scope as “/” in manifest.json:

Step 2: add the Service-Worker-Allowed: / header to the HTTP response for your install page (you only need it on the install page, not on every page on the site). You’ll need to do this in your web server configuration, or if you are generating the install page dynamically, you may be able to output the header there.

Step 3: Set the scope in registerServiceWorker.js:

Note 1: you need the npm library register-service-worker 1.6.2 or up; earlier versions did not support setting the scope like this.

Note 2: your registerServiceWorker.js may look different from this one. I added some additional console logging.

Step 4: in vue.config.js, configure workbox to use InjectManifest mode (docs):

Step 5: Create a simple service-worker.js in the src/ directory. If you’re run npm run serve or npm run build once before changing the workboxPluginMode, you should have a service worker auto-generated in the dist/ directory. It will look something like this:

Step 6: configure the workbox routing handler to return the PWA’s index page from cache for all ‘document’ requests. Add this to the end of service-worker.js:

Without configuring workbox in this way, your PWA won’t load when you open URLs in your browser, even if those URLs are within the defined scope. If you were to open the URL https://www.example.com/foo/ after installing the service worker, you would just see the existing web site at /foo/.

The fact that your service worker is installed doesn’t mean that your PWA’s full app code loads up for all URLs in the scope. It just gives your PWA an opportunity to intercept URL requests within its scope and handle them differently. You need to use that hook to load up the rest of your app code, where the PWA’s routing, navigation, and UI lives.

So whenever the browser is making a request where event.request.destination === 'document' (which happens when the browser navigates directly to a URL within the PWA’s scope), the service worker just returns the markup for the index page from cache (we know it was cached because it was cached as part of the initial service worker installation; @vue/cli-plugin-pwa ensures that index.html is part of the precache manifest.

Once you’ve loaded the PWA, a user can follow links within the app, and as long as you’re using the app’s router to go from URL to URL, the PWA will remain loaded, and you don’t have to think about this.

Step 7: redirect from the PWA index page to the start URL

In your vue-router configuration, make sure you set up a redirect so that when the user hits your PWA install URL, he will get redirected to a page with actual content (remember that your PWA uses the same URLs as the existing site, and the existing site didn’t have real content at ‘/pwa/dist/index.html’.

You could probably also create a special Vue component for this route that would say something to the effect of “Thanks for installing. Click here to start using the site.”.

devising a routing/navigation strategy for deep links

This one really forced us to step back and look at how we wanted our app to behave as the user navigates through the app.

We modeled the navigation behavior after our existing mobile app:

  • app opens to a “main window”
  • the main window has a menu and a main view, which defaults to the home view
  • user selections in the app menu cause the main view to be replaced
  • clicking in the main view opens a new window on the stack
  • clicking in the new window opens another window on the stack, and so on

Typically, the main window will have a list of headlines, and the user will tap a headline to open the full story. The key takeaway here is that in the app, when a user is viewing a story, it is always within a window on a stack of size n >= 2. The bottom screen on that stack is our home screen (or one of the other main views available in the menu).

So what happens if a user finds a link to one of our stories via a google search? Or a shared link on Facebook? If that user has our PWA installed, he will click on the link, and our PWA will load up the content. We call this a “deep link” to the story.

In order for the navigation in this deep link scenario to be consistent with the “launch from the home screen” scenario, we need to artificially create the navigation stack by putting a main window with the home view on the bottom of the stack and opening the deep link in a window above the main window.

We need to address this through the router, since we are syncing the vue-router and the onsen navigator.

What we ended up doing was adding meta data to the routes to indicate the UI context for each route. For routes like the home view, the context is defined as “root”. For the route that opens our stories, we set this context to “window”.

Then in the beforeEach() navigation guard function, we check the uiContext . If it is “window”, we check to see if the stack is empty. If it is, we save the current route as a pending route, and then we cancel the current navigation and navigate instead to “/”. Once the vue component for the “/” route loads in, we’ll navigate to the pending route.

Here’s a rough idea of what our routes look like:

Notice how we get the uiContext from the meta data associated with the routes. When the uiContext is “window”, we check to see if the stack is empty (using the navigator module we’ve built for our vuex store. Here’s the code for the navigator module:

Finally, when the HomeView component is mounted, we’ll check the store for a pending route; if one exists, we’ll clear it from the store, and we’ll push it onto the router:

Note that we had to push the pending route onto the router inside a 0ms timeout. When we tried to call it directly, the onsen navigator didn’t give us a back button on the new view.

Wrapup

So at this point, we have a PWA that can be selectively installed by opening a specific URL, but has a scope that covers the entire site.

It uses a URL structure that matches the existing site, so as the user navigates through the application, the URL displayed by the browser is the “real” URL for that content. If the user emails that link to a friend, the friend could open that URL in a desktop browser and see the same content, formatted appropriately for the desktop. Conversely, if somebody shares an asset on our site with a friend who is using the PWA, the asset will open directly in the PWA and display as expected.

Our next steps will be to flesh out the UI for the PWA so it supports all of our various content types and do some internal testing. After that, we may invite the public to use the PWA, and if all goes well, we would change the way we deliver the service worker to deliver on all URLs, using user agent sniffing to deliver to devices with PWA support.

Leave a Reply

Your email address will not be published. Required fields are marked *